moved rrweb into packages/rrweb
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tsconfig/svelte": "^1.0.0",
|
||||
"rrweb": "^1.0.1"
|
||||
"rrweb": "^1.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
|
||||
8
packages/rrweb-snapshot/.gitignore
vendored
Normal file
8
packages/rrweb-snapshot/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.vscode
|
||||
node_modules
|
||||
package-lock.json
|
||||
build
|
||||
dist
|
||||
es
|
||||
lib
|
||||
temp
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb-snapshot/graphs/contributors) and SmartX Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
15
packages/rrweb/.gitignore
vendored
Normal file
15
packages/rrweb/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
.vscode
|
||||
.idea
|
||||
node_modules
|
||||
package-lock.json
|
||||
# yarn.lock
|
||||
build
|
||||
dist
|
||||
es
|
||||
lib
|
||||
|
||||
temp
|
||||
|
||||
*.log
|
||||
|
||||
.env
|
||||
12
packages/rrweb/.release-it.json
Normal file
12
packages/rrweb/.release-it.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"non-interactive": true,
|
||||
"hooks": {
|
||||
"before:init": ["npm run bundle", "npm run typings"]
|
||||
},
|
||||
"git": {
|
||||
"requireCleanWorkingDir": false
|
||||
},
|
||||
"github": {
|
||||
"release": true
|
||||
}
|
||||
}
|
||||
79
packages/rrweb/package.json
Normal file
79
packages/rrweb/package.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "rrweb",
|
||||
"version": "1.0.2",
|
||||
"description": "record and replay the web",
|
||||
"scripts": {
|
||||
"prepare": "npm run prepack",
|
||||
"prepack": "npm run bundle",
|
||||
"test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/register test/**.test.ts",
|
||||
"test:headless": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true PUPPETEER_HEADLESS=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/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",
|
||||
"typings": "tsc -d --declarationDir typings",
|
||||
"check-types": "tsc -noEmit"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@github.com/rrweb-io/rrweb.git"
|
||||
},
|
||||
"keywords": [
|
||||
"rrweb"
|
||||
],
|
||||
"main": "lib/rrweb-all.js",
|
||||
"module": "es/rrweb/src/entries/all.js",
|
||||
"unpkg": "dist/rrweb.js",
|
||||
"sideEffects": false,
|
||||
"typings": "typings/entries/all.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"lib",
|
||||
"es",
|
||||
"typings"
|
||||
],
|
||||
"author": "yanzhen@smartx.com",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/rrweb-io/rrweb/issues"
|
||||
},
|
||||
"homepage": "https://github.com/rrweb-io/rrweb#readme",
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.1.6",
|
||||
"@types/inquirer": "0.0.43",
|
||||
"@types/jsdom": "^16.2.12",
|
||||
"@types/mocha": "^5.2.5",
|
||||
"@types/node": "^12.20.16",
|
||||
"@types/prettier": "^2.3.2",
|
||||
"@types/puppeteer": "^5.4.3",
|
||||
"chai": "^4.2.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"fast-mhtml": "^1.1.9",
|
||||
"ignore-styles": "^5.0.1",
|
||||
"inquirer": "^6.2.1",
|
||||
"jest-snapshot": "^23.6.0",
|
||||
"jsdom": "^16.6.0",
|
||||
"jsdom-global": "^3.0.2",
|
||||
"mocha": "^5.2.0",
|
||||
"prettier": "2.2.1",
|
||||
"puppeteer": "^9.1.1",
|
||||
"rollup": "^2.3.3",
|
||||
"rollup-plugin-commonjs": "^9.2.0",
|
||||
"rollup-plugin-node-resolve": "^3.4.0",
|
||||
"rollup-plugin-postcss": "^3.1.1",
|
||||
"rollup-plugin-rename-node-modules": "^1.1.0",
|
||||
"rollup-plugin-terser": "^5.3.0",
|
||||
"rollup-plugin-typescript": "^1.0.0",
|
||||
"ts-node": "^7.0.1",
|
||||
"tslib": "^1.9.3",
|
||||
"tslint": "^4.5.1",
|
||||
"typescript": "^3.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/css-font-loading-module": "0.0.4",
|
||||
"@xstate/fsm": "^1.4.0",
|
||||
"fflate": "^0.4.4",
|
||||
"mitt": "^1.1.3",
|
||||
"rrweb-snapshot": "^1.1.7"
|
||||
}
|
||||
}
|
||||
211
packages/rrweb/rollup.config.js
Normal file
211
packages/rrweb/rollup.config.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import typescript from 'rollup-plugin-typescript';
|
||||
import resolve from 'rollup-plugin-node-resolve';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import renameNodeModules from 'rollup-plugin-rename-node-modules';
|
||||
import pkg from './package.json';
|
||||
|
||||
function toRecordPath(path) {
|
||||
return path
|
||||
.replace(/^([\w]+)\//, '$1/record/')
|
||||
.replace('rrweb', 'rrweb-record');
|
||||
}
|
||||
|
||||
function toRecordPackPath(path) {
|
||||
return path
|
||||
.replace(/^([\w]+)\//, '$1/record/')
|
||||
.replace('rrweb', 'rrweb-record-pack');
|
||||
}
|
||||
|
||||
function toReplayPath(path) {
|
||||
return path
|
||||
.replace(/^([\w]+)\//, '$1/replay/')
|
||||
.replace('rrweb', 'rrweb-replay');
|
||||
}
|
||||
|
||||
function toReplayUnpackPath(path) {
|
||||
return path
|
||||
.replace(/^([\w]+)\//, '$1/replay/')
|
||||
.replace('rrweb', 'rrweb-replay-unpack');
|
||||
}
|
||||
|
||||
function toAllPath(path) {
|
||||
return path.replace('rrweb', 'rrweb-all');
|
||||
}
|
||||
|
||||
function toPluginPath(pluginName, stage) {
|
||||
return (path) =>
|
||||
path
|
||||
.replace(/^([\w]+)\//, '$1/plugins/')
|
||||
.replace('rrweb', `${pluginName}-${stage}`);
|
||||
}
|
||||
|
||||
function toMinPath(path) {
|
||||
return path.replace(/\.js$/, '.min.js');
|
||||
}
|
||||
|
||||
const baseConfigs = [
|
||||
// record only
|
||||
{
|
||||
input: './src/record/index.ts',
|
||||
name: 'rrwebRecord',
|
||||
pathFn: toRecordPath,
|
||||
},
|
||||
// record and pack
|
||||
{
|
||||
input: './src/entries/record-pack.ts',
|
||||
name: 'rrwebRecord',
|
||||
pathFn: toRecordPackPath,
|
||||
},
|
||||
// replay only
|
||||
{
|
||||
input: './src/replay/index.ts',
|
||||
name: 'rrwebReplay',
|
||||
pathFn: toReplayPath,
|
||||
},
|
||||
// replay and unpack
|
||||
{
|
||||
input: './src/entries/replay-unpack.ts',
|
||||
name: 'rrwebReplay',
|
||||
pathFn: toReplayUnpackPath,
|
||||
},
|
||||
// record and replay
|
||||
{
|
||||
input: './src/index.ts',
|
||||
name: 'rrweb',
|
||||
pathFn: (p) => p,
|
||||
},
|
||||
// all in one
|
||||
{
|
||||
input: './src/entries/all.ts',
|
||||
name: 'rrweb',
|
||||
pathFn: toAllPath,
|
||||
esm: true,
|
||||
},
|
||||
// plugins
|
||||
{
|
||||
input: './src/plugins/console/record/index.ts',
|
||||
name: 'rrwebConsoleRecord',
|
||||
pathFn: toPluginPath('console', 'record'),
|
||||
},
|
||||
{
|
||||
input: './src/plugins/console/replay/index.ts',
|
||||
name: 'rrwebConsoleReplay',
|
||||
pathFn: toPluginPath('console', 'replay'),
|
||||
},
|
||||
];
|
||||
|
||||
let configs = [];
|
||||
|
||||
for (const c of baseConfigs) {
|
||||
const basePlugins = [resolve({ browser: true }), typescript()];
|
||||
const plugins = basePlugins.concat(
|
||||
postcss({
|
||||
extract: false,
|
||||
inject: false,
|
||||
}),
|
||||
);
|
||||
// browser
|
||||
configs.push({
|
||||
input: c.input,
|
||||
plugins,
|
||||
output: [
|
||||
{
|
||||
name: c.name,
|
||||
format: 'iife',
|
||||
file: c.pathFn(pkg.unpkg),
|
||||
},
|
||||
],
|
||||
});
|
||||
// browser + minify
|
||||
configs.push({
|
||||
input: c.input,
|
||||
plugins: basePlugins.concat(
|
||||
postcss({
|
||||
extract: true,
|
||||
minimize: true,
|
||||
sourceMap: true,
|
||||
}),
|
||||
terser(),
|
||||
),
|
||||
output: [
|
||||
{
|
||||
name: c.name,
|
||||
format: 'iife',
|
||||
file: toMinPath(c.pathFn(pkg.unpkg)),
|
||||
sourcemap: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// CommonJS
|
||||
configs.push({
|
||||
input: c.input,
|
||||
plugins,
|
||||
output: [
|
||||
{
|
||||
format: 'cjs',
|
||||
file: c.pathFn('lib/rrweb.js'),
|
||||
},
|
||||
],
|
||||
});
|
||||
if (c.esm) {
|
||||
// ES module
|
||||
configs.push({
|
||||
input: c.input,
|
||||
plugins,
|
||||
preserveModules: true,
|
||||
output: [
|
||||
{
|
||||
format: 'esm',
|
||||
dir: 'es/rrweb',
|
||||
plugins: [renameNodeModules('ext')],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.BROWSER_ONLY) {
|
||||
const browserOnlyBaseConfigs = [
|
||||
{
|
||||
input: './src/index.ts',
|
||||
name: 'rrweb',
|
||||
pathFn: (p) => p,
|
||||
},
|
||||
{
|
||||
input: './src/plugins/console/record/index.ts',
|
||||
name: 'rrwebConsoleRecord',
|
||||
pathFn: toPluginPath('console', 'record'),
|
||||
},
|
||||
];
|
||||
|
||||
configs = [];
|
||||
|
||||
for (const c of browserOnlyBaseConfigs) {
|
||||
const plugins = [
|
||||
resolve({ browser: true }),
|
||||
typescript(),
|
||||
postcss({
|
||||
extract: false,
|
||||
inject: false,
|
||||
sourceMap: true,
|
||||
}),
|
||||
terser(),
|
||||
];
|
||||
|
||||
configs.push({
|
||||
input: c.input,
|
||||
plugins,
|
||||
output: [
|
||||
{
|
||||
name: c.name,
|
||||
format: 'iife',
|
||||
file: toMinPath(c.pathFn(pkg.unpkg)),
|
||||
sourcemap: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default configs;
|
||||
200
packages/rrweb/scripts/repl.ts
Normal file
200
packages/rrweb/scripts/repl.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/* tslint:disable: no-console */
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as EventEmitter from 'events';
|
||||
import * as inquirer from 'inquirer';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import { eventWithTime } from '../src/types';
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
function getCode(): string {
|
||||
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
|
||||
return fs.readFileSync(bundlePath, 'utf8');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const code = getCode();
|
||||
let events: eventWithTime[] = [];
|
||||
|
||||
start();
|
||||
|
||||
async function start() {
|
||||
events = [];
|
||||
const { url } = await inquirer.prompt<{ url: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'url',
|
||||
message:
|
||||
'Enter the url you want to record, e.g https://react-redux.realworld.io: ',
|
||||
},
|
||||
]);
|
||||
|
||||
console.log(`Going to open ${url}...`);
|
||||
await record(url);
|
||||
console.log('Ready to record. You can do any interaction on the page.');
|
||||
|
||||
const { shouldReplay } = await inquirer.prompt<{ shouldReplay: boolean }>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldReplay',
|
||||
message: `Once you want to finish the recording, enter 'y' to start replay: `,
|
||||
},
|
||||
]);
|
||||
|
||||
emitter.emit('done', shouldReplay);
|
||||
|
||||
const { shouldStore } = await inquirer.prompt<{ shouldStore: boolean }>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldStore',
|
||||
message: `Persistently store these recorded events?`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (shouldStore) {
|
||||
saveEvents();
|
||||
}
|
||||
|
||||
const { shouldRecordAnother } = await inquirer.prompt<{
|
||||
shouldRecordAnother: boolean;
|
||||
}>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldRecordAnother',
|
||||
message: 'Record another one?',
|
||||
},
|
||||
]);
|
||||
|
||||
if (shouldRecordAnother) {
|
||||
start();
|
||||
} else {
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
async function record(url: string) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
defaultViewport: {
|
||||
width: 1600,
|
||||
height: 900,
|
||||
},
|
||||
args: [
|
||||
'--start-maximized',
|
||||
'--ignore-certificate-errors',
|
||||
'--no-sandbox',
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
await page.exposeFunction('_replLog', (event: eventWithTime) => {
|
||||
events.push(event);
|
||||
});
|
||||
await page.evaluate(`;${code}
|
||||
window.__IS_RECORDING__ = true
|
||||
rrweb.record({
|
||||
emit: event => window._replLog(event),
|
||||
recordCanvas: true,
|
||||
collectFonts: true
|
||||
});
|
||||
`);
|
||||
page.on('framenavigated', async () => {
|
||||
const isRecording = await page.evaluate('window.__IS_RECORDING__');
|
||||
if (!isRecording) {
|
||||
await page.evaluate(`;${code}
|
||||
window.__IS_RECORDING__ = true
|
||||
rrweb.record({
|
||||
emit: event => window._replLog(event),
|
||||
recordCanvas: true,
|
||||
collectFonts: true
|
||||
});
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
emitter.once('done', async (shouldReplay) => {
|
||||
await browser.close();
|
||||
if (shouldReplay) {
|
||||
await replay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function replay() {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
defaultViewport: {
|
||||
width: 1600,
|
||||
height: 900,
|
||||
},
|
||||
args: ['--start-maximized', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.addStyleTag({
|
||||
path: path.resolve(__dirname, '../dist/rrweb.min.css'),
|
||||
});
|
||||
await page.evaluate(`${code}
|
||||
const events = ${JSON.stringify(events)};
|
||||
const replayer = new rrweb.Replayer(events);
|
||||
replayer.play();
|
||||
`);
|
||||
}
|
||||
|
||||
function saveEvents() {
|
||||
const tempFolder = path.join(__dirname, '../temp');
|
||||
console.log(tempFolder);
|
||||
|
||||
if (!fs.existsSync(tempFolder)) {
|
||||
fs.mkdirSync(tempFolder);
|
||||
}
|
||||
const time = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-|:]/g, '_')
|
||||
.replace(/\..+/, '');
|
||||
const fileName = `replay_${time}.html`;
|
||||
const content = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Record @${time}</title>
|
||||
<link rel="stylesheet" href="../dist/rrweb.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<script src="../dist/rrweb.min.js"></script>
|
||||
<script>
|
||||
/*<!--*/
|
||||
const events = ${JSON.stringify(events).replace(
|
||||
/<\/script>/g,
|
||||
'<\\/script>',
|
||||
)};
|
||||
/*-->*/
|
||||
const replayer = new rrweb.Replayer(events, {
|
||||
UNSAFE_replayCanvas: true
|
||||
});
|
||||
replayer.play();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const savePath = path.resolve(tempFolder, fileName);
|
||||
fs.writeFileSync(savePath, content);
|
||||
console.log(`Saved at ${savePath}`);
|
||||
}
|
||||
|
||||
process
|
||||
.on('uncaughtException', (error) => {
|
||||
console.error(error);
|
||||
})
|
||||
.on('unhandledRejection', (error) => {
|
||||
console.error(error);
|
||||
});
|
||||
})();
|
||||
4
packages/rrweb/src/entries/all.ts
Normal file
4
packages/rrweb/src/entries/all.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from '../index';
|
||||
export * from '../packer';
|
||||
export * from '../plugins/console/record';
|
||||
export * from '../plugins/console/replay';
|
||||
2
packages/rrweb/src/entries/record-pack.ts
Normal file
2
packages/rrweb/src/entries/record-pack.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../record/index';
|
||||
export * from '../packer/pack';
|
||||
2
packages/rrweb/src/entries/replay-unpack.ts
Normal file
2
packages/rrweb/src/entries/replay-unpack.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../replay';
|
||||
export * from '../packer/unpack';
|
||||
23
packages/rrweb/src/index.ts
Normal file
23
packages/rrweb/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import record from './record';
|
||||
import { Replayer } from './replay';
|
||||
import { _mirror } from './utils';
|
||||
import * as utils from './utils';
|
||||
|
||||
export {
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
MouseInteractions,
|
||||
ReplayerEvents,
|
||||
} from './types';
|
||||
|
||||
const { addCustomEvent } = record;
|
||||
const { freezePage } = record;
|
||||
|
||||
export {
|
||||
record,
|
||||
addCustomEvent,
|
||||
freezePage,
|
||||
Replayer,
|
||||
_mirror as mirror,
|
||||
utils,
|
||||
};
|
||||
10
packages/rrweb/src/packer/base.ts
Normal file
10
packages/rrweb/src/packer/base.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { eventWithTime } from '../types';
|
||||
|
||||
export type PackFn = (event: eventWithTime) => string;
|
||||
export type UnpackFn = (raw: string) => eventWithTime;
|
||||
|
||||
export type eventWithTimeAndPacker = eventWithTime & {
|
||||
v: string;
|
||||
};
|
||||
|
||||
export const MARK = 'v1';
|
||||
2
packages/rrweb/src/packer/index.ts
Normal file
2
packages/rrweb/src/packer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { pack } from './pack';
|
||||
export { unpack } from './unpack';
|
||||
10
packages/rrweb/src/packer/pack.ts
Normal file
10
packages/rrweb/src/packer/pack.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { strFromU8, strToU8, zlibSync } from 'fflate';
|
||||
import { PackFn, MARK, eventWithTimeAndPacker } from './base';
|
||||
|
||||
export const pack: PackFn = (event) => {
|
||||
const _e: eventWithTimeAndPacker = {
|
||||
...event,
|
||||
v: MARK,
|
||||
};
|
||||
return strFromU8(zlibSync(strToU8(JSON.stringify(_e))), true);
|
||||
};
|
||||
31
packages/rrweb/src/packer/unpack.ts
Normal file
31
packages/rrweb/src/packer/unpack.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { strFromU8, strToU8, unzlibSync } from 'fflate';
|
||||
import { UnpackFn, eventWithTimeAndPacker, MARK } from './base';
|
||||
import { eventWithTime } from '../types';
|
||||
|
||||
export const unpack: UnpackFn = (raw: string) => {
|
||||
if (typeof raw !== 'string') {
|
||||
return raw;
|
||||
}
|
||||
try {
|
||||
const e: eventWithTime = JSON.parse(raw);
|
||||
if (e.timestamp) {
|
||||
return e;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore and continue
|
||||
}
|
||||
try {
|
||||
const e: eventWithTimeAndPacker = JSON.parse(
|
||||
strFromU8(unzlibSync(strToU8(raw, true)))
|
||||
);
|
||||
if (e.v === MARK) {
|
||||
return e;
|
||||
}
|
||||
throw new Error(
|
||||
`These events were packed with packer ${e.v} which is incompatible with current packer ${MARK}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error('Unknown data format.');
|
||||
}
|
||||
};
|
||||
255
packages/rrweb/src/plugins/console/record/error-stack-parser.ts
Normal file
255
packages/rrweb/src/plugins/console/record/error-stack-parser.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// tslint:disable
|
||||
/**
|
||||
* Class StackFrame is a fork of https://github.com/stacktracejs/stackframe/blob/master/stackframe.js
|
||||
* I fork it because:
|
||||
* 1. There are some build issues when importing this package.
|
||||
* 2. Rewrites into typescript give us a better type interface.
|
||||
* 3. StackFrame contains some functions we don't need.
|
||||
*/
|
||||
export class StackFrame {
|
||||
private fileName: string;
|
||||
private functionName: string;
|
||||
private lineNumber?: number;
|
||||
private columnNumber?: number;
|
||||
|
||||
constructor(obj: {
|
||||
fileName?: string;
|
||||
functionName?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
}) {
|
||||
this.fileName = obj.fileName || '';
|
||||
this.functionName = obj.functionName || '';
|
||||
this.lineNumber = obj.lineNumber;
|
||||
this.columnNumber = obj.columnNumber;
|
||||
}
|
||||
|
||||
toString() {
|
||||
const lineNumber = this.lineNumber || '';
|
||||
const columnNumber = this.columnNumber || '';
|
||||
if (this.functionName) {
|
||||
return (
|
||||
this.functionName +
|
||||
' (' +
|
||||
this.fileName +
|
||||
':' +
|
||||
lineNumber +
|
||||
':' +
|
||||
columnNumber +
|
||||
')'
|
||||
);
|
||||
}
|
||||
return this.fileName + ':' + lineNumber + ':' + columnNumber;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorStackParser is a fork of https://github.com/stacktracejs/error-stack-parser/blob/master/error-stack-parser.js
|
||||
* I fork it because:
|
||||
* 1. There are some build issues when importing this package.
|
||||
* 2. Rewrites into typescript give us a better type interface.
|
||||
*/
|
||||
const FIREFOX_SAFARI_STACK_REGEXP = /(^|@)\S+:\d+/;
|
||||
const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
|
||||
const SAFARI_NATIVE_CODE_REGEXP = /^(eval@)?(\[native code])?$/;
|
||||
export const ErrorStackParser = {
|
||||
/**
|
||||
* Given an Error object, extract the most information from it.
|
||||
*
|
||||
* @param {Error} error object
|
||||
* @return {Array} of StackFrames
|
||||
*/
|
||||
parse: function (error: Error): StackFrame[] {
|
||||
if (
|
||||
// @ts-ignore
|
||||
typeof error.stacktrace !== 'undefined' ||
|
||||
// @ts-ignore
|
||||
typeof error['opera#sourceloc'] !== 'undefined'
|
||||
) {
|
||||
return this.parseOpera(
|
||||
error as {
|
||||
stacktrace?: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
},
|
||||
);
|
||||
} else if (error.stack && error.stack.match(CHROME_IE_STACK_REGEXP)) {
|
||||
return this.parseV8OrIE(error as { stack: string });
|
||||
} else if (error.stack) {
|
||||
return this.parseFFOrSafari(error as { stack: string });
|
||||
} else {
|
||||
throw new Error('Cannot parse given Error object');
|
||||
}
|
||||
},
|
||||
// Separate line and column numbers from a string of the form: (URI:Line:Column)
|
||||
extractLocation: function (urlLike: string) {
|
||||
// Fail-fast but return locations like "(native)"
|
||||
if (urlLike.indexOf(':') === -1) {
|
||||
return [urlLike];
|
||||
}
|
||||
|
||||
const regExp = /(.+?)(?::(\d+))?(?::(\d+))?$/;
|
||||
const parts = regExp.exec(urlLike.replace(/[()]/g, ''));
|
||||
if (!parts) throw new Error(`Cannot parse given url: ${urlLike}`);
|
||||
return [parts[1], parts[2] || undefined, parts[3] || undefined];
|
||||
},
|
||||
parseV8OrIE: function (error: { stack: string }) {
|
||||
const filtered = error.stack.split('\n').filter(function (line) {
|
||||
return !!line.match(CHROME_IE_STACK_REGEXP);
|
||||
}, this);
|
||||
|
||||
return filtered.map(function (line) {
|
||||
if (line.indexOf('(eval ') > -1) {
|
||||
// Throw away eval information until we implement stacktrace.js/stackframe#8
|
||||
line = line
|
||||
.replace(/eval code/g, 'eval')
|
||||
.replace(/(\(eval at [^()]*)|(\),.*$)/g, '');
|
||||
}
|
||||
let sanitizedLine = line.replace(/^\s+/, '').replace(/\(eval code/g, '(');
|
||||
|
||||
// capture and preseve the parenthesized location "(/foo/my bar.js:12:87)" in
|
||||
// case it has spaces in it, as the string is split on \s+ later on
|
||||
const location = sanitizedLine.match(/ (\((.+):(\d+):(\d+)\)$)/);
|
||||
|
||||
// remove the parenthesized location from the line, if it was matched
|
||||
sanitizedLine = location
|
||||
? sanitizedLine.replace(location[0], '')
|
||||
: sanitizedLine;
|
||||
|
||||
const tokens = sanitizedLine.split(/\s+/).slice(1);
|
||||
// if a location was matched, pass it to extractLocation() otherwise pop the last token
|
||||
const locationParts = this.extractLocation(
|
||||
location ? location[1] : tokens.pop(),
|
||||
);
|
||||
const functionName = tokens.join(' ') || undefined;
|
||||
const fileName =
|
||||
['eval', '<anonymous>'].indexOf(locationParts[0]) > -1
|
||||
? undefined
|
||||
: locationParts[0];
|
||||
|
||||
return new StackFrame({
|
||||
functionName,
|
||||
fileName,
|
||||
lineNumber: locationParts[1],
|
||||
columnNumber: locationParts[2],
|
||||
});
|
||||
}, this);
|
||||
},
|
||||
parseFFOrSafari: function (error: { stack: string }) {
|
||||
const filtered = error.stack.split('\n').filter(function (line) {
|
||||
return !line.match(SAFARI_NATIVE_CODE_REGEXP);
|
||||
}, this);
|
||||
|
||||
return filtered.map(function (line) {
|
||||
// Throw away eval information until we implement stacktrace.js/stackframe#8
|
||||
if (line.indexOf(' > eval') > -1) {
|
||||
line = line.replace(
|
||||
/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,
|
||||
':$1',
|
||||
);
|
||||
}
|
||||
|
||||
if (line.indexOf('@') === -1 && line.indexOf(':') === -1) {
|
||||
// Safari eval frames only have function names and nothing else
|
||||
return new StackFrame({
|
||||
functionName: line,
|
||||
});
|
||||
} else {
|
||||
const functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/;
|
||||
const matches = line.match(functionNameRegex);
|
||||
const functionName = matches && matches[1] ? matches[1] : undefined;
|
||||
const locationParts = this.extractLocation(
|
||||
line.replace(functionNameRegex, ''),
|
||||
);
|
||||
|
||||
return new StackFrame({
|
||||
functionName,
|
||||
fileName: locationParts[0],
|
||||
lineNumber: locationParts[1],
|
||||
columnNumber: locationParts[2],
|
||||
});
|
||||
}
|
||||
}, this);
|
||||
},
|
||||
parseOpera: function (e: {
|
||||
stacktrace?: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
}): StackFrame[] {
|
||||
if (
|
||||
!e.stacktrace ||
|
||||
(e.message.indexOf('\n') > -1 &&
|
||||
e.message.split('\n').length > e.stacktrace.split('\n').length)
|
||||
) {
|
||||
return this.parseOpera9(e as { message: string });
|
||||
} else if (!e.stack) {
|
||||
return this.parseOpera10(e as { stacktrace: string });
|
||||
} else {
|
||||
return this.parseOpera11(e as { stack: string });
|
||||
}
|
||||
},
|
||||
parseOpera9: function (e: { message: string }) {
|
||||
const lineRE = /Line (\d+).*script (?:in )?(\S+)/i;
|
||||
const lines = e.message.split('\n');
|
||||
const result = [];
|
||||
|
||||
for (let i = 2, len = lines.length; i < len; i += 2) {
|
||||
const match = lineRE.exec(lines[i]);
|
||||
if (match) {
|
||||
result.push(
|
||||
new StackFrame({
|
||||
fileName: match[2],
|
||||
lineNumber: parseFloat(match[1]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
parseOpera10: function (e: { stacktrace: string }) {
|
||||
const lineRE = /Line (\d+).*script (?:in )?(\S+)(?:: In function (\S+))?$/i;
|
||||
const lines = e.stacktrace.split('\n');
|
||||
const result = [];
|
||||
|
||||
for (let i = 0, len = lines.length; i < len; i += 2) {
|
||||
const match = lineRE.exec(lines[i]);
|
||||
if (match) {
|
||||
result.push(
|
||||
new StackFrame({
|
||||
functionName: match[3] || undefined,
|
||||
fileName: match[2],
|
||||
lineNumber: parseFloat(match[1]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
// Opera 10.65+ Error.stack very similar to FF/Safari
|
||||
parseOpera11: function (error: { stack: string }) {
|
||||
const filtered = error.stack.split('\n').filter(function (line) {
|
||||
return (
|
||||
!!line.match(FIREFOX_SAFARI_STACK_REGEXP) &&
|
||||
!line.match(/^Error created at/)
|
||||
);
|
||||
}, this);
|
||||
|
||||
return filtered.map(function (line: string) {
|
||||
const tokens = line.split('@');
|
||||
const locationParts = this.extractLocation(tokens.pop());
|
||||
const functionCall = tokens.shift() || '';
|
||||
const functionName =
|
||||
functionCall
|
||||
.replace(/<anonymous function(: (\w+))?>/, '$2')
|
||||
.replace(/\([^)]*\)/g, '') || undefined;
|
||||
return new StackFrame({
|
||||
functionName,
|
||||
fileName: locationParts[0],
|
||||
lineNumber: locationParts[1],
|
||||
columnNumber: locationParts[2],
|
||||
});
|
||||
}, this);
|
||||
},
|
||||
};
|
||||
203
packages/rrweb/src/plugins/console/record/index.ts
Normal file
203
packages/rrweb/src/plugins/console/record/index.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { listenerHandler, RecordPlugin } from '../../../types';
|
||||
import { stringify } from './stringify';
|
||||
import { StackFrame, ErrorStackParser } from './error-stack-parser';
|
||||
import { patch } from '../../../utils';
|
||||
|
||||
export type StringifyOptions = {
|
||||
// limit of string length
|
||||
stringLengthLimit?: number;
|
||||
/**
|
||||
* limit of number of keys in an object
|
||||
* if an object contains more keys than this limit, we would call its toString function directly
|
||||
*/
|
||||
numOfKeysLimit: number;
|
||||
};
|
||||
|
||||
type LogRecordOptions = {
|
||||
level?: LogLevel[] | undefined;
|
||||
lengthThreshold?: number;
|
||||
stringifyOptions?: StringifyOptions;
|
||||
logger?: Logger;
|
||||
};
|
||||
|
||||
const defaultLogOptions: LogRecordOptions = {
|
||||
level: [
|
||||
'assert',
|
||||
'clear',
|
||||
'count',
|
||||
'countReset',
|
||||
'debug',
|
||||
'dir',
|
||||
'dirxml',
|
||||
'error',
|
||||
'group',
|
||||
'groupCollapsed',
|
||||
'groupEnd',
|
||||
'info',
|
||||
'log',
|
||||
'table',
|
||||
'time',
|
||||
'timeEnd',
|
||||
'timeLog',
|
||||
'trace',
|
||||
'warn',
|
||||
],
|
||||
lengthThreshold: 1000,
|
||||
logger: console,
|
||||
};
|
||||
|
||||
export type LogData = {
|
||||
level: LogLevel;
|
||||
trace: string[];
|
||||
payload: string[];
|
||||
};
|
||||
|
||||
type logCallback = (p: LogData) => void;
|
||||
|
||||
export type LogLevel =
|
||||
| 'assert'
|
||||
| 'clear'
|
||||
| 'count'
|
||||
| 'countReset'
|
||||
| 'debug'
|
||||
| 'dir'
|
||||
| 'dirxml'
|
||||
| 'error'
|
||||
| 'group'
|
||||
| 'groupCollapsed'
|
||||
| 'groupEnd'
|
||||
| 'info'
|
||||
| 'log'
|
||||
| 'table'
|
||||
| 'time'
|
||||
| 'timeEnd'
|
||||
| 'timeLog'
|
||||
| 'trace'
|
||||
| 'warn';
|
||||
|
||||
/* fork from interface Console */
|
||||
// all kinds of console functions
|
||||
export type Logger = {
|
||||
assert?: typeof console.assert;
|
||||
clear?: typeof console.clear;
|
||||
count?: typeof console.count;
|
||||
countReset?: typeof console.countReset;
|
||||
debug?: typeof console.debug;
|
||||
dir?: typeof console.dir;
|
||||
dirxml?: typeof console.dirxml;
|
||||
error?: typeof console.error;
|
||||
group?: typeof console.group;
|
||||
groupCollapsed?: typeof console.groupCollapsed;
|
||||
groupEnd?: () => void;
|
||||
info?: typeof console.info;
|
||||
log?: typeof console.log;
|
||||
table?: typeof console.table;
|
||||
time?: typeof console.time;
|
||||
timeEnd?: typeof console.timeEnd;
|
||||
timeLog?: typeof console.timeLog;
|
||||
trace?: typeof console.trace;
|
||||
warn?: typeof console.warn;
|
||||
};
|
||||
|
||||
function initLogObserver(
|
||||
cb: logCallback,
|
||||
logOptions: LogRecordOptions,
|
||||
): listenerHandler {
|
||||
const logger = logOptions.logger;
|
||||
if (!logger) {
|
||||
return () => {};
|
||||
}
|
||||
let logCount = 0;
|
||||
const cancelHandlers: listenerHandler[] = [];
|
||||
// add listener to thrown errors
|
||||
if (logOptions.level!.includes('error')) {
|
||||
if (window) {
|
||||
const originalOnError = window.onerror;
|
||||
window.onerror = (
|
||||
msg: Event | string,
|
||||
file: string,
|
||||
line: number,
|
||||
col: number,
|
||||
error: Error,
|
||||
) => {
|
||||
if (originalOnError) {
|
||||
originalOnError.apply(this, [msg, file, line, col, error]);
|
||||
}
|
||||
const trace: string[] = ErrorStackParser.parse(
|
||||
error,
|
||||
).map((stackFrame: StackFrame) => stackFrame.toString());
|
||||
const payload = [stringify(msg, logOptions.stringifyOptions)];
|
||||
cb({
|
||||
level: 'error',
|
||||
trace,
|
||||
payload,
|
||||
});
|
||||
};
|
||||
cancelHandlers.push(() => {
|
||||
window.onerror = originalOnError;
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const levelType of logOptions.level!) {
|
||||
cancelHandlers.push(replace(logger, levelType));
|
||||
}
|
||||
return () => {
|
||||
cancelHandlers.forEach((h) => h());
|
||||
};
|
||||
|
||||
/**
|
||||
* replace the original console function and record logs
|
||||
* @param logger the logger object such as Console
|
||||
* @param level the name of log function to be replaced
|
||||
*/
|
||||
function replace(_logger: Logger, level: LogLevel) {
|
||||
if (!_logger[level]) {
|
||||
return () => {};
|
||||
}
|
||||
// replace the logger.{level}. return a restore function
|
||||
return patch(_logger, level, (original) => {
|
||||
return (...args: Array<unknown>) => {
|
||||
original.apply(this, args);
|
||||
try {
|
||||
const trace = ErrorStackParser.parse(new Error())
|
||||
.map((stackFrame: StackFrame) => stackFrame.toString())
|
||||
.splice(1); // splice(1) to omit the hijacked log function
|
||||
const payload = args.map((s) =>
|
||||
stringify(s, logOptions.stringifyOptions),
|
||||
);
|
||||
logCount++;
|
||||
if (logCount < logOptions.lengthThreshold!) {
|
||||
cb({
|
||||
level,
|
||||
trace,
|
||||
payload,
|
||||
});
|
||||
} else if (logCount === logOptions.lengthThreshold) {
|
||||
// notify the user
|
||||
cb({
|
||||
level: 'warn',
|
||||
trace: [],
|
||||
payload: [
|
||||
stringify('The number of log records reached the threshold.'),
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
original('rrweb logger error:', error, ...args);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const PLUGIN_NAME = 'rrweb/console@1';
|
||||
|
||||
export const getRecordConsolePlugin: (
|
||||
options?: LogRecordOptions,
|
||||
) => RecordPlugin = (options) => ({
|
||||
name: PLUGIN_NAME,
|
||||
observer: initLogObserver,
|
||||
options: options
|
||||
? Object.assign({}, defaultLogOptions, options)
|
||||
: defaultLogOptions,
|
||||
});
|
||||
143
packages/rrweb/src/plugins/console/record/stringify.ts
Normal file
143
packages/rrweb/src/plugins/console/record/stringify.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// tslint:disable:no-any no-bitwise forin
|
||||
/**
|
||||
* this file is used to serialize log message to string
|
||||
*
|
||||
*/
|
||||
|
||||
import { StringifyOptions } from './index';
|
||||
|
||||
/**
|
||||
* transfer the node path in Event to string
|
||||
* @param node the first node in a node path array
|
||||
*/
|
||||
function pathToSelector(node: HTMLElement): string | '' {
|
||||
if (!node || !node.outerHTML) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let path = '';
|
||||
while (node.parentElement) {
|
||||
let name = node.localName;
|
||||
if (!name) {
|
||||
break;
|
||||
}
|
||||
name = name.toLowerCase();
|
||||
let parent = node.parentElement;
|
||||
|
||||
let domSiblings = [];
|
||||
|
||||
if (parent.children && parent.children.length > 0) {
|
||||
// tslint:disable-next-line:prefer-for-of
|
||||
for (let i = 0; i < parent.children.length; i++) {
|
||||
let sibling = parent.children[i];
|
||||
if (sibling.localName && sibling.localName.toLowerCase) {
|
||||
if (sibling.localName.toLowerCase() === name) {
|
||||
domSiblings.push(sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (domSiblings.length > 1) {
|
||||
name += ':eq(' + domSiblings.indexOf(node) + ')';
|
||||
}
|
||||
path = name + (path ? '>' + path : '');
|
||||
node = parent;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* stringify any js object
|
||||
* @param obj the object to stringify
|
||||
*/
|
||||
export function stringify(
|
||||
obj: any,
|
||||
stringifyOptions?: StringifyOptions,
|
||||
): string {
|
||||
const options: StringifyOptions = {
|
||||
numOfKeysLimit: 50,
|
||||
};
|
||||
Object.assign(options, stringifyOptions);
|
||||
const stack: any[] = [];
|
||||
const keys: any[] = [];
|
||||
return JSON.stringify(obj, function (key, value) {
|
||||
/**
|
||||
* forked from https://github.com/moll/json-stringify-safe/blob/master/stringify.js
|
||||
* to deCycle the object
|
||||
*/
|
||||
if (stack.length > 0) {
|
||||
const thisPos = stack.indexOf(this);
|
||||
~thisPos ? stack.splice(thisPos + 1) : stack.push(this);
|
||||
~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key);
|
||||
if (~stack.indexOf(value)) {
|
||||
if (stack[0] === value) {
|
||||
value = '[Circular ~]';
|
||||
} else {
|
||||
value =
|
||||
'[Circular ~.' +
|
||||
keys.slice(0, stack.indexOf(value)).join('.') +
|
||||
']';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stack.push(value);
|
||||
}
|
||||
/* END of the FORK */
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
if (shouldToString(value)) {
|
||||
return toString(value);
|
||||
}
|
||||
if (value instanceof Event) {
|
||||
const eventResult: any = {};
|
||||
for (const eventKey in value) {
|
||||
const eventValue = (value as any)[eventKey];
|
||||
if (Array.isArray(eventValue)) {
|
||||
eventResult[eventKey] = pathToSelector(
|
||||
eventValue.length ? eventValue[0] : null,
|
||||
);
|
||||
} else {
|
||||
eventResult[eventKey] = eventValue;
|
||||
}
|
||||
}
|
||||
return eventResult;
|
||||
} else if (value instanceof Node) {
|
||||
if (value instanceof HTMLElement) {
|
||||
return value ? value.outerHTML : '';
|
||||
}
|
||||
return value.nodeName;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
/**
|
||||
* whether we should call toString function of this object
|
||||
*/
|
||||
function shouldToString(_obj: object): boolean {
|
||||
if (
|
||||
typeof _obj === 'object' &&
|
||||
Object.keys(_obj).length > options.numOfKeysLimit
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (typeof _obj === 'function') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* limit the toString() result according to option
|
||||
*/
|
||||
function toString(_obj: object): string {
|
||||
let str = _obj.toString();
|
||||
if (options.stringLengthLimit && str.length > options.stringLengthLimit) {
|
||||
str = `${str.slice(0, options.stringLengthLimit)}...`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
}
|
||||
144
packages/rrweb/src/plugins/console/replay/index.ts
Normal file
144
packages/rrweb/src/plugins/console/replay/index.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { LogLevel, LogData, PLUGIN_NAME } from '../record';
|
||||
import {
|
||||
eventWithTime,
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
ReplayPlugin,
|
||||
} from '../../../types';
|
||||
|
||||
/**
|
||||
* define an interface to replay log records
|
||||
* (data: logData) => void> function to display the log data
|
||||
*/
|
||||
type ReplayLogger = Partial<Record<LogLevel, (data: LogData) => void>>;
|
||||
|
||||
type LogReplayConfig = {
|
||||
level?: LogLevel[] | undefined;
|
||||
replayLogger: ReplayLogger | undefined;
|
||||
};
|
||||
|
||||
const ORIGINAL_ATTRIBUTE_NAME = '__rrweb_original__';
|
||||
type PatchedConsoleLog = {
|
||||
[ORIGINAL_ATTRIBUTE_NAME]: typeof console.log;
|
||||
};
|
||||
|
||||
const defaultLogConfig: LogReplayConfig = {
|
||||
level: [
|
||||
'assert',
|
||||
'clear',
|
||||
'count',
|
||||
'countReset',
|
||||
'debug',
|
||||
'dir',
|
||||
'dirxml',
|
||||
'error',
|
||||
'group',
|
||||
'groupCollapsed',
|
||||
'groupEnd',
|
||||
'info',
|
||||
'log',
|
||||
'table',
|
||||
'time',
|
||||
'timeEnd',
|
||||
'timeLog',
|
||||
'trace',
|
||||
'warn',
|
||||
],
|
||||
replayLogger: undefined,
|
||||
};
|
||||
|
||||
class LogReplayPlugin {
|
||||
private config: LogReplayConfig;
|
||||
|
||||
constructor(config?: LogReplayConfig) {
|
||||
this.config = Object.assign(defaultLogConfig, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* generate a console log replayer which implement the interface ReplayLogger
|
||||
*/
|
||||
public getConsoleLogger(): ReplayLogger {
|
||||
const replayLogger: ReplayLogger = {};
|
||||
for (const level of this.config.level!) {
|
||||
if (level === 'trace') {
|
||||
replayLogger[level] = (data: LogData) => {
|
||||
const logger = ((console.log as unknown) as PatchedConsoleLog)[
|
||||
ORIGINAL_ATTRIBUTE_NAME
|
||||
]
|
||||
? ((console.log as unknown) as PatchedConsoleLog)[
|
||||
ORIGINAL_ATTRIBUTE_NAME
|
||||
]
|
||||
: console.log;
|
||||
logger(
|
||||
...data.payload.map((s) => JSON.parse(s)),
|
||||
this.formatMessage(data),
|
||||
);
|
||||
};
|
||||
} else {
|
||||
replayLogger[level] = (data: LogData) => {
|
||||
const logger = ((console[level] as unknown) as PatchedConsoleLog)[
|
||||
ORIGINAL_ATTRIBUTE_NAME
|
||||
]
|
||||
? ((console[level] as unknown) as PatchedConsoleLog)[
|
||||
ORIGINAL_ATTRIBUTE_NAME
|
||||
]
|
||||
: console[level];
|
||||
logger(
|
||||
...data.payload.map((s) => JSON.parse(s)),
|
||||
this.formatMessage(data),
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
return replayLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* format the trace data to a string
|
||||
* @param data the log data
|
||||
*/
|
||||
private formatMessage(data: LogData): string {
|
||||
if (data.trace.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const stackPrefix = '\n\tat ';
|
||||
let result = stackPrefix;
|
||||
result += data.trace.join(stackPrefix);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const getReplayConsolePlugin: (
|
||||
options?: LogReplayConfig,
|
||||
) => ReplayPlugin = (options) => {
|
||||
const replayLogger =
|
||||
options?.replayLogger || new LogReplayPlugin(options).getConsoleLogger();
|
||||
|
||||
return {
|
||||
handler(event: eventWithTime, _isSync, context) {
|
||||
let logData: LogData | null = null;
|
||||
if (
|
||||
event.type === EventType.IncrementalSnapshot &&
|
||||
event.data.source === (IncrementalSource.Log as IncrementalSource)
|
||||
) {
|
||||
logData = (event.data as unknown) as LogData;
|
||||
} else if (
|
||||
event.type === EventType.Plugin &&
|
||||
event.data.plugin === PLUGIN_NAME
|
||||
) {
|
||||
logData = event.data.payload as LogData;
|
||||
}
|
||||
if (logData) {
|
||||
try {
|
||||
if (typeof replayLogger[logData.level] === 'function') {
|
||||
replayLogger[logData.level]!(logData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.replayer.config.showWarning) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
37
packages/rrweb/src/record/iframe-manager.ts
Normal file
37
packages/rrweb/src/record/iframe-manager.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { serializedNodeWithId, INode } from 'rrweb-snapshot';
|
||||
import { mutationCallBack } from '../types';
|
||||
|
||||
export class IframeManager {
|
||||
private iframes: WeakMap<HTMLIFrameElement, true> = new WeakMap();
|
||||
private mutationCb: mutationCallBack;
|
||||
private loadListener?: (iframeEl: HTMLIFrameElement) => unknown;
|
||||
|
||||
constructor(options: { mutationCb: mutationCallBack }) {
|
||||
this.mutationCb = options.mutationCb;
|
||||
}
|
||||
|
||||
public addIframe(iframeEl: HTMLIFrameElement) {
|
||||
this.iframes.set(iframeEl, true);
|
||||
}
|
||||
|
||||
public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) {
|
||||
this.loadListener = cb;
|
||||
}
|
||||
|
||||
public attachIframe(iframeEl: INode, childSn: serializedNodeWithId) {
|
||||
this.mutationCb({
|
||||
adds: [
|
||||
{
|
||||
parentId: iframeEl.__sn.id,
|
||||
nextId: null,
|
||||
node: childSn,
|
||||
},
|
||||
],
|
||||
removes: [],
|
||||
texts: [],
|
||||
attributes: [],
|
||||
isAttachIframe: true,
|
||||
});
|
||||
this.loadListener?.((iframeEl as unknown) as HTMLIFrameElement);
|
||||
}
|
||||
}
|
||||
480
packages/rrweb/src/record/index.ts
Normal file
480
packages/rrweb/src/record/index.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import { snapshot, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
|
||||
import { initObservers, mutationBuffers } from './observer';
|
||||
import {
|
||||
on,
|
||||
getWindowWidth,
|
||||
getWindowHeight,
|
||||
polyfill,
|
||||
isIframeINode,
|
||||
hasShadowRoot,
|
||||
createMirror,
|
||||
} from '../utils';
|
||||
import {
|
||||
EventType,
|
||||
event,
|
||||
eventWithTime,
|
||||
recordOptions,
|
||||
IncrementalSource,
|
||||
listenerHandler,
|
||||
mutationCallbackParam,
|
||||
scrollCallback,
|
||||
} from '../types';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
import { ShadowDomManager } from './shadow-dom-manager';
|
||||
|
||||
function wrapEvent(e: event): eventWithTime {
|
||||
return {
|
||||
...e,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
let wrappedEmit!: (e: eventWithTime, isCheckout?: boolean) => void;
|
||||
|
||||
let takeFullSnapshot!: (isCheckout?: boolean) => void;
|
||||
|
||||
const mirror = createMirror();
|
||||
function record<T = eventWithTime>(
|
||||
options: recordOptions<T> = {},
|
||||
): listenerHandler | undefined {
|
||||
const {
|
||||
emit,
|
||||
checkoutEveryNms,
|
||||
checkoutEveryNth,
|
||||
blockClass = 'rr-block',
|
||||
blockSelector = null,
|
||||
ignoreClass = 'rr-ignore',
|
||||
maskTextClass = 'rr-mask',
|
||||
maskTextSelector = null,
|
||||
inlineStylesheet = true,
|
||||
maskAllInputs,
|
||||
maskInputOptions: _maskInputOptions,
|
||||
slimDOMOptions: _slimDOMOptions,
|
||||
maskInputFn,
|
||||
maskTextFn,
|
||||
hooks,
|
||||
packFn,
|
||||
sampling = {},
|
||||
mousemoveWait,
|
||||
recordCanvas = false,
|
||||
userTriggeredOnInput = false,
|
||||
collectFonts = false,
|
||||
plugins,
|
||||
keepIframeSrcFn = () => false,
|
||||
} = options;
|
||||
// runtime checks for user options
|
||||
if (!emit) {
|
||||
throw new Error('emit function is required');
|
||||
}
|
||||
// move departed options to new options
|
||||
if (mousemoveWait !== undefined && sampling.mousemove === undefined) {
|
||||
sampling.mousemove = mousemoveWait;
|
||||
}
|
||||
|
||||
const maskInputOptions: MaskInputOptions =
|
||||
maskAllInputs === true
|
||||
? {
|
||||
color: true,
|
||||
date: true,
|
||||
'datetime-local': true,
|
||||
email: true,
|
||||
month: true,
|
||||
number: true,
|
||||
range: true,
|
||||
search: true,
|
||||
tel: true,
|
||||
text: true,
|
||||
time: true,
|
||||
url: true,
|
||||
week: true,
|
||||
textarea: true,
|
||||
select: true,
|
||||
password: true,
|
||||
}
|
||||
: _maskInputOptions !== undefined
|
||||
? _maskInputOptions
|
||||
: { password: true };
|
||||
|
||||
const slimDOMOptions: SlimDOMOptions =
|
||||
_slimDOMOptions === true || _slimDOMOptions === 'all'
|
||||
? {
|
||||
script: true,
|
||||
comment: true,
|
||||
headFavicon: true,
|
||||
headWhitespace: true,
|
||||
headMetaSocial: true,
|
||||
headMetaRobots: true,
|
||||
headMetaHttpEquiv: true,
|
||||
headMetaVerification: true,
|
||||
// the following are off for slimDOMOptions === true,
|
||||
// as they destroy some (hidden) info:
|
||||
headMetaAuthorship: _slimDOMOptions === 'all',
|
||||
headMetaDescKeywords: _slimDOMOptions === 'all',
|
||||
}
|
||||
: _slimDOMOptions
|
||||
? _slimDOMOptions
|
||||
: {};
|
||||
|
||||
polyfill();
|
||||
|
||||
let lastFullSnapshotEvent: eventWithTime;
|
||||
let incrementalSnapshotCount = 0;
|
||||
wrappedEmit = (e: eventWithTime, isCheckout?: boolean) => {
|
||||
if (
|
||||
mutationBuffers[0]?.isFrozen() &&
|
||||
e.type !== EventType.FullSnapshot &&
|
||||
!(
|
||||
e.type === EventType.IncrementalSnapshot &&
|
||||
e.data.source === IncrementalSource.Mutation
|
||||
)
|
||||
) {
|
||||
// we've got a user initiated event so first we need to apply
|
||||
// all DOM changes that have been buffering during paused state
|
||||
mutationBuffers.forEach((buf) => buf.unfreeze());
|
||||
}
|
||||
|
||||
emit(((packFn ? packFn(e) : e) as unknown) as T, isCheckout);
|
||||
if (e.type === EventType.FullSnapshot) {
|
||||
lastFullSnapshotEvent = e;
|
||||
incrementalSnapshotCount = 0;
|
||||
} else if (e.type === EventType.IncrementalSnapshot) {
|
||||
// attach iframe should be considered as full snapshot
|
||||
if (
|
||||
e.data.source === IncrementalSource.Mutation &&
|
||||
e.data.isAttachIframe
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
incrementalSnapshotCount++;
|
||||
const exceedCount =
|
||||
checkoutEveryNth && incrementalSnapshotCount >= checkoutEveryNth;
|
||||
const exceedTime =
|
||||
checkoutEveryNms &&
|
||||
e.timestamp - lastFullSnapshotEvent.timestamp > checkoutEveryNms;
|
||||
if (exceedCount || exceedTime) {
|
||||
takeFullSnapshot(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const wrappedMutationEmit = (m: mutationCallbackParam) => {
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Mutation,
|
||||
...m,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
const wrappedScrollEmit: scrollCallback = (p) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Scroll,
|
||||
...p,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const iframeManager = new IframeManager({
|
||||
mutationCb: wrappedMutationEmit,
|
||||
});
|
||||
|
||||
const shadowDomManager = new ShadowDomManager({
|
||||
mutationCb: wrappedMutationEmit,
|
||||
scrollCb: wrappedScrollEmit,
|
||||
bypassOptions: {
|
||||
blockClass,
|
||||
blockSelector,
|
||||
maskTextClass,
|
||||
maskTextSelector,
|
||||
inlineStylesheet,
|
||||
maskInputOptions,
|
||||
maskTextFn,
|
||||
maskInputFn,
|
||||
recordCanvas,
|
||||
sampling,
|
||||
slimDOMOptions,
|
||||
iframeManager,
|
||||
},
|
||||
mirror,
|
||||
});
|
||||
|
||||
takeFullSnapshot = (isCheckout = false) => {
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
href: window.location.href,
|
||||
width: getWindowWidth(),
|
||||
height: getWindowHeight(),
|
||||
},
|
||||
}),
|
||||
isCheckout,
|
||||
);
|
||||
|
||||
mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting
|
||||
const [node, idNodeMap] = snapshot(document, {
|
||||
blockClass,
|
||||
blockSelector,
|
||||
maskTextClass,
|
||||
maskTextSelector,
|
||||
inlineStylesheet,
|
||||
maskAllInputs: maskInputOptions,
|
||||
maskTextFn,
|
||||
slimDOM: slimDOMOptions,
|
||||
recordCanvas,
|
||||
onSerialize: (n) => {
|
||||
if (isIframeINode(n)) {
|
||||
iframeManager.addIframe(n);
|
||||
}
|
||||
if (hasShadowRoot(n)) {
|
||||
shadowDomManager.addShadowRoot(n.shadowRoot, document);
|
||||
}
|
||||
},
|
||||
onIframeLoad: (iframe, childSn) => {
|
||||
iframeManager.attachIframe(iframe, childSn);
|
||||
},
|
||||
keepIframeSrcFn,
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
return console.warn('Failed to snapshot the document');
|
||||
}
|
||||
|
||||
mirror.map = idNodeMap;
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.FullSnapshot,
|
||||
data: {
|
||||
node,
|
||||
initialOffset: {
|
||||
left:
|
||||
window.pageXOffset !== undefined
|
||||
? window.pageXOffset
|
||||
: document?.documentElement.scrollLeft ||
|
||||
document?.body?.parentElement?.scrollLeft ||
|
||||
document?.body.scrollLeft ||
|
||||
0,
|
||||
top:
|
||||
window.pageYOffset !== undefined
|
||||
? window.pageYOffset
|
||||
: document?.documentElement.scrollTop ||
|
||||
document?.body?.parentElement?.scrollTop ||
|
||||
document?.body.scrollTop ||
|
||||
0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror
|
||||
};
|
||||
|
||||
try {
|
||||
const handlers: listenerHandler[] = [];
|
||||
handlers.push(
|
||||
on('DOMContentLoaded', () => {
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.DomContentLoaded,
|
||||
data: {},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const observe = (doc: Document) => {
|
||||
return initObservers(
|
||||
{
|
||||
mutationCb: wrappedMutationEmit,
|
||||
mousemoveCb: (positions, source) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source,
|
||||
positions,
|
||||
},
|
||||
}),
|
||||
),
|
||||
mouseInteractionCb: (d) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.MouseInteraction,
|
||||
...d,
|
||||
},
|
||||
}),
|
||||
),
|
||||
scrollCb: wrappedScrollEmit,
|
||||
viewportResizeCb: (d) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.ViewportResize,
|
||||
...d,
|
||||
},
|
||||
}),
|
||||
),
|
||||
inputCb: (v) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Input,
|
||||
...v,
|
||||
},
|
||||
}),
|
||||
),
|
||||
mediaInteractionCb: (p) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.MediaInteraction,
|
||||
...p,
|
||||
},
|
||||
}),
|
||||
),
|
||||
styleSheetRuleCb: (r) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
...r,
|
||||
},
|
||||
}),
|
||||
),
|
||||
canvasMutationCb: (p) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.CanvasMutation,
|
||||
...p,
|
||||
},
|
||||
}),
|
||||
),
|
||||
fontCb: (p) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Font,
|
||||
...p,
|
||||
},
|
||||
}),
|
||||
),
|
||||
blockClass,
|
||||
ignoreClass,
|
||||
maskTextClass,
|
||||
maskTextSelector,
|
||||
maskInputOptions,
|
||||
inlineStylesheet,
|
||||
sampling,
|
||||
recordCanvas,
|
||||
userTriggeredOnInput,
|
||||
collectFonts,
|
||||
doc,
|
||||
maskInputFn,
|
||||
maskTextFn,
|
||||
blockSelector,
|
||||
slimDOMOptions,
|
||||
mirror,
|
||||
iframeManager,
|
||||
shadowDomManager,
|
||||
plugins:
|
||||
plugins?.map((p) => ({
|
||||
observer: p.observer,
|
||||
options: p.options,
|
||||
callback: (payload: object) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.Plugin,
|
||||
data: {
|
||||
plugin: p.name,
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
),
|
||||
})) || [],
|
||||
},
|
||||
hooks,
|
||||
);
|
||||
};
|
||||
|
||||
iframeManager.addLoadListener((iframeEl) => {
|
||||
handlers.push(observe(iframeEl.contentDocument!));
|
||||
});
|
||||
|
||||
const init = () => {
|
||||
takeFullSnapshot();
|
||||
handlers.push(observe(document));
|
||||
};
|
||||
if (
|
||||
document.readyState === 'interactive' ||
|
||||
document.readyState === 'complete'
|
||||
) {
|
||||
init();
|
||||
} else {
|
||||
handlers.push(
|
||||
on(
|
||||
'load',
|
||||
() => {
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.Load,
|
||||
data: {},
|
||||
}),
|
||||
);
|
||||
init();
|
||||
},
|
||||
window,
|
||||
),
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
} catch (error) {
|
||||
// TODO: handle internal error
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
record.addCustomEvent = <T>(tag: string, payload: T) => {
|
||||
if (!wrappedEmit) {
|
||||
throw new Error('please add custom event after start recording');
|
||||
}
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.Custom,
|
||||
data: {
|
||||
tag,
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
record.freezePage = () => {
|
||||
mutationBuffers.forEach((buf) => buf.freeze());
|
||||
};
|
||||
|
||||
record.takeFullSnapshot = (isCheckout?: boolean) => {
|
||||
if (!takeFullSnapshot) {
|
||||
throw new Error('please take full snapshot after start recording');
|
||||
}
|
||||
takeFullSnapshot(isCheckout);
|
||||
};
|
||||
|
||||
record.mirror = mirror;
|
||||
|
||||
export default record;
|
||||
641
packages/rrweb/src/record/mutation.ts
Normal file
641
packages/rrweb/src/record/mutation.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import {
|
||||
INode,
|
||||
serializeNodeWithId,
|
||||
transformAttribute,
|
||||
MaskInputOptions,
|
||||
SlimDOMOptions,
|
||||
IGNORED_NODE,
|
||||
isShadowRoot,
|
||||
needMaskingText,
|
||||
maskInputValue,
|
||||
MaskTextFn,
|
||||
MaskInputFn,
|
||||
} from 'rrweb-snapshot';
|
||||
import {
|
||||
mutationRecord,
|
||||
blockClass,
|
||||
maskTextClass,
|
||||
mutationCallBack,
|
||||
textCursor,
|
||||
attributeCursor,
|
||||
removedNodeMutation,
|
||||
addedNodeMutation,
|
||||
Mirror,
|
||||
styleAttributeValue,
|
||||
} from '../types';
|
||||
import {
|
||||
isBlocked,
|
||||
isAncestorRemoved,
|
||||
isIgnored,
|
||||
isIframeINode,
|
||||
hasShadowRoot,
|
||||
} from '../utils';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
import { ShadowDomManager } from './shadow-dom-manager';
|
||||
|
||||
type DoubleLinkedListNode = {
|
||||
previous: DoubleLinkedListNode | null;
|
||||
next: DoubleLinkedListNode | null;
|
||||
value: NodeInLinkedList;
|
||||
};
|
||||
type NodeInLinkedList = Node & {
|
||||
__ln: DoubleLinkedListNode;
|
||||
};
|
||||
|
||||
function isNodeInLinkedList(n: Node | NodeInLinkedList): n is NodeInLinkedList {
|
||||
return '__ln' in n;
|
||||
}
|
||||
class DoubleLinkedList {
|
||||
public length = 0;
|
||||
public head: DoubleLinkedListNode | null = null;
|
||||
|
||||
public get(position: number) {
|
||||
if (position >= this.length) {
|
||||
throw new Error('Position outside of list range');
|
||||
}
|
||||
|
||||
let current = this.head;
|
||||
for (let index = 0; index < position; index++) {
|
||||
current = current?.next || null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
public addNode(n: Node) {
|
||||
const node: DoubleLinkedListNode = {
|
||||
value: n as NodeInLinkedList,
|
||||
previous: null,
|
||||
next: null,
|
||||
};
|
||||
(n as NodeInLinkedList).__ln = node;
|
||||
if (n.previousSibling && isNodeInLinkedList(n.previousSibling)) {
|
||||
const current = n.previousSibling.__ln.next;
|
||||
node.next = current;
|
||||
node.previous = n.previousSibling.__ln;
|
||||
n.previousSibling.__ln.next = node;
|
||||
if (current) {
|
||||
current.previous = node;
|
||||
}
|
||||
} else if (
|
||||
n.nextSibling &&
|
||||
isNodeInLinkedList(n.nextSibling) &&
|
||||
n.nextSibling.__ln.previous
|
||||
) {
|
||||
const current = n.nextSibling.__ln.previous;
|
||||
node.previous = current;
|
||||
node.next = n.nextSibling.__ln;
|
||||
n.nextSibling.__ln.previous = node;
|
||||
if (current) {
|
||||
current.next = node;
|
||||
}
|
||||
} else {
|
||||
if (this.head) {
|
||||
this.head.previous = node;
|
||||
}
|
||||
node.next = this.head;
|
||||
this.head = node;
|
||||
}
|
||||
this.length++;
|
||||
}
|
||||
|
||||
public removeNode(n: NodeInLinkedList) {
|
||||
const current = n.__ln;
|
||||
if (!this.head) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current.previous) {
|
||||
this.head = current.next;
|
||||
if (this.head) {
|
||||
this.head.previous = null;
|
||||
}
|
||||
} else {
|
||||
current.previous.next = current.next;
|
||||
if (current.next) {
|
||||
current.next.previous = current.previous;
|
||||
}
|
||||
}
|
||||
if (n.__ln) {
|
||||
delete n.__ln;
|
||||
}
|
||||
this.length--;
|
||||
}
|
||||
}
|
||||
|
||||
const moveKey = (id: number, parentId: number) => `${id}@${parentId}`;
|
||||
function isINode(n: Node | INode): n is INode {
|
||||
return '__sn' in n;
|
||||
}
|
||||
|
||||
/**
|
||||
* controls behaviour of a MutationObserver
|
||||
*/
|
||||
export default class MutationBuffer {
|
||||
private frozen: boolean = false;
|
||||
private locked: boolean = false;
|
||||
|
||||
private texts: textCursor[] = [];
|
||||
private attributes: attributeCursor[] = [];
|
||||
private removes: removedNodeMutation[] = [];
|
||||
private mapRemoves: Node[] = [];
|
||||
|
||||
private movedMap: Record<string, true> = {};
|
||||
|
||||
/**
|
||||
* the browser MutationObserver emits multiple mutations after
|
||||
* a delay for performance reasons, making tracing added nodes hard
|
||||
* in our `processMutations` callback function.
|
||||
* For example, if we append an element el_1 into body, and then append
|
||||
* another element el_2 into el_1, these two mutations may be passed to the
|
||||
* callback function together when the two operations were done.
|
||||
* Generally we need to trace child nodes of newly added nodes, but in this
|
||||
* case if we count el_2 as el_1's child node in the first mutation record,
|
||||
* then we will count el_2 again in the second mutation record which was
|
||||
* duplicated.
|
||||
* To avoid of duplicate counting added nodes, we use a Set to store
|
||||
* added nodes and its child nodes during iterate mutation records. Then
|
||||
* collect added nodes from the Set which have no duplicate copy. But
|
||||
* this also causes newly added nodes will not be serialized with id ASAP,
|
||||
* which means all the id related calculation should be lazy too.
|
||||
*/
|
||||
private addedSet = new Set<Node>();
|
||||
private movedSet = new Set<Node>();
|
||||
private droppedSet = new Set<Node>();
|
||||
|
||||
private emissionCallback: mutationCallBack;
|
||||
private blockClass: blockClass;
|
||||
private blockSelector: string | null;
|
||||
private maskTextClass: maskTextClass;
|
||||
private maskTextSelector: string | null;
|
||||
private inlineStylesheet: boolean;
|
||||
private maskInputOptions: MaskInputOptions;
|
||||
private maskTextFn: MaskTextFn | undefined;
|
||||
private maskInputFn: MaskInputFn | undefined;
|
||||
private recordCanvas: boolean;
|
||||
private slimDOMOptions: SlimDOMOptions;
|
||||
private doc: Document;
|
||||
|
||||
private mirror: Mirror;
|
||||
private iframeManager: IframeManager;
|
||||
private shadowDomManager: ShadowDomManager;
|
||||
|
||||
public 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,
|
||||
slimDOMOptions: SlimDOMOptions,
|
||||
doc: Document,
|
||||
mirror: Mirror,
|
||||
iframeManager: IframeManager,
|
||||
shadowDomManager: ShadowDomManager,
|
||||
) {
|
||||
this.blockClass = blockClass;
|
||||
this.blockSelector = blockSelector;
|
||||
this.maskTextClass = maskTextClass;
|
||||
this.maskTextSelector = maskTextSelector;
|
||||
this.inlineStylesheet = inlineStylesheet;
|
||||
this.maskInputOptions = maskInputOptions;
|
||||
this.maskTextFn = maskTextFn;
|
||||
this.maskInputFn = maskInputFn;
|
||||
this.recordCanvas = recordCanvas;
|
||||
this.slimDOMOptions = slimDOMOptions;
|
||||
this.emissionCallback = cb;
|
||||
this.doc = doc;
|
||||
this.mirror = mirror;
|
||||
this.iframeManager = iframeManager;
|
||||
this.shadowDomManager = shadowDomManager;
|
||||
}
|
||||
|
||||
public freeze() {
|
||||
this.frozen = true;
|
||||
}
|
||||
|
||||
public unfreeze() {
|
||||
this.frozen = false;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
public isFrozen() {
|
||||
return this.frozen;
|
||||
}
|
||||
|
||||
public lock() {
|
||||
this.locked = true;
|
||||
}
|
||||
|
||||
public unlock() {
|
||||
this.locked = false;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
public processMutations = (mutations: mutationRecord[]) => {
|
||||
mutations.forEach(this.processMutation);
|
||||
this.emit();
|
||||
};
|
||||
|
||||
public emit = () => {
|
||||
if (this.frozen || this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// delay any modification of the mirror until this function
|
||||
// so that the mirror for takeFullSnapshot doesn't get mutated while it's event is being processed
|
||||
|
||||
const adds: addedNodeMutation[] = [];
|
||||
|
||||
/**
|
||||
* Sometimes child node may be pushed before its newly added
|
||||
* parent, so we init a queue to store these nodes.
|
||||
*/
|
||||
const addList = new DoubleLinkedList();
|
||||
const getNextId = (n: Node): number | null => {
|
||||
let ns: Node | null = n;
|
||||
let nextId: number | null = IGNORED_NODE; // slimDOM: ignored
|
||||
while (nextId === IGNORED_NODE) {
|
||||
ns = ns && ns.nextSibling;
|
||||
nextId = ns && this.mirror.getId((ns as unknown) as INode);
|
||||
}
|
||||
if (nextId === -1 && isBlocked(n.nextSibling, this.blockClass)) {
|
||||
nextId = null;
|
||||
}
|
||||
return nextId;
|
||||
};
|
||||
const pushAdd = (n: Node) => {
|
||||
const shadowHost: Element | null = n.getRootNode
|
||||
? (n.getRootNode() as ShadowRoot)?.host
|
||||
: null;
|
||||
const notInDoc = !this.doc.contains(n) && !this.doc.contains(shadowHost);
|
||||
if (!n.parentNode || notInDoc) {
|
||||
return;
|
||||
}
|
||||
const parentId = isShadowRoot(n.parentNode)
|
||||
? this.mirror.getId((shadowHost as unknown) as INode)
|
||||
: this.mirror.getId((n.parentNode as Node) as INode);
|
||||
const nextId = getNextId(n);
|
||||
if (parentId === -1 || nextId === -1) {
|
||||
return addList.addNode(n);
|
||||
}
|
||||
let sn = serializeNodeWithId(n, {
|
||||
doc: this.doc,
|
||||
map: this.mirror.map,
|
||||
blockClass: this.blockClass,
|
||||
blockSelector: this.blockSelector,
|
||||
maskTextClass: this.maskTextClass,
|
||||
maskTextSelector: this.maskTextSelector,
|
||||
skipChild: true,
|
||||
inlineStylesheet: this.inlineStylesheet,
|
||||
maskInputOptions: this.maskInputOptions,
|
||||
maskTextFn: this.maskTextFn,
|
||||
maskInputFn: this.maskInputFn,
|
||||
slimDOMOptions: this.slimDOMOptions,
|
||||
recordCanvas: this.recordCanvas,
|
||||
onSerialize: (currentN) => {
|
||||
if (isIframeINode(currentN)) {
|
||||
this.iframeManager.addIframe(currentN);
|
||||
}
|
||||
if (hasShadowRoot(n)) {
|
||||
this.shadowDomManager.addShadowRoot(n.shadowRoot, document);
|
||||
}
|
||||
},
|
||||
onIframeLoad: (iframe, childSn) => {
|
||||
this.iframeManager.attachIframe(iframe, childSn);
|
||||
},
|
||||
});
|
||||
if (sn) {
|
||||
adds.push({
|
||||
parentId,
|
||||
nextId,
|
||||
node: sn,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
while (this.mapRemoves.length) {
|
||||
this.mirror.removeNodeFromMap(this.mapRemoves.shift() as INode);
|
||||
}
|
||||
|
||||
for (const n of this.movedSet) {
|
||||
if (
|
||||
isParentRemoved(this.removes, n, this.mirror) &&
|
||||
!this.movedSet.has(n.parentNode!)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pushAdd(n);
|
||||
}
|
||||
|
||||
for (const n of this.addedSet) {
|
||||
if (
|
||||
!isAncestorInSet(this.droppedSet, n) &&
|
||||
!isParentRemoved(this.removes, n, this.mirror)
|
||||
) {
|
||||
pushAdd(n);
|
||||
} else if (isAncestorInSet(this.movedSet, n)) {
|
||||
pushAdd(n);
|
||||
} else {
|
||||
this.droppedSet.add(n);
|
||||
}
|
||||
}
|
||||
|
||||
let candidate: DoubleLinkedListNode | null = null;
|
||||
while (addList.length) {
|
||||
let node: DoubleLinkedListNode | null = null;
|
||||
if (candidate) {
|
||||
const parentId = this.mirror.getId(
|
||||
(candidate.value.parentNode as Node) as INode,
|
||||
);
|
||||
const nextId = getNextId(candidate.value);
|
||||
if (parentId !== -1 && nextId !== -1) {
|
||||
node = candidate;
|
||||
}
|
||||
}
|
||||
if (!node) {
|
||||
for (let index = addList.length - 1; index >= 0; index--) {
|
||||
const _node = addList.get(index)!;
|
||||
const parentId = this.mirror.getId(
|
||||
(_node.value.parentNode as Node) as INode,
|
||||
);
|
||||
const nextId = getNextId(_node.value);
|
||||
if (parentId !== -1 && nextId !== -1) {
|
||||
node = _node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!node) {
|
||||
/**
|
||||
* If all nodes in queue could not find a serialized parent,
|
||||
* it may be a bug or corner case. We need to escape the
|
||||
* dead while loop at once.
|
||||
*/
|
||||
while (addList.head) {
|
||||
addList.removeNode(addList.head.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
candidate = node.previous;
|
||||
addList.removeNode(node.value);
|
||||
pushAdd(node.value);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
texts: this.texts
|
||||
.map((text) => ({
|
||||
id: this.mirror.getId(text.node as INode),
|
||||
value: text.value,
|
||||
}))
|
||||
// text mutation's id was not in the mirror map means the target node has been removed
|
||||
.filter((text) => this.mirror.has(text.id)),
|
||||
attributes: this.attributes
|
||||
.map((attribute) => ({
|
||||
id: this.mirror.getId(attribute.node as INode),
|
||||
attributes: attribute.attributes,
|
||||
}))
|
||||
// attribute mutation's id was not in the mirror map means the target node has been removed
|
||||
.filter((attribute) => this.mirror.has(attribute.id)),
|
||||
removes: this.removes,
|
||||
adds,
|
||||
};
|
||||
// payload may be empty if the mutations happened in some blocked elements
|
||||
if (
|
||||
!payload.texts.length &&
|
||||
!payload.attributes.length &&
|
||||
!payload.removes.length &&
|
||||
!payload.adds.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset
|
||||
this.texts = [];
|
||||
this.attributes = [];
|
||||
this.removes = [];
|
||||
this.addedSet = new Set<Node>();
|
||||
this.movedSet = new Set<Node>();
|
||||
this.droppedSet = new Set<Node>();
|
||||
this.movedMap = {};
|
||||
|
||||
this.emissionCallback(payload);
|
||||
};
|
||||
|
||||
private processMutation = (m: mutationRecord) => {
|
||||
if (isIgnored(m.target)) {
|
||||
return;
|
||||
}
|
||||
switch (m.type) {
|
||||
case 'characterData': {
|
||||
const value = m.target.textContent;
|
||||
if (!isBlocked(m.target, this.blockClass) && value !== m.oldValue) {
|
||||
this.texts.push({
|
||||
value:
|
||||
needMaskingText(
|
||||
m.target,
|
||||
this.maskTextClass,
|
||||
this.maskTextSelector,
|
||||
) && value
|
||||
? this.maskTextFn
|
||||
? this.maskTextFn(value)
|
||||
: value.replace(/[\S]/g, '*')
|
||||
: value,
|
||||
node: m.target,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'attributes': {
|
||||
const target = m.target as HTMLElement;
|
||||
let value = (m.target as HTMLElement).getAttribute(m.attributeName!);
|
||||
if (m.attributeName === 'value') {
|
||||
value = maskInputValue({
|
||||
maskInputOptions: this.maskInputOptions,
|
||||
tagName: (m.target as HTMLElement).tagName,
|
||||
type: (m.target as HTMLElement).getAttribute('type'),
|
||||
value,
|
||||
maskInputFn: this.maskInputFn,
|
||||
});
|
||||
}
|
||||
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
|
||||
return;
|
||||
}
|
||||
let item: attributeCursor | undefined = this.attributes.find(
|
||||
(a) => a.node === m.target,
|
||||
);
|
||||
if (!item) {
|
||||
item = {
|
||||
node: m.target,
|
||||
attributes: {},
|
||||
};
|
||||
this.attributes.push(item);
|
||||
}
|
||||
if (m.attributeName === 'style') {
|
||||
const old = this.doc.createElement('span');
|
||||
if (m.oldValue) {
|
||||
old.setAttribute('style', m.oldValue);
|
||||
}
|
||||
if (
|
||||
item.attributes['style'] === undefined ||
|
||||
item.attributes['style'] === null
|
||||
) {
|
||||
item.attributes['style'] = {};
|
||||
}
|
||||
const styleObj = item.attributes['style'] as styleAttributeValue;
|
||||
for (let i = 0; i < target.style.length; i++) {
|
||||
let pname = target.style[i];
|
||||
const newValue = target.style.getPropertyValue(pname);
|
||||
const newPriority = target.style.getPropertyPriority(pname);
|
||||
if (
|
||||
newValue != old.style.getPropertyValue(pname) ||
|
||||
newPriority != old.style.getPropertyPriority(pname)
|
||||
) {
|
||||
if (newPriority == '') {
|
||||
styleObj[pname] = newValue;
|
||||
} else {
|
||||
styleObj[pname] = [newValue, newPriority];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < old.style.length; i++) {
|
||||
let pname = old.style[i];
|
||||
if (
|
||||
target.style.getPropertyValue(pname) === '' ||
|
||||
!target.style.getPropertyValue(pname) // covering potential non-standard browsers
|
||||
) {
|
||||
styleObj[pname] = false; // delete
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// overwrite attribute if the mutations was triggered in same time
|
||||
item.attributes[m.attributeName!] = transformAttribute(
|
||||
this.doc,
|
||||
(m.target as HTMLElement).tagName,
|
||||
m.attributeName!,
|
||||
value!,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'childList': {
|
||||
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
|
||||
m.removedNodes.forEach((n) => {
|
||||
const nodeId = this.mirror.getId(n as INode);
|
||||
const parentId = isShadowRoot(m.target)
|
||||
? this.mirror.getId((m.target.host as unknown) as INode)
|
||||
: this.mirror.getId(m.target as INode);
|
||||
if (
|
||||
isBlocked(n, this.blockClass) ||
|
||||
isBlocked(m.target, this.blockClass) ||
|
||||
isIgnored(n)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// removed node has not been serialized yet, just remove it from the Set
|
||||
if (this.addedSet.has(n)) {
|
||||
deepDelete(this.addedSet, n);
|
||||
this.droppedSet.add(n);
|
||||
} else if (this.addedSet.has(m.target) && nodeId === -1) {
|
||||
/**
|
||||
* If target was newly added and removed child node was
|
||||
* not serialized, it means the child node has been removed
|
||||
* before callback fired, so we can ignore it because
|
||||
* newly added node will be serialized without child nodes.
|
||||
* TODO: verify this
|
||||
*/
|
||||
} else if (isAncestorRemoved(m.target as INode, this.mirror)) {
|
||||
/**
|
||||
* If parent id was not in the mirror map any more, it
|
||||
* means the parent node has already been removed. So
|
||||
* the node is also removed which we do not need to track
|
||||
* and replay.
|
||||
*/
|
||||
} else if (
|
||||
this.movedSet.has(n) &&
|
||||
this.movedMap[moveKey(nodeId, parentId)]
|
||||
) {
|
||||
deepDelete(this.movedSet, n);
|
||||
} else {
|
||||
this.removes.push({
|
||||
parentId,
|
||||
id: nodeId,
|
||||
isShadow: isShadowRoot(m.target) ? true : undefined,
|
||||
});
|
||||
}
|
||||
this.mapRemoves.push(n);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private genAdds = (n: Node | INode, target?: Node | INode) => {
|
||||
if (isBlocked(n, this.blockClass)) {
|
||||
return;
|
||||
}
|
||||
if (target && isBlocked(target, this.blockClass)) {
|
||||
return;
|
||||
}
|
||||
if (isINode(n)) {
|
||||
if (isIgnored(n)) {
|
||||
return;
|
||||
}
|
||||
this.movedSet.add(n);
|
||||
let targetId: number | null = null;
|
||||
if (target && isINode(target)) {
|
||||
targetId = target.__sn.id;
|
||||
}
|
||||
if (targetId) {
|
||||
this.movedMap[moveKey(n.__sn.id, targetId)] = true;
|
||||
}
|
||||
} else {
|
||||
this.addedSet.add(n);
|
||||
this.droppedSet.delete(n);
|
||||
}
|
||||
n.childNodes.forEach((childN) => this.genAdds(childN));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Some utils to handle the mutation observer DOM records.
|
||||
* It should be more clear to extend the native data structure
|
||||
* like Set and Map, but currently Typescript does not support
|
||||
* that.
|
||||
*/
|
||||
function deepDelete(addsSet: Set<Node>, n: Node) {
|
||||
addsSet.delete(n);
|
||||
n.childNodes.forEach((childN) => deepDelete(addsSet, childN));
|
||||
}
|
||||
|
||||
function isParentRemoved(
|
||||
removes: removedNodeMutation[],
|
||||
n: Node,
|
||||
mirror: Mirror,
|
||||
): boolean {
|
||||
const { parentNode } = n;
|
||||
if (!parentNode) {
|
||||
return false;
|
||||
}
|
||||
const parentId = mirror.getId((parentNode as Node) as INode);
|
||||
if (removes.some((r) => r.id === parentId)) {
|
||||
return true;
|
||||
}
|
||||
return isParentRemoved(removes, parentNode, mirror);
|
||||
}
|
||||
|
||||
function isAncestorInSet(set: Set<Node>, n: Node): boolean {
|
||||
const { parentNode } = n;
|
||||
if (!parentNode) {
|
||||
return false;
|
||||
}
|
||||
if (set.has(parentNode)) {
|
||||
return true;
|
||||
}
|
||||
return isAncestorInSet(set, parentNode);
|
||||
}
|
||||
824
packages/rrweb/src/record/observer.ts
Normal file
824
packages/rrweb/src/record/observer.ts
Normal file
@@ -0,0 +1,824 @@
|
||||
import {
|
||||
INode,
|
||||
MaskInputOptions,
|
||||
SlimDOMOptions,
|
||||
maskInputValue,
|
||||
MaskInputFn,
|
||||
MaskTextFn,
|
||||
} from 'rrweb-snapshot';
|
||||
import { FontFaceDescriptors, FontFaceSet } from 'css-font-loading-module';
|
||||
import {
|
||||
throttle,
|
||||
on,
|
||||
hookSetter,
|
||||
getWindowHeight,
|
||||
getWindowWidth,
|
||||
isBlocked,
|
||||
isTouchEvent,
|
||||
patch,
|
||||
} from '../utils';
|
||||
import {
|
||||
mutationCallBack,
|
||||
observerParam,
|
||||
mousemoveCallBack,
|
||||
mousePosition,
|
||||
mouseInteractionCallBack,
|
||||
MouseInteractions,
|
||||
listenerHandler,
|
||||
scrollCallback,
|
||||
styleSheetRuleCallback,
|
||||
viewportResizeCallback,
|
||||
inputValue,
|
||||
inputCallback,
|
||||
hookResetter,
|
||||
blockClass,
|
||||
maskTextClass,
|
||||
IncrementalSource,
|
||||
hooksParam,
|
||||
Arguments,
|
||||
mediaInteractionCallback,
|
||||
MediaInteractions,
|
||||
SamplingStrategy,
|
||||
canvasMutationCallback,
|
||||
fontCallback,
|
||||
fontParam,
|
||||
Mirror,
|
||||
} from '../types';
|
||||
import MutationBuffer from './mutation';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
import { ShadowDomManager } from './shadow-dom-manager';
|
||||
|
||||
type WindowWithStoredMutationObserver = Window & {
|
||||
__rrMutationObserver?: MutationObserver;
|
||||
};
|
||||
type WindowWithAngularZone = Window & {
|
||||
Zone?: {
|
||||
__symbol__?: (key: string) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export const mutationBuffers: MutationBuffer[] = [];
|
||||
|
||||
function getEventTarget(event: Event): EventTarget | null {
|
||||
try {
|
||||
if ('composedPath' in event) {
|
||||
const path = event.composedPath();
|
||||
if (path.length) {
|
||||
return path[0];
|
||||
}
|
||||
} else if (
|
||||
'path' in event &&
|
||||
(event as { path: EventTarget[] }).path.length
|
||||
) {
|
||||
return (event as { path: EventTarget[] }).path[0];
|
||||
}
|
||||
return event.target;
|
||||
} catch {
|
||||
return event.target;
|
||||
}
|
||||
}
|
||||
|
||||
export 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,
|
||||
slimDOMOptions: SlimDOMOptions,
|
||||
mirror: Mirror,
|
||||
iframeManager: IframeManager,
|
||||
shadowDomManager: ShadowDomManager,
|
||||
rootEl: Node,
|
||||
): MutationObserver {
|
||||
const mutationBuffer = new MutationBuffer();
|
||||
mutationBuffers.push(mutationBuffer);
|
||||
// see mutation.ts for details
|
||||
mutationBuffer.init(
|
||||
cb,
|
||||
blockClass,
|
||||
blockSelector,
|
||||
maskTextClass,
|
||||
maskTextSelector,
|
||||
inlineStylesheet,
|
||||
maskInputOptions,
|
||||
maskTextFn,
|
||||
maskInputFn,
|
||||
recordCanvas,
|
||||
slimDOMOptions,
|
||||
doc,
|
||||
mirror,
|
||||
iframeManager,
|
||||
shadowDomManager,
|
||||
);
|
||||
let mutationObserverCtor =
|
||||
window.MutationObserver ||
|
||||
/**
|
||||
* Some websites may disable MutationObserver by removing it from the window object.
|
||||
* If someone is using rrweb to build a browser extention or things like it, they
|
||||
* could not change the website's code but can have an opportunity to inject some
|
||||
* code before the website executing its JS logic.
|
||||
* Then they can do this to store the native MutationObserver:
|
||||
* window.__rrMutationObserver = MutationObserver
|
||||
*/
|
||||
(window as WindowWithStoredMutationObserver).__rrMutationObserver;
|
||||
const angularZoneSymbol = (window as WindowWithAngularZone)?.Zone?.__symbol__?.(
|
||||
'MutationObserver',
|
||||
);
|
||||
if (
|
||||
angularZoneSymbol &&
|
||||
((window as unknown) as Record<string, typeof MutationObserver>)[
|
||||
angularZoneSymbol
|
||||
]
|
||||
) {
|
||||
mutationObserverCtor = ((window as unknown) as Record<
|
||||
string,
|
||||
typeof MutationObserver
|
||||
>)[angularZoneSymbol];
|
||||
}
|
||||
const observer = new mutationObserverCtor(
|
||||
mutationBuffer.processMutations.bind(mutationBuffer),
|
||||
);
|
||||
observer.observe(rootEl, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
characterData: true,
|
||||
characterDataOldValue: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
return observer;
|
||||
}
|
||||
|
||||
function initMoveObserver(
|
||||
cb: mousemoveCallBack,
|
||||
sampling: SamplingStrategy,
|
||||
doc: Document,
|
||||
mirror: Mirror,
|
||||
): listenerHandler {
|
||||
if (sampling.mousemove === false) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const threshold =
|
||||
typeof sampling.mousemove === 'number' ? sampling.mousemove : 50;
|
||||
const callbackThreshold =
|
||||
typeof sampling.mousemoveCallback === 'number'
|
||||
? sampling.mousemoveCallback
|
||||
: 500;
|
||||
|
||||
let positions: mousePosition[] = [];
|
||||
let timeBaseline: number | null;
|
||||
const wrappedCb = throttle(
|
||||
(
|
||||
source:
|
||||
| IncrementalSource.MouseMove
|
||||
| IncrementalSource.TouchMove
|
||||
| IncrementalSource.Drag,
|
||||
) => {
|
||||
const totalOffset = Date.now() - timeBaseline!;
|
||||
cb(
|
||||
positions.map((p) => {
|
||||
p.timeOffset -= totalOffset;
|
||||
return p;
|
||||
}),
|
||||
source,
|
||||
);
|
||||
positions = [];
|
||||
timeBaseline = null;
|
||||
},
|
||||
callbackThreshold,
|
||||
);
|
||||
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>(
|
||||
(evt) => {
|
||||
const target = getEventTarget(evt);
|
||||
const { clientX, clientY } = isTouchEvent(evt)
|
||||
? evt.changedTouches[0]
|
||||
: evt;
|
||||
if (!timeBaseline) {
|
||||
timeBaseline = Date.now();
|
||||
}
|
||||
positions.push({
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
id: mirror.getId(target as INode),
|
||||
timeOffset: Date.now() - timeBaseline,
|
||||
});
|
||||
wrappedCb(
|
||||
evt instanceof DragEvent
|
||||
? IncrementalSource.Drag
|
||||
: evt instanceof MouseEvent
|
||||
? IncrementalSource.MouseMove
|
||||
: IncrementalSource.TouchMove,
|
||||
);
|
||||
},
|
||||
threshold,
|
||||
{
|
||||
trailing: false,
|
||||
},
|
||||
);
|
||||
const handlers = [
|
||||
on('mousemove', updatePosition, doc),
|
||||
on('touchmove', updatePosition, doc),
|
||||
on('drag', updatePosition, doc),
|
||||
];
|
||||
return () => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
}
|
||||
|
||||
function initMouseInteractionObserver(
|
||||
cb: mouseInteractionCallBack,
|
||||
doc: Document,
|
||||
mirror: Mirror,
|
||||
blockClass: blockClass,
|
||||
sampling: SamplingStrategy,
|
||||
): listenerHandler {
|
||||
if (sampling.mouseInteraction === false) {
|
||||
return () => {};
|
||||
}
|
||||
const disableMap: Record<string, boolean | undefined> =
|
||||
sampling.mouseInteraction === true ||
|
||||
sampling.mouseInteraction === undefined
|
||||
? {}
|
||||
: sampling.mouseInteraction;
|
||||
|
||||
const handlers: listenerHandler[] = [];
|
||||
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
|
||||
return (event: MouseEvent | TouchEvent) => {
|
||||
const target = getEventTarget(event) as Node;
|
||||
if (isBlocked(target as Node, blockClass)) {
|
||||
return;
|
||||
}
|
||||
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
|
||||
if (!e) {
|
||||
return;
|
||||
}
|
||||
const id = mirror.getId(target as INode);
|
||||
const { clientX, clientY } = e;
|
||||
cb({
|
||||
type: MouseInteractions[eventKey],
|
||||
id,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
});
|
||||
};
|
||||
};
|
||||
Object.keys(MouseInteractions)
|
||||
.filter(
|
||||
(key) =>
|
||||
Number.isNaN(Number(key)) &&
|
||||
!key.endsWith('_Departed') &&
|
||||
disableMap[key] !== false,
|
||||
)
|
||||
.forEach((eventKey: keyof typeof MouseInteractions) => {
|
||||
const eventName = eventKey.toLowerCase();
|
||||
const handler = getHandler(eventKey);
|
||||
handlers.push(on(eventName, handler, doc));
|
||||
});
|
||||
return () => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
}
|
||||
|
||||
export function initScrollObserver(
|
||||
cb: scrollCallback,
|
||||
doc: Document,
|
||||
mirror: Mirror,
|
||||
blockClass: blockClass,
|
||||
sampling: SamplingStrategy,
|
||||
): listenerHandler {
|
||||
const updatePosition = throttle<UIEvent>((evt) => {
|
||||
const target = getEventTarget(evt);
|
||||
if (!target || isBlocked(target as Node, blockClass)) {
|
||||
return;
|
||||
}
|
||||
const id = mirror.getId(target as INode);
|
||||
if (target === doc) {
|
||||
const scrollEl = (doc.scrollingElement || doc.documentElement)!;
|
||||
cb({
|
||||
id,
|
||||
x: scrollEl.scrollLeft,
|
||||
y: scrollEl.scrollTop,
|
||||
});
|
||||
} else {
|
||||
cb({
|
||||
id,
|
||||
x: (target as HTMLElement).scrollLeft,
|
||||
y: (target as HTMLElement).scrollTop,
|
||||
});
|
||||
}
|
||||
}, sampling.scroll || 100);
|
||||
return on('scroll', updatePosition, doc);
|
||||
}
|
||||
|
||||
function initViewportResizeObserver(
|
||||
cb: viewportResizeCallback,
|
||||
): listenerHandler {
|
||||
let lastH = -1;
|
||||
let lastW = -1;
|
||||
const updateDimension = throttle(() => {
|
||||
const height = getWindowHeight();
|
||||
const width = getWindowWidth();
|
||||
if (lastH !== height || lastW !== width) {
|
||||
cb({
|
||||
width: Number(width),
|
||||
height: Number(height),
|
||||
});
|
||||
lastH = height;
|
||||
lastW = width;
|
||||
}
|
||||
}, 200);
|
||||
return on('resize', updateDimension, window);
|
||||
}
|
||||
|
||||
function wrapEventWithUserTriggeredFlag(
|
||||
v: inputValue,
|
||||
enable: boolean,
|
||||
): inputValue {
|
||||
const value = { ...v };
|
||||
if (!enable) delete value.userTriggered;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
|
||||
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
|
||||
function initInputObserver(
|
||||
cb: inputCallback,
|
||||
doc: Document,
|
||||
mirror: Mirror,
|
||||
blockClass: blockClass,
|
||||
ignoreClass: string,
|
||||
maskInputOptions: MaskInputOptions,
|
||||
maskInputFn: MaskInputFn | undefined,
|
||||
sampling: SamplingStrategy,
|
||||
userTriggeredOnInput: boolean,
|
||||
): listenerHandler {
|
||||
function eventHandler(event: Event) {
|
||||
const target = getEventTarget(event);
|
||||
const userTriggered = event.isTrusted;
|
||||
if (
|
||||
!target ||
|
||||
!(target as Element).tagName ||
|
||||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
|
||||
isBlocked(target as Node, blockClass)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const type: string | undefined = (target as HTMLInputElement).type;
|
||||
if ((target as HTMLElement).classList.contains(ignoreClass)) {
|
||||
return;
|
||||
}
|
||||
let text = (target as HTMLInputElement).value;
|
||||
let isChecked = false;
|
||||
if (type === 'radio' || type === 'checkbox') {
|
||||
isChecked = (target as HTMLInputElement).checked;
|
||||
} else if (
|
||||
maskInputOptions[
|
||||
(target as Element).tagName.toLowerCase() as keyof MaskInputOptions
|
||||
] ||
|
||||
maskInputOptions[type as keyof MaskInputOptions]
|
||||
) {
|
||||
text = maskInputValue({
|
||||
maskInputOptions,
|
||||
tagName: (target as HTMLElement).tagName,
|
||||
type,
|
||||
value: text,
|
||||
maskInputFn,
|
||||
});
|
||||
}
|
||||
cbWithDedup(
|
||||
target,
|
||||
wrapEventWithUserTriggeredFlag(
|
||||
{ text, isChecked, userTriggered },
|
||||
userTriggeredOnInput,
|
||||
),
|
||||
);
|
||||
// if a radio was checked
|
||||
// the other radios with the same name attribute will be unchecked.
|
||||
const name: string | undefined = (target as HTMLInputElement).name;
|
||||
if (type === 'radio' && name && isChecked) {
|
||||
doc
|
||||
.querySelectorAll(`input[type="radio"][name="${name}"]`)
|
||||
.forEach((el) => {
|
||||
if (el !== target) {
|
||||
cbWithDedup(
|
||||
el,
|
||||
wrapEventWithUserTriggeredFlag(
|
||||
{
|
||||
text: (el as HTMLInputElement).value,
|
||||
isChecked: !isChecked,
|
||||
userTriggered: false,
|
||||
},
|
||||
userTriggeredOnInput,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
function cbWithDedup(target: EventTarget, v: inputValue) {
|
||||
const lastInputValue = lastInputValueMap.get(target);
|
||||
if (
|
||||
!lastInputValue ||
|
||||
lastInputValue.text !== v.text ||
|
||||
lastInputValue.isChecked !== v.isChecked
|
||||
) {
|
||||
lastInputValueMap.set(target, v);
|
||||
const id = mirror.getId(target as INode);
|
||||
cb({
|
||||
...v,
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
|
||||
const handlers: Array<
|
||||
listenerHandler | hookResetter
|
||||
> = events.map((eventName) => on(eventName, eventHandler, doc));
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
);
|
||||
const hookProperties: Array<[HTMLElement, string]> = [
|
||||
[HTMLInputElement.prototype, 'value'],
|
||||
[HTMLInputElement.prototype, 'checked'],
|
||||
[HTMLSelectElement.prototype, 'value'],
|
||||
[HTMLTextAreaElement.prototype, 'value'],
|
||||
// Some UI library use selectedIndex to set select value
|
||||
[HTMLSelectElement.prototype, 'selectedIndex'],
|
||||
];
|
||||
if (propertyDescriptor && propertyDescriptor.set) {
|
||||
handlers.push(
|
||||
...hookProperties.map((p) =>
|
||||
hookSetter<HTMLElement>(p[0], p[1], {
|
||||
set() {
|
||||
// mock to a normal event
|
||||
eventHandler({ target: this } as Event);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
}
|
||||
|
||||
function initStyleSheetObserver(
|
||||
cb: styleSheetRuleCallback,
|
||||
mirror: Mirror,
|
||||
): listenerHandler {
|
||||
const insertRule = CSSStyleSheet.prototype.insertRule;
|
||||
CSSStyleSheet.prototype.insertRule = function (rule: string, index?: number) {
|
||||
const id = mirror.getId(this.ownerNode as INode);
|
||||
if (id !== -1) {
|
||||
cb({
|
||||
id,
|
||||
adds: [{ rule, index }],
|
||||
});
|
||||
}
|
||||
return insertRule.apply(this, arguments);
|
||||
};
|
||||
|
||||
const deleteRule = CSSStyleSheet.prototype.deleteRule;
|
||||
CSSStyleSheet.prototype.deleteRule = function (index: number) {
|
||||
const id = mirror.getId(this.ownerNode as INode);
|
||||
if (id !== -1) {
|
||||
cb({
|
||||
id,
|
||||
removes: [{ index }],
|
||||
});
|
||||
}
|
||||
return deleteRule.apply(this, arguments);
|
||||
};
|
||||
|
||||
return () => {
|
||||
CSSStyleSheet.prototype.insertRule = insertRule;
|
||||
CSSStyleSheet.prototype.deleteRule = deleteRule;
|
||||
};
|
||||
}
|
||||
|
||||
function initMediaInteractionObserver(
|
||||
mediaInteractionCb: mediaInteractionCallback,
|
||||
blockClass: blockClass,
|
||||
mirror: Mirror,
|
||||
): listenerHandler {
|
||||
const handler = (type: MediaInteractions) => (event: Event) => {
|
||||
const target = getEventTarget(event);
|
||||
if (!target || isBlocked(target as Node, blockClass)) {
|
||||
return;
|
||||
}
|
||||
mediaInteractionCb({
|
||||
type,
|
||||
id: mirror.getId(target as INode),
|
||||
currentTime: (target as HTMLMediaElement).currentTime,
|
||||
});
|
||||
};
|
||||
const handlers = [
|
||||
on('play', handler(MediaInteractions.Play)),
|
||||
on('pause', handler(MediaInteractions.Pause)),
|
||||
on('seeked', handler(MediaInteractions.Seeked)),
|
||||
];
|
||||
return () => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
}
|
||||
|
||||
function initCanvasMutationObserver(
|
||||
cb: canvasMutationCallback,
|
||||
blockClass: blockClass,
|
||||
mirror: Mirror,
|
||||
): 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<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>(
|
||||
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): listenerHandler {
|
||||
const handlers: listenerHandler[] = [];
|
||||
|
||||
const fontMap = new WeakMap<FontFace, fontParam>();
|
||||
|
||||
const originalFontFace = FontFace;
|
||||
// tslint:disable-next-line: no-any
|
||||
(window as any).FontFace = function FontFace(
|
||||
family: string,
|
||||
source: string | ArrayBufferView,
|
||||
descriptors?: FontFaceDescriptors,
|
||||
) {
|
||||
const fontFace = new originalFontFace(family, source, descriptors);
|
||||
fontMap.set(fontFace, {
|
||||
family,
|
||||
buffer: typeof source !== 'string',
|
||||
descriptors,
|
||||
fontSource:
|
||||
typeof source === 'string'
|
||||
? source
|
||||
: // tslint:disable-next-line: no-any
|
||||
JSON.stringify(Array.from(new Uint8Array(source as any))),
|
||||
});
|
||||
return fontFace;
|
||||
};
|
||||
|
||||
const restoreHandler = patch(document.fonts, 'add', function (original) {
|
||||
return function (this: FontFaceSet, fontFace: FontFace) {
|
||||
setTimeout(() => {
|
||||
const p = fontMap.get(fontFace);
|
||||
if (p) {
|
||||
cb(p);
|
||||
fontMap.delete(fontFace);
|
||||
}
|
||||
}, 0);
|
||||
return original.apply(this, [fontFace]);
|
||||
};
|
||||
});
|
||||
|
||||
handlers.push(() => {
|
||||
// tslint:disable-next-line: no-any
|
||||
(window as any).FonFace = originalFontFace;
|
||||
});
|
||||
handlers.push(restoreHandler);
|
||||
|
||||
return () => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
}
|
||||
|
||||
function mergeHooks(o: observerParam, hooks: hooksParam) {
|
||||
const {
|
||||
mutationCb,
|
||||
mousemoveCb,
|
||||
mouseInteractionCb,
|
||||
scrollCb,
|
||||
viewportResizeCb,
|
||||
inputCb,
|
||||
mediaInteractionCb,
|
||||
styleSheetRuleCb,
|
||||
canvasMutationCb,
|
||||
fontCb,
|
||||
} = o;
|
||||
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
|
||||
if (hooks.mutation) {
|
||||
hooks.mutation(...p);
|
||||
}
|
||||
mutationCb(...p);
|
||||
};
|
||||
o.mousemoveCb = (...p: Arguments<mousemoveCallBack>) => {
|
||||
if (hooks.mousemove) {
|
||||
hooks.mousemove(...p);
|
||||
}
|
||||
mousemoveCb(...p);
|
||||
};
|
||||
o.mouseInteractionCb = (...p: Arguments<mouseInteractionCallBack>) => {
|
||||
if (hooks.mouseInteraction) {
|
||||
hooks.mouseInteraction(...p);
|
||||
}
|
||||
mouseInteractionCb(...p);
|
||||
};
|
||||
o.scrollCb = (...p: Arguments<scrollCallback>) => {
|
||||
if (hooks.scroll) {
|
||||
hooks.scroll(...p);
|
||||
}
|
||||
scrollCb(...p);
|
||||
};
|
||||
o.viewportResizeCb = (...p: Arguments<viewportResizeCallback>) => {
|
||||
if (hooks.viewportResize) {
|
||||
hooks.viewportResize(...p);
|
||||
}
|
||||
viewportResizeCb(...p);
|
||||
};
|
||||
o.inputCb = (...p: Arguments<inputCallback>) => {
|
||||
if (hooks.input) {
|
||||
hooks.input(...p);
|
||||
}
|
||||
inputCb(...p);
|
||||
};
|
||||
o.mediaInteractionCb = (...p: Arguments<mediaInteractionCallback>) => {
|
||||
if (hooks.mediaInteaction) {
|
||||
hooks.mediaInteaction(...p);
|
||||
}
|
||||
mediaInteractionCb(...p);
|
||||
};
|
||||
o.styleSheetRuleCb = (...p: Arguments<styleSheetRuleCallback>) => {
|
||||
if (hooks.styleSheetRule) {
|
||||
hooks.styleSheetRule(...p);
|
||||
}
|
||||
styleSheetRuleCb(...p);
|
||||
};
|
||||
o.canvasMutationCb = (...p: Arguments<canvasMutationCallback>) => {
|
||||
if (hooks.canvasMutation) {
|
||||
hooks.canvasMutation(...p);
|
||||
}
|
||||
canvasMutationCb(...p);
|
||||
};
|
||||
o.fontCb = (...p: Arguments<fontCallback>) => {
|
||||
if (hooks.font) {
|
||||
hooks.font(...p);
|
||||
}
|
||||
fontCb(...p);
|
||||
};
|
||||
}
|
||||
|
||||
export function initObservers(
|
||||
o: observerParam,
|
||||
hooks: hooksParam = {},
|
||||
): listenerHandler {
|
||||
mergeHooks(o, hooks);
|
||||
const mutationObserver = initMutationObserver(
|
||||
o.mutationCb,
|
||||
o.doc,
|
||||
o.blockClass,
|
||||
o.blockSelector,
|
||||
o.maskTextClass,
|
||||
o.maskTextSelector,
|
||||
o.inlineStylesheet,
|
||||
o.maskInputOptions,
|
||||
o.maskTextFn,
|
||||
o.maskInputFn,
|
||||
o.recordCanvas,
|
||||
o.slimDOMOptions,
|
||||
o.mirror,
|
||||
o.iframeManager,
|
||||
o.shadowDomManager,
|
||||
o.doc,
|
||||
);
|
||||
const mousemoveHandler = initMoveObserver(
|
||||
o.mousemoveCb,
|
||||
o.sampling,
|
||||
o.doc,
|
||||
o.mirror,
|
||||
);
|
||||
const mouseInteractionHandler = initMouseInteractionObserver(
|
||||
o.mouseInteractionCb,
|
||||
o.doc,
|
||||
o.mirror,
|
||||
o.blockClass,
|
||||
o.sampling,
|
||||
);
|
||||
const scrollHandler = initScrollObserver(
|
||||
o.scrollCb,
|
||||
o.doc,
|
||||
o.mirror,
|
||||
o.blockClass,
|
||||
o.sampling,
|
||||
);
|
||||
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
|
||||
const inputHandler = initInputObserver(
|
||||
o.inputCb,
|
||||
o.doc,
|
||||
o.mirror,
|
||||
o.blockClass,
|
||||
o.ignoreClass,
|
||||
o.maskInputOptions,
|
||||
o.maskInputFn,
|
||||
o.sampling,
|
||||
o.userTriggeredOnInput,
|
||||
);
|
||||
const mediaInteractionHandler = initMediaInteractionObserver(
|
||||
o.mediaInteractionCb,
|
||||
o.blockClass,
|
||||
o.mirror,
|
||||
);
|
||||
const styleSheetObserver = initStyleSheetObserver(
|
||||
o.styleSheetRuleCb,
|
||||
o.mirror,
|
||||
);
|
||||
const canvasMutationObserver = o.recordCanvas
|
||||
? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass, o.mirror)
|
||||
: () => {};
|
||||
const fontObserver = o.collectFonts ? initFontObserver(o.fontCb) : () => {};
|
||||
// plugins
|
||||
const pluginHandlers: listenerHandler[] = [];
|
||||
for (const plugin of o.plugins) {
|
||||
pluginHandlers.push(plugin.observer(plugin.callback, plugin.options));
|
||||
}
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
mousemoveHandler();
|
||||
mouseInteractionHandler();
|
||||
scrollHandler();
|
||||
viewportResizeHandler();
|
||||
inputHandler();
|
||||
mediaInteractionHandler();
|
||||
styleSheetObserver();
|
||||
canvasMutationObserver();
|
||||
fontObserver();
|
||||
pluginHandlers.forEach((h) => h());
|
||||
};
|
||||
}
|
||||
80
packages/rrweb/src/record/shadow-dom-manager.ts
Normal file
80
packages/rrweb/src/record/shadow-dom-manager.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
mutationCallBack,
|
||||
blockClass,
|
||||
maskTextClass,
|
||||
Mirror,
|
||||
scrollCallback,
|
||||
SamplingStrategy,
|
||||
} from '../types';
|
||||
import {
|
||||
MaskInputOptions,
|
||||
SlimDOMOptions,
|
||||
MaskTextFn,
|
||||
MaskInputFn,
|
||||
} from 'rrweb-snapshot';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
import { initMutationObserver, initScrollObserver } from './observer';
|
||||
|
||||
type BypassOptions = {
|
||||
blockClass: blockClass;
|
||||
blockSelector: string | null;
|
||||
maskTextClass: maskTextClass;
|
||||
maskTextSelector: string | null;
|
||||
inlineStylesheet: boolean;
|
||||
maskInputOptions: MaskInputOptions;
|
||||
maskTextFn: MaskTextFn | undefined;
|
||||
maskInputFn: MaskInputFn | undefined;
|
||||
recordCanvas: boolean;
|
||||
sampling: SamplingStrategy;
|
||||
slimDOMOptions: SlimDOMOptions;
|
||||
iframeManager: IframeManager;
|
||||
};
|
||||
|
||||
export class ShadowDomManager {
|
||||
private mutationCb: mutationCallBack;
|
||||
private scrollCb: scrollCallback;
|
||||
private bypassOptions: BypassOptions;
|
||||
private mirror: Mirror;
|
||||
|
||||
constructor(options: {
|
||||
mutationCb: mutationCallBack;
|
||||
scrollCb: scrollCallback;
|
||||
bypassOptions: BypassOptions;
|
||||
mirror: Mirror;
|
||||
}) {
|
||||
this.mutationCb = options.mutationCb;
|
||||
this.scrollCb = options.scrollCb;
|
||||
this.bypassOptions = options.bypassOptions;
|
||||
this.mirror = options.mirror;
|
||||
}
|
||||
|
||||
public addShadowRoot(shadowRoot: ShadowRoot, doc: Document) {
|
||||
initMutationObserver(
|
||||
this.mutationCb,
|
||||
doc,
|
||||
this.bypassOptions.blockClass,
|
||||
this.bypassOptions.blockSelector,
|
||||
this.bypassOptions.maskTextClass,
|
||||
this.bypassOptions.maskTextSelector,
|
||||
this.bypassOptions.inlineStylesheet,
|
||||
this.bypassOptions.maskInputOptions,
|
||||
this.bypassOptions.maskTextFn,
|
||||
this.bypassOptions.maskInputFn,
|
||||
this.bypassOptions.recordCanvas,
|
||||
this.bypassOptions.slimDOMOptions,
|
||||
this.mirror,
|
||||
this.bypassOptions.iframeManager,
|
||||
this,
|
||||
shadowRoot,
|
||||
);
|
||||
initScrollObserver(
|
||||
this.scrollCb,
|
||||
// https://gist.github.com/praveenpuglia/0832da687ed5a5d7a0907046c9ef1813
|
||||
// scroll is not allowed to pass the boundary, so we need to listen the shadow document
|
||||
(shadowRoot as unknown) as Document,
|
||||
this.mirror,
|
||||
this.bypassOptions.blockClass,
|
||||
this.bypassOptions.sampling,
|
||||
);
|
||||
}
|
||||
}
|
||||
1669
packages/rrweb/src/replay/index.ts
Normal file
1669
packages/rrweb/src/replay/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
379
packages/rrweb/src/replay/machine.ts
Normal file
379
packages/rrweb/src/replay/machine.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { createMachine, interpret, assign, StateMachine } from '@xstate/fsm';
|
||||
import {
|
||||
playerConfig,
|
||||
eventWithTime,
|
||||
actionWithDelay,
|
||||
ReplayerEvents,
|
||||
EventType,
|
||||
Emitter,
|
||||
IncrementalSource,
|
||||
} from '../types';
|
||||
import { Timer, addDelay } from './timer';
|
||||
import { needCastInSyncMode } from '../utils';
|
||||
|
||||
export type PlayerContext = {
|
||||
events: eventWithTime[];
|
||||
timer: Timer;
|
||||
timeOffset: number;
|
||||
baselineTime: number;
|
||||
lastPlayedEvent: eventWithTime | null;
|
||||
};
|
||||
export type PlayerEvent =
|
||||
| {
|
||||
type: 'PLAY';
|
||||
payload: {
|
||||
timeOffset: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'CAST_EVENT';
|
||||
payload: {
|
||||
event: eventWithTime;
|
||||
};
|
||||
}
|
||||
| { type: 'PAUSE' }
|
||||
| { type: 'TO_LIVE'; payload: { baselineTime?: number } }
|
||||
| {
|
||||
type: 'ADD_EVENT';
|
||||
payload: {
|
||||
event: eventWithTime;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'END';
|
||||
};
|
||||
export type PlayerState =
|
||||
| {
|
||||
value: 'playing';
|
||||
context: PlayerContext;
|
||||
}
|
||||
| {
|
||||
value: 'paused';
|
||||
context: PlayerContext;
|
||||
}
|
||||
| {
|
||||
value: 'live';
|
||||
context: PlayerContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* If the array have multiple meta and fullsnapshot events,
|
||||
* return the events from last meta to the end.
|
||||
*/
|
||||
export function discardPriorSnapshots(
|
||||
events: eventWithTime[],
|
||||
baselineTime: number,
|
||||
): eventWithTime[] {
|
||||
for (let idx = events.length - 1; idx >= 0; idx--) {
|
||||
const event = events[idx];
|
||||
if (event.type === EventType.Meta) {
|
||||
if (event.timestamp <= baselineTime) {
|
||||
return events.slice(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
type PlayerAssets = {
|
||||
emitter: Emitter;
|
||||
getCastFn(event: eventWithTime, isSync: boolean): () => void;
|
||||
};
|
||||
export function createPlayerService(
|
||||
context: PlayerContext,
|
||||
{ getCastFn, emitter }: PlayerAssets,
|
||||
) {
|
||||
const playerMachine = createMachine<PlayerContext, PlayerEvent, PlayerState>(
|
||||
{
|
||||
id: 'player',
|
||||
context,
|
||||
initial: 'paused',
|
||||
states: {
|
||||
playing: {
|
||||
on: {
|
||||
PAUSE: {
|
||||
target: 'paused',
|
||||
actions: ['pause'],
|
||||
},
|
||||
CAST_EVENT: {
|
||||
target: 'playing',
|
||||
actions: 'castEvent',
|
||||
},
|
||||
END: {
|
||||
target: 'paused',
|
||||
actions: ['resetLastPlayedEvent', 'pause'],
|
||||
},
|
||||
ADD_EVENT: {
|
||||
target: 'playing',
|
||||
actions: ['addEvent'],
|
||||
},
|
||||
},
|
||||
},
|
||||
paused: {
|
||||
on: {
|
||||
PLAY: {
|
||||
target: 'playing',
|
||||
actions: ['recordTimeOffset', 'play'],
|
||||
},
|
||||
CAST_EVENT: {
|
||||
target: 'paused',
|
||||
actions: 'castEvent',
|
||||
},
|
||||
TO_LIVE: {
|
||||
target: 'live',
|
||||
actions: ['startLive'],
|
||||
},
|
||||
ADD_EVENT: {
|
||||
target: 'paused',
|
||||
actions: ['addEvent'],
|
||||
},
|
||||
},
|
||||
},
|
||||
live: {
|
||||
on: {
|
||||
ADD_EVENT: {
|
||||
target: 'live',
|
||||
actions: ['addEvent'],
|
||||
},
|
||||
CAST_EVENT: {
|
||||
target: 'live',
|
||||
actions: ['castEvent'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
castEvent: assign({
|
||||
lastPlayedEvent: (ctx, event) => {
|
||||
if (event.type === 'CAST_EVENT') {
|
||||
return event.payload.event;
|
||||
}
|
||||
return ctx.lastPlayedEvent;
|
||||
},
|
||||
}),
|
||||
recordTimeOffset: assign((ctx, event) => {
|
||||
let timeOffset = ctx.timeOffset;
|
||||
if ('payload' in event && 'timeOffset' in event.payload) {
|
||||
timeOffset = event.payload.timeOffset;
|
||||
}
|
||||
return {
|
||||
...ctx,
|
||||
timeOffset,
|
||||
baselineTime: ctx.events[0].timestamp + timeOffset,
|
||||
};
|
||||
}),
|
||||
play(ctx) {
|
||||
const { timer, events, baselineTime, lastPlayedEvent } = ctx;
|
||||
timer.clear();
|
||||
for (const event of events) {
|
||||
// TODO: improve this API
|
||||
addDelay(event, baselineTime);
|
||||
}
|
||||
const neededEvents = discardPriorSnapshots(events, baselineTime);
|
||||
|
||||
let lastPlayedTimestamp = lastPlayedEvent?.timestamp;
|
||||
if (
|
||||
lastPlayedEvent?.type === EventType.IncrementalSnapshot &&
|
||||
lastPlayedEvent.data.source === IncrementalSource.MouseMove
|
||||
) {
|
||||
lastPlayedTimestamp =
|
||||
lastPlayedEvent.timestamp +
|
||||
lastPlayedEvent.data.positions[0]?.timeOffset;
|
||||
}
|
||||
if (baselineTime < (lastPlayedTimestamp || 0)) {
|
||||
emitter.emit(ReplayerEvents.PlayBack);
|
||||
}
|
||||
|
||||
const actions = new Array<actionWithDelay>();
|
||||
for (const event of neededEvents) {
|
||||
if (
|
||||
lastPlayedTimestamp &&
|
||||
lastPlayedTimestamp < baselineTime &&
|
||||
(event.timestamp <= lastPlayedTimestamp ||
|
||||
event === lastPlayedEvent)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const isSync = event.timestamp < baselineTime;
|
||||
if (isSync && !needCastInSyncMode(event)) {
|
||||
continue;
|
||||
}
|
||||
const castFn = getCastFn(event, isSync);
|
||||
if (isSync) {
|
||||
castFn();
|
||||
} else {
|
||||
actions.push({
|
||||
doAction: () => {
|
||||
castFn();
|
||||
emitter.emit(ReplayerEvents.EventCast, event);
|
||||
},
|
||||
delay: event.delay!,
|
||||
});
|
||||
}
|
||||
}
|
||||
emitter.emit(ReplayerEvents.Flush);
|
||||
timer.addActions(actions);
|
||||
timer.start();
|
||||
},
|
||||
pause(ctx) {
|
||||
ctx.timer.clear();
|
||||
},
|
||||
resetLastPlayedEvent: assign((ctx) => {
|
||||
return {
|
||||
...ctx,
|
||||
lastPlayedEvent: null,
|
||||
};
|
||||
}),
|
||||
startLive: assign({
|
||||
baselineTime: (ctx, event) => {
|
||||
ctx.timer.toggleLiveMode(true);
|
||||
ctx.timer.start();
|
||||
if (event.type === 'TO_LIVE' && event.payload.baselineTime) {
|
||||
return event.payload.baselineTime;
|
||||
}
|
||||
return Date.now();
|
||||
},
|
||||
}),
|
||||
addEvent: assign((ctx, machineEvent) => {
|
||||
const { baselineTime, timer, events } = ctx;
|
||||
if (machineEvent.type === 'ADD_EVENT') {
|
||||
const { event } = machineEvent.payload;
|
||||
addDelay(event, baselineTime);
|
||||
|
||||
let end = events.length - 1;
|
||||
if (!events[end] || events[end].timestamp <= event.timestamp) {
|
||||
// fast track
|
||||
events.push(event);
|
||||
} else {
|
||||
let insertionIndex = -1;
|
||||
let start = 0;
|
||||
while (start <= end) {
|
||||
let mid = Math.floor((start + end) / 2);
|
||||
if (events[mid].timestamp <= event.timestamp) {
|
||||
start = mid + 1;
|
||||
} else {
|
||||
end = mid - 1;
|
||||
}
|
||||
}
|
||||
if (insertionIndex === -1) {
|
||||
insertionIndex = start;
|
||||
}
|
||||
events.splice(insertionIndex, 0, event);
|
||||
}
|
||||
|
||||
const isSync = event.timestamp < baselineTime;
|
||||
const castFn = getCastFn(event, isSync);
|
||||
if (isSync) {
|
||||
castFn();
|
||||
} else if (timer.isActive()) {
|
||||
timer.addAction({
|
||||
doAction: () => {
|
||||
castFn();
|
||||
emitter.emit(ReplayerEvents.EventCast, event);
|
||||
},
|
||||
delay: event.delay!,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...ctx, events };
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
return interpret(playerMachine);
|
||||
}
|
||||
|
||||
export type SpeedContext = {
|
||||
normalSpeed: playerConfig['speed'];
|
||||
timer: Timer;
|
||||
};
|
||||
|
||||
export type SpeedEvent =
|
||||
| {
|
||||
type: 'FAST_FORWARD';
|
||||
payload: { speed: playerConfig['speed'] };
|
||||
}
|
||||
| {
|
||||
type: 'BACK_TO_NORMAL';
|
||||
}
|
||||
| {
|
||||
type: 'SET_SPEED';
|
||||
payload: { speed: playerConfig['speed'] };
|
||||
};
|
||||
|
||||
export type SpeedState =
|
||||
| {
|
||||
value: 'normal';
|
||||
context: SpeedContext;
|
||||
}
|
||||
| {
|
||||
value: 'skipping';
|
||||
context: SpeedContext;
|
||||
};
|
||||
|
||||
export function createSpeedService(context: SpeedContext) {
|
||||
const speedMachine = createMachine<SpeedContext, SpeedEvent, SpeedState>(
|
||||
{
|
||||
id: 'speed',
|
||||
context,
|
||||
initial: 'normal',
|
||||
states: {
|
||||
normal: {
|
||||
on: {
|
||||
FAST_FORWARD: {
|
||||
target: 'skipping',
|
||||
actions: ['recordSpeed', 'setSpeed'],
|
||||
},
|
||||
SET_SPEED: {
|
||||
target: 'normal',
|
||||
actions: ['setSpeed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
skipping: {
|
||||
on: {
|
||||
BACK_TO_NORMAL: {
|
||||
target: 'normal',
|
||||
actions: ['restoreSpeed'],
|
||||
},
|
||||
SET_SPEED: {
|
||||
target: 'normal',
|
||||
actions: ['setSpeed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
setSpeed: (ctx, event) => {
|
||||
if ('payload' in event) {
|
||||
ctx.timer.setSpeed(event.payload.speed);
|
||||
}
|
||||
},
|
||||
recordSpeed: assign({
|
||||
normalSpeed: (ctx) => ctx.timer.speed,
|
||||
}),
|
||||
restoreSpeed: (ctx) => {
|
||||
ctx.timer.setSpeed(ctx.normalSpeed);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return interpret(speedMachine);
|
||||
}
|
||||
|
||||
export type PlayerMachineState = StateMachine.State<
|
||||
PlayerContext,
|
||||
PlayerEvent,
|
||||
PlayerState
|
||||
>;
|
||||
|
||||
export type SpeedMachineState = StateMachine.State<
|
||||
SpeedContext,
|
||||
SpeedEvent,
|
||||
SpeedState
|
||||
>;
|
||||
429
packages/rrweb/src/replay/smoothscroll.ts
Normal file
429
packages/rrweb/src/replay/smoothscroll.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* A fork version of https://github.com/iamdustan/smoothscroll
|
||||
* Add support of customize target window and document
|
||||
*/
|
||||
|
||||
// @ts-nocheck
|
||||
// tslint:disable
|
||||
export function polyfill(w: Window = window, d = document) {
|
||||
// return if scroll behavior is supported and polyfill is not forced
|
||||
if (
|
||||
'scrollBehavior' in d.documentElement.style &&
|
||||
w.__forceSmoothScrollPolyfill__ !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// globals
|
||||
var Element = w.HTMLElement || w.Element;
|
||||
var SCROLL_TIME = 468;
|
||||
|
||||
// object gathering original scroll methods
|
||||
var original = {
|
||||
scroll: w.scroll || w.scrollTo,
|
||||
scrollBy: w.scrollBy,
|
||||
elementScroll: Element.prototype.scroll || scrollElement,
|
||||
scrollIntoView: Element.prototype.scrollIntoView,
|
||||
};
|
||||
|
||||
// define timing method
|
||||
var now =
|
||||
w.performance && w.performance.now
|
||||
? w.performance.now.bind(w.performance)
|
||||
: Date.now;
|
||||
|
||||
/**
|
||||
* indicates if a the current browser is made by Microsoft
|
||||
* @method isMicrosoftBrowser
|
||||
* @param {String} userAgent
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isMicrosoftBrowser(userAgent) {
|
||||
var userAgentPatterns = ['MSIE ', 'Trident/', 'Edge/'];
|
||||
|
||||
return new RegExp(userAgentPatterns.join('|')).test(userAgent);
|
||||
}
|
||||
|
||||
/*
|
||||
* IE has rounding bug rounding down clientHeight and clientWidth and
|
||||
* rounding up scrollHeight and scrollWidth causing false positives
|
||||
* on hasScrollableSpace
|
||||
*/
|
||||
var ROUNDING_TOLERANCE = isMicrosoftBrowser(w.navigator.userAgent) ? 1 : 0;
|
||||
|
||||
/**
|
||||
* changes scroll position inside an element
|
||||
* @method scrollElement
|
||||
* @param {Number} x
|
||||
* @param {Number} y
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function scrollElement(x, y) {
|
||||
this.scrollLeft = x;
|
||||
this.scrollTop = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns result of applying ease math function to a number
|
||||
* @method ease
|
||||
* @param {Number} k
|
||||
* @returns {Number}
|
||||
*/
|
||||
function ease(k) {
|
||||
return 0.5 * (1 - Math.cos(Math.PI * k));
|
||||
}
|
||||
|
||||
/**
|
||||
* indicates if a smooth behavior should be applied
|
||||
* @method shouldBailOut
|
||||
* @param {Number|Object} firstArg
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function shouldBailOut(firstArg) {
|
||||
if (
|
||||
firstArg === null ||
|
||||
typeof firstArg !== 'object' ||
|
||||
firstArg.behavior === undefined ||
|
||||
firstArg.behavior === 'auto' ||
|
||||
firstArg.behavior === 'instant'
|
||||
) {
|
||||
// first argument is not an object/null
|
||||
// or behavior is auto, instant or undefined
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof firstArg === 'object' && firstArg.behavior === 'smooth') {
|
||||
// first argument is an object and behavior is smooth
|
||||
return false;
|
||||
}
|
||||
|
||||
// throw error when behavior is not supported
|
||||
throw new TypeError(
|
||||
'behavior member of ScrollOptions ' +
|
||||
firstArg.behavior +
|
||||
' is not a valid value for enumeration ScrollBehavior.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* indicates if an element has scrollable space in the provided axis
|
||||
* @method hasScrollableSpace
|
||||
* @param {Node} el
|
||||
* @param {String} axis
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function hasScrollableSpace(el, axis) {
|
||||
if (axis === 'Y') {
|
||||
return el.clientHeight + ROUNDING_TOLERANCE < el.scrollHeight;
|
||||
}
|
||||
|
||||
if (axis === 'X') {
|
||||
return el.clientWidth + ROUNDING_TOLERANCE < el.scrollWidth;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* indicates if an element has a scrollable overflow property in the axis
|
||||
* @method canOverflow
|
||||
* @param {Node} el
|
||||
* @param {String} axis
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function canOverflow(el, axis) {
|
||||
var overflowValue = w.getComputedStyle(el, null)['overflow' + axis];
|
||||
|
||||
return overflowValue === 'auto' || overflowValue === 'scroll';
|
||||
}
|
||||
|
||||
/**
|
||||
* indicates if an element can be scrolled in either axis
|
||||
* @method isScrollable
|
||||
* @param {Node} el
|
||||
* @param {String} axis
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isScrollable(el) {
|
||||
var isScrollableY = hasScrollableSpace(el, 'Y') && canOverflow(el, 'Y');
|
||||
var isScrollableX = hasScrollableSpace(el, 'X') && canOverflow(el, 'X');
|
||||
|
||||
return isScrollableY || isScrollableX;
|
||||
}
|
||||
|
||||
/**
|
||||
* finds scrollable parent of an element
|
||||
* @method findScrollableParent
|
||||
* @param {Node} el
|
||||
* @returns {Node} el
|
||||
*/
|
||||
function findScrollableParent(el) {
|
||||
while (el !== d.body && isScrollable(el) === false) {
|
||||
el = el.parentNode || el.host;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* self invoked function that, given a context, steps through scrolling
|
||||
* @method step
|
||||
* @param {Object} context
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function step(context) {
|
||||
var time = now();
|
||||
var value;
|
||||
var currentX;
|
||||
var currentY;
|
||||
var elapsed = (time - context.startTime) / SCROLL_TIME;
|
||||
|
||||
// avoid elapsed times higher than one
|
||||
elapsed = elapsed > 1 ? 1 : elapsed;
|
||||
|
||||
// apply easing to elapsed time
|
||||
value = ease(elapsed);
|
||||
|
||||
currentX = context.startX + (context.x - context.startX) * value;
|
||||
currentY = context.startY + (context.y - context.startY) * value;
|
||||
|
||||
context.method.call(context.scrollable, currentX, currentY);
|
||||
|
||||
// scroll more if we have not reached our destination
|
||||
if (currentX !== context.x || currentY !== context.y) {
|
||||
w.requestAnimationFrame(step.bind(w, context));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* scrolls window or element with a smooth behavior
|
||||
* @method smoothScroll
|
||||
* @param {Object|Node} el
|
||||
* @param {Number} x
|
||||
* @param {Number} y
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function smoothScroll(el, x, y) {
|
||||
var scrollable;
|
||||
var startX;
|
||||
var startY;
|
||||
var method;
|
||||
var startTime = now();
|
||||
|
||||
// define scroll context
|
||||
if (el === d.body) {
|
||||
scrollable = w;
|
||||
startX = w.scrollX || w.pageXOffset;
|
||||
startY = w.scrollY || w.pageYOffset;
|
||||
method = original.scroll;
|
||||
} else {
|
||||
scrollable = el;
|
||||
startX = el.scrollLeft;
|
||||
startY = el.scrollTop;
|
||||
method = scrollElement;
|
||||
}
|
||||
|
||||
// scroll looping over a frame
|
||||
step({
|
||||
scrollable: scrollable,
|
||||
method: method,
|
||||
startTime: startTime,
|
||||
startX: startX,
|
||||
startY: startY,
|
||||
x: x,
|
||||
y: y,
|
||||
});
|
||||
}
|
||||
|
||||
// ORIGINAL METHODS OVERRIDES
|
||||
// w.scroll and w.scrollTo
|
||||
w.scroll = w.scrollTo = function () {
|
||||
// avoid action when no arguments are passed
|
||||
if (arguments[0] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// avoid smooth behavior if not required
|
||||
if (shouldBailOut(arguments[0]) === true) {
|
||||
original.scroll.call(
|
||||
w,
|
||||
arguments[0].left !== undefined
|
||||
? arguments[0].left
|
||||
: typeof arguments[0] !== 'object'
|
||||
? arguments[0]
|
||||
: w.scrollX || w.pageXOffset,
|
||||
// use top prop, second argument if present or fallback to scrollY
|
||||
arguments[0].top !== undefined
|
||||
? arguments[0].top
|
||||
: arguments[1] !== undefined
|
||||
? arguments[1]
|
||||
: w.scrollY || w.pageYOffset,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// LET THE SMOOTHNESS BEGIN!
|
||||
smoothScroll.call(
|
||||
w,
|
||||
d.body,
|
||||
arguments[0].left !== undefined
|
||||
? ~~arguments[0].left
|
||||
: w.scrollX || w.pageXOffset,
|
||||
arguments[0].top !== undefined
|
||||
? ~~arguments[0].top
|
||||
: w.scrollY || w.pageYOffset,
|
||||
);
|
||||
};
|
||||
|
||||
// w.scrollBy
|
||||
w.scrollBy = function () {
|
||||
// avoid action when no arguments are passed
|
||||
if (arguments[0] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// avoid smooth behavior if not required
|
||||
if (shouldBailOut(arguments[0])) {
|
||||
original.scrollBy.call(
|
||||
w,
|
||||
arguments[0].left !== undefined
|
||||
? arguments[0].left
|
||||
: typeof arguments[0] !== 'object'
|
||||
? arguments[0]
|
||||
: 0,
|
||||
arguments[0].top !== undefined
|
||||
? arguments[0].top
|
||||
: arguments[1] !== undefined
|
||||
? arguments[1]
|
||||
: 0,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// LET THE SMOOTHNESS BEGIN!
|
||||
smoothScroll.call(
|
||||
w,
|
||||
d.body,
|
||||
~~arguments[0].left + (w.scrollX || w.pageXOffset),
|
||||
~~arguments[0].top + (w.scrollY || w.pageYOffset),
|
||||
);
|
||||
};
|
||||
|
||||
// Element.prototype.scroll and Element.prototype.scrollTo
|
||||
Element.prototype.scroll = Element.prototype.scrollTo = function () {
|
||||
// avoid action when no arguments are passed
|
||||
if (arguments[0] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// avoid smooth behavior if not required
|
||||
if (shouldBailOut(arguments[0]) === true) {
|
||||
// if one number is passed, throw error to match Firefox implementation
|
||||
if (typeof arguments[0] === 'number' && arguments[1] === undefined) {
|
||||
throw new SyntaxError('Value could not be converted');
|
||||
}
|
||||
|
||||
original.elementScroll.call(
|
||||
this,
|
||||
// use left prop, first number argument or fallback to scrollLeft
|
||||
arguments[0].left !== undefined
|
||||
? ~~arguments[0].left
|
||||
: typeof arguments[0] !== 'object'
|
||||
? ~~arguments[0]
|
||||
: this.scrollLeft,
|
||||
// use top prop, second argument or fallback to scrollTop
|
||||
arguments[0].top !== undefined
|
||||
? ~~arguments[0].top
|
||||
: arguments[1] !== undefined
|
||||
? ~~arguments[1]
|
||||
: this.scrollTop,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var left = arguments[0].left;
|
||||
var top = arguments[0].top;
|
||||
|
||||
// LET THE SMOOTHNESS BEGIN!
|
||||
smoothScroll.call(
|
||||
this,
|
||||
this,
|
||||
typeof left === 'undefined' ? this.scrollLeft : ~~left,
|
||||
typeof top === 'undefined' ? this.scrollTop : ~~top,
|
||||
);
|
||||
};
|
||||
|
||||
// Element.prototype.scrollBy
|
||||
Element.prototype.scrollBy = function () {
|
||||
// avoid action when no arguments are passed
|
||||
if (arguments[0] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// avoid smooth behavior if not required
|
||||
if (shouldBailOut(arguments[0]) === true) {
|
||||
original.elementScroll.call(
|
||||
this,
|
||||
arguments[0].left !== undefined
|
||||
? ~~arguments[0].left + this.scrollLeft
|
||||
: ~~arguments[0] + this.scrollLeft,
|
||||
arguments[0].top !== undefined
|
||||
? ~~arguments[0].top + this.scrollTop
|
||||
: ~~arguments[1] + this.scrollTop,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.scroll({
|
||||
left: ~~arguments[0].left + this.scrollLeft,
|
||||
top: ~~arguments[0].top + this.scrollTop,
|
||||
behavior: arguments[0].behavior,
|
||||
});
|
||||
};
|
||||
|
||||
// Element.prototype.scrollIntoView
|
||||
Element.prototype.scrollIntoView = function () {
|
||||
// avoid smooth behavior if not required
|
||||
if (shouldBailOut(arguments[0]) === true) {
|
||||
original.scrollIntoView.call(
|
||||
this,
|
||||
arguments[0] === undefined ? true : arguments[0],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// LET THE SMOOTHNESS BEGIN!
|
||||
var scrollableParent = findScrollableParent(this);
|
||||
var parentRects = scrollableParent.getBoundingClientRect();
|
||||
var clientRects = this.getBoundingClientRect();
|
||||
|
||||
if (scrollableParent !== d.body) {
|
||||
// reveal element inside parent
|
||||
smoothScroll.call(
|
||||
this,
|
||||
scrollableParent,
|
||||
scrollableParent.scrollLeft + clientRects.left - parentRects.left,
|
||||
scrollableParent.scrollTop + clientRects.top - parentRects.top,
|
||||
);
|
||||
|
||||
// reveal parent in viewport unless is fixed
|
||||
if (w.getComputedStyle(scrollableParent).position !== 'fixed') {
|
||||
w.scrollBy({
|
||||
left: parentRects.left,
|
||||
top: parentRects.top,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// reveal element in viewport
|
||||
w.scrollBy({
|
||||
left: clientRects.left,
|
||||
top: clientRects.top,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
6
packages/rrweb/src/replay/styles/inject-style.ts
Normal file
6
packages/rrweb/src/replay/styles/inject-style.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const rules: (blockClass: string) => string[] = (blockClass: string) => [
|
||||
`.${blockClass} { background: #ccc }`,
|
||||
'noscript { display: none !important; }',
|
||||
];
|
||||
|
||||
export default rules;
|
||||
47
packages/rrweb/src/replay/styles/style.css
Normal file
47
packages/rrweb/src/replay/styles/style.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.replayer-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.replayer-mouse {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: 0.05s linear;
|
||||
background-size: contain;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg==');
|
||||
}
|
||||
.replayer-mouse::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: rgb(73, 80, 246);
|
||||
transform: translate(-10px, -10px);
|
||||
opacity: 0.3;
|
||||
}
|
||||
.replayer-mouse.active::after {
|
||||
animation: click 0.2s ease-in-out 1;
|
||||
}
|
||||
.replayer-mouse-tail {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes click {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
transform: translate(-10px, -10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
transform: translate(-5px, -5px);
|
||||
}
|
||||
}
|
||||
114
packages/rrweb/src/replay/timer.ts
Normal file
114
packages/rrweb/src/replay/timer.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
actionWithDelay,
|
||||
eventWithTime,
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
} from '../types';
|
||||
|
||||
export class Timer {
|
||||
public timeOffset: number = 0;
|
||||
public speed: number;
|
||||
|
||||
private actions: actionWithDelay[];
|
||||
private raf: number | null = null;
|
||||
private liveMode: boolean;
|
||||
|
||||
constructor(actions: actionWithDelay[] = [], speed: number) {
|
||||
this.actions = actions;
|
||||
this.speed = speed;
|
||||
}
|
||||
/**
|
||||
* Add an action after the timer starts.
|
||||
* @param action
|
||||
*/
|
||||
public addAction(action: actionWithDelay) {
|
||||
const index = this.findActionIndex(action);
|
||||
this.actions.splice(index, 0, action);
|
||||
}
|
||||
/**
|
||||
* Add all actions before the timer starts
|
||||
* @param actions
|
||||
*/
|
||||
public addActions(actions: actionWithDelay[]) {
|
||||
this.actions = this.actions.concat(actions);
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.timeOffset = 0;
|
||||
let lastTimestamp = performance.now();
|
||||
const { actions } = this;
|
||||
const self = this;
|
||||
function check() {
|
||||
const time = performance.now();
|
||||
self.timeOffset += (time - lastTimestamp) * self.speed;
|
||||
lastTimestamp = time;
|
||||
while (actions.length) {
|
||||
const action = actions[0];
|
||||
if (self.timeOffset >= action.delay) {
|
||||
actions.shift();
|
||||
action.doAction();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actions.length > 0 || self.liveMode) {
|
||||
self.raf = requestAnimationFrame(check);
|
||||
}
|
||||
}
|
||||
this.raf = requestAnimationFrame(check);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.raf) {
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.raf = null;
|
||||
}
|
||||
this.actions.length = 0;
|
||||
}
|
||||
|
||||
public setSpeed(speed: number) {
|
||||
this.speed = speed;
|
||||
}
|
||||
|
||||
public toggleLiveMode(mode: boolean) {
|
||||
this.liveMode = mode;
|
||||
}
|
||||
|
||||
public isActive() {
|
||||
return this.raf !== null;
|
||||
}
|
||||
|
||||
private findActionIndex(action: actionWithDelay): number {
|
||||
let start = 0;
|
||||
let end = this.actions.length - 1;
|
||||
while (start <= end) {
|
||||
let mid = Math.floor((start + end) / 2);
|
||||
if (this.actions[mid].delay < action.delay) {
|
||||
start = mid + 1;
|
||||
} else if (this.actions[mid].delay > action.delay) {
|
||||
end = mid - 1;
|
||||
} else {
|
||||
return mid;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add speed to mouse move timestamp calculation
|
||||
export function addDelay(event: eventWithTime, baselineTime: number): number {
|
||||
// Mouse move events was recorded in a throttle function,
|
||||
// so we need to find the real timestamp by traverse the time offsets.
|
||||
if (
|
||||
event.type === EventType.IncrementalSnapshot &&
|
||||
event.data.source === IncrementalSource.MouseMove
|
||||
) {
|
||||
const firstOffset = event.data.positions[0].timeOffset;
|
||||
// timeOffset is a negative offset to event.timestamp
|
||||
const firstTimestamp = event.timestamp + firstOffset;
|
||||
event.delay = firstTimestamp - baselineTime;
|
||||
return firstTimestamp - baselineTime;
|
||||
}
|
||||
event.delay = event.timestamp - baselineTime;
|
||||
return event.delay;
|
||||
}
|
||||
119
packages/rrweb/src/replay/virtual-styles.ts
Normal file
119
packages/rrweb/src/replay/virtual-styles.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { INode } from 'rrweb-snapshot';
|
||||
|
||||
export enum StyleRuleType {
|
||||
Insert,
|
||||
Remove,
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
type InsertRule = {
|
||||
cssText: string;
|
||||
type: StyleRuleType.Insert;
|
||||
index?: number;
|
||||
};
|
||||
type RemoveRule = {
|
||||
type: StyleRuleType.Remove;
|
||||
index: number;
|
||||
};
|
||||
type SnapshotRule = {
|
||||
type: StyleRuleType.Snapshot;
|
||||
cssTexts: string[];
|
||||
};
|
||||
|
||||
export type VirtualStyleRules = Array<InsertRule | RemoveRule | SnapshotRule>;
|
||||
export type VirtualStyleRulesMap = Map<INode, VirtualStyleRules>;
|
||||
|
||||
export function applyVirtualStyleRulesToNode(
|
||||
storedRules: VirtualStyleRules,
|
||||
styleNode: HTMLStyleElement,
|
||||
) {
|
||||
storedRules.forEach((rule) => {
|
||||
if (rule.type === StyleRuleType.Insert) {
|
||||
try {
|
||||
styleNode.sheet?.insertRule(rule.cssText, rule.index);
|
||||
} catch (e) {
|
||||
/**
|
||||
* sometimes we may capture rules with browser prefix
|
||||
* insert rule with prefixs in other browsers may cause Error
|
||||
*/
|
||||
}
|
||||
} else if (rule.type === StyleRuleType.Remove) {
|
||||
try {
|
||||
styleNode.sheet?.deleteRule(rule.index);
|
||||
} catch (e) {
|
||||
/**
|
||||
* accessing styleSheet rules may cause SecurityError
|
||||
* for specific access control settings
|
||||
*/
|
||||
}
|
||||
} else if (rule.type === StyleRuleType.Snapshot) {
|
||||
restoreSnapshotOfStyleRulesToNode(rule.cssTexts, styleNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function restoreSnapshotOfStyleRulesToNode(
|
||||
cssTexts: string[],
|
||||
styleNode: HTMLStyleElement,
|
||||
) {
|
||||
try {
|
||||
const existingRules = Array.from(styleNode.sheet?.cssRules || []).map(
|
||||
(rule) => rule.cssText,
|
||||
);
|
||||
const existingRulesReversed = Object.entries(existingRules).reverse();
|
||||
let lastMatch = existingRules.length;
|
||||
existingRulesReversed.forEach(([index, rule]) => {
|
||||
const indexOf = cssTexts.indexOf(rule);
|
||||
if (indexOf === -1 || indexOf > lastMatch) {
|
||||
try {
|
||||
styleNode.sheet?.deleteRule(Number(index));
|
||||
} catch (e) {
|
||||
/**
|
||||
* accessing styleSheet rules may cause SecurityError
|
||||
* for specific access control settings
|
||||
*/
|
||||
}
|
||||
}
|
||||
lastMatch = indexOf;
|
||||
});
|
||||
cssTexts.forEach((cssText, index) => {
|
||||
try {
|
||||
if (styleNode.sheet?.cssRules[index]?.cssText !== cssText) {
|
||||
styleNode.sheet?.insertRule(cssText, index);
|
||||
}
|
||||
} catch (e) {
|
||||
/**
|
||||
* sometimes we may capture rules with browser prefix
|
||||
* insert rule with prefixs in other browsers may cause Error
|
||||
*/
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
/**
|
||||
* accessing styleSheet rules may cause SecurityError
|
||||
* for specific access control settings
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
export function storeCSSRules(
|
||||
parentElement: HTMLStyleElement,
|
||||
virtualStyleRulesMap: VirtualStyleRulesMap,
|
||||
) {
|
||||
try {
|
||||
const cssTexts = Array.from(
|
||||
(parentElement as HTMLStyleElement).sheet?.cssRules || [],
|
||||
).map((rule) => rule.cssText);
|
||||
virtualStyleRulesMap.set((parentElement as unknown) as INode, [
|
||||
{
|
||||
type: StyleRuleType.Snapshot,
|
||||
cssTexts,
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
/**
|
||||
* accessing styleSheet rules may cause SecurityError
|
||||
* for specific access control settings
|
||||
*/
|
||||
}
|
||||
}
|
||||
173
packages/rrweb/src/rrdom/index.ts
Normal file
173
packages/rrweb/src/rrdom/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { RRdomTreeNode, AnyObject } from './tree-node';
|
||||
|
||||
class RRdomTree {
|
||||
private readonly symbol = '__rrdom__';
|
||||
|
||||
public initialize(object: AnyObject) {
|
||||
this._node(object);
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
public hasChildren(object: AnyObject): boolean {
|
||||
return Boolean(this._node(object).hasChildren);
|
||||
}
|
||||
|
||||
public firstChild(object: AnyObject) {
|
||||
return this._node(object).firstChild || null;
|
||||
}
|
||||
|
||||
public lastChild(object: AnyObject) {
|
||||
return this._node(object).lastChild || null;
|
||||
}
|
||||
|
||||
public previousSibling(object: AnyObject) {
|
||||
return this._node(object).previousSibling || null;
|
||||
}
|
||||
|
||||
public nextSibling(object: AnyObject) {
|
||||
return this._node(object).nextSibling || null;
|
||||
}
|
||||
|
||||
public parent(object: AnyObject) {
|
||||
return this._node(object).parent || null;
|
||||
}
|
||||
|
||||
public insertAfter(referenceObject: AnyObject, newObject: AnyObject) {
|
||||
const referenceNode = this._node(referenceObject);
|
||||
const nextNode = this._node(referenceNode.nextSibling);
|
||||
const newNode = this._node(newObject);
|
||||
const parentNode = this._node(referenceNode.parent);
|
||||
|
||||
if (newNode.isAttached) {
|
||||
throw new Error('Node already attached');
|
||||
}
|
||||
if (!referenceNode) {
|
||||
throw new Error('Reference node not attached');
|
||||
}
|
||||
|
||||
newNode.parent = referenceNode.parent;
|
||||
newNode.previousSibling = referenceObject;
|
||||
newNode.nextSibling = referenceNode.nextSibling;
|
||||
referenceNode.nextSibling = newObject;
|
||||
|
||||
if (nextNode) {
|
||||
nextNode.previousSibling = newObject;
|
||||
}
|
||||
|
||||
if (parentNode && parentNode.lastChild === referenceObject) {
|
||||
parentNode.lastChild = newObject;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.childrenChanged();
|
||||
}
|
||||
|
||||
return newObject;
|
||||
}
|
||||
|
||||
public insertBefore(referenceObject: AnyObject, newObject: AnyObject) {
|
||||
const referenceNode = this._node(referenceObject);
|
||||
const prevNode = this._node(referenceNode.previousSibling);
|
||||
const newNode = this._node(newObject);
|
||||
const parentNode = this._node(referenceNode.parent);
|
||||
|
||||
if (newNode.isAttached) {
|
||||
throw new Error('Node already attached');
|
||||
}
|
||||
if (!referenceNode) {
|
||||
throw new Error('Reference node not attached');
|
||||
}
|
||||
|
||||
newNode.parent = referenceNode.parent;
|
||||
newNode.previousSibling = referenceNode.previousSibling;
|
||||
newNode.nextSibling = referenceObject;
|
||||
referenceNode.previousSibling = newObject;
|
||||
|
||||
if (prevNode) {
|
||||
prevNode.nextSibling = newObject;
|
||||
}
|
||||
|
||||
if (parentNode && parentNode.firstChild === referenceObject) {
|
||||
parentNode.firstChild = newObject;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.childrenChanged();
|
||||
}
|
||||
|
||||
return newObject;
|
||||
}
|
||||
|
||||
public appendChild(referenceObject: AnyObject, newObject: AnyObject) {
|
||||
const referenceNode = this._node(referenceObject);
|
||||
const newNode = this._node(newObject);
|
||||
|
||||
if (newNode.isAttached) {
|
||||
throw new Error('Node already attached');
|
||||
}
|
||||
if (!referenceNode) {
|
||||
throw new Error('Reference node not attached');
|
||||
}
|
||||
|
||||
if (referenceNode.hasChildren) {
|
||||
this.insertAfter(referenceNode.lastChild!, newObject);
|
||||
} else {
|
||||
newNode.parent = referenceObject;
|
||||
referenceNode.firstChild = newObject;
|
||||
referenceNode.lastChild = newObject;
|
||||
referenceNode.childrenChanged();
|
||||
}
|
||||
|
||||
return newObject;
|
||||
}
|
||||
|
||||
public remove(removeObject: AnyObject) {
|
||||
const removeNode = this._node(removeObject);
|
||||
const parentNode = this._node(removeNode.parent);
|
||||
const prevNode = this._node(removeNode.previousSibling);
|
||||
const nextNode = this._node(removeNode.nextSibling);
|
||||
|
||||
if (parentNode) {
|
||||
if (parentNode.firstChild === removeObject) {
|
||||
parentNode.firstChild = removeNode.nextSibling;
|
||||
}
|
||||
|
||||
if (parentNode.lastChild === removeObject) {
|
||||
parentNode.lastChild = removeNode.previousSibling;
|
||||
}
|
||||
}
|
||||
|
||||
if (prevNode) {
|
||||
prevNode.nextSibling = removeNode.nextSibling;
|
||||
}
|
||||
|
||||
if (nextNode) {
|
||||
nextNode.previousSibling = removeNode.previousSibling;
|
||||
}
|
||||
|
||||
removeNode.parent = null;
|
||||
removeNode.previousSibling = null;
|
||||
removeNode.nextSibling = null;
|
||||
removeNode.cachedIndex = -1;
|
||||
removeNode.cachedIndexVersion = NaN;
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.childrenChanged();
|
||||
}
|
||||
|
||||
return removeObject;
|
||||
}
|
||||
|
||||
private _node(object: AnyObject | null): RRdomTreeNode {
|
||||
if (!object) {
|
||||
throw new Error('Object is falsy');
|
||||
}
|
||||
|
||||
if (this.symbol in object) {
|
||||
return object[this.symbol] as RRdomTreeNode;
|
||||
}
|
||||
|
||||
return (object[this.symbol] = new RRdomTreeNode());
|
||||
}
|
||||
}
|
||||
52
packages/rrweb/src/rrdom/tree-node.ts
Normal file
52
packages/rrweb/src/rrdom/tree-node.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// tslint:disable-next-line: no-any
|
||||
export type AnyObject = { [key: string]: any; __rrdom__?: RRdomTreeNode };
|
||||
|
||||
export class RRdomTreeNode implements AnyObject {
|
||||
public parent: AnyObject | null = null;
|
||||
public previousSibling: AnyObject | null = null;
|
||||
public nextSibling: AnyObject | null = null;
|
||||
|
||||
public firstChild: AnyObject | null = null;
|
||||
public lastChild: AnyObject | null = null;
|
||||
|
||||
// This value is incremented anytime a children is added or removed
|
||||
public childrenVersion = 0;
|
||||
// The last child object which has a cached index
|
||||
public childIndexCachedUpTo: AnyObject | null = null;
|
||||
|
||||
/**
|
||||
* This value represents the cached node index, as long as
|
||||
* cachedIndexVersion matches with the childrenVersion of the parent
|
||||
*/
|
||||
public cachedIndex = -1;
|
||||
public cachedIndexVersion = NaN;
|
||||
|
||||
public get isAttached() {
|
||||
return Boolean(this.parent || this.previousSibling || this.nextSibling);
|
||||
}
|
||||
|
||||
public get hasChildren() {
|
||||
return Boolean(this.firstChild);
|
||||
}
|
||||
|
||||
public childrenChanged() {
|
||||
// tslint:disable-next-line: no-bitwise
|
||||
this.childrenVersion = (this.childrenVersion + 1) & 0xffffffff;
|
||||
this.childIndexCachedUpTo = null;
|
||||
}
|
||||
|
||||
public getCachedIndex(parentNode: AnyObject) {
|
||||
if (this.cachedIndexVersion !== parentNode.childrenVersion) {
|
||||
this.cachedIndexVersion = NaN;
|
||||
// cachedIndex is no longer valid
|
||||
return -1;
|
||||
}
|
||||
|
||||
return this.cachedIndex;
|
||||
}
|
||||
|
||||
public setCachedIndex(parentNode: AnyObject, index: number) {
|
||||
this.cachedIndexVersion = parentNode.childrenVersion;
|
||||
this.cachedIndex = index;
|
||||
}
|
||||
}
|
||||
569
packages/rrweb/src/types.ts
Normal file
569
packages/rrweb/src/types.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
serializedNodeWithId,
|
||||
idNodeMap,
|
||||
INode,
|
||||
MaskInputOptions,
|
||||
SlimDOMOptions,
|
||||
MaskInputFn,
|
||||
MaskTextFn,
|
||||
} from 'rrweb-snapshot';
|
||||
import { PackFn, UnpackFn } from './packer/base';
|
||||
import { FontFaceDescriptors } from 'css-font-loading-module';
|
||||
import { IframeManager } from './record/iframe-manager';
|
||||
import { ShadowDomManager } from './record/shadow-dom-manager';
|
||||
import type { Replayer } from './replay';
|
||||
|
||||
export enum EventType {
|
||||
DomContentLoaded,
|
||||
Load,
|
||||
FullSnapshot,
|
||||
IncrementalSnapshot,
|
||||
Meta,
|
||||
Custom,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
export type domContentLoadedEvent = {
|
||||
type: EventType.DomContentLoaded;
|
||||
data: {};
|
||||
};
|
||||
|
||||
export type loadedEvent = {
|
||||
type: EventType.Load;
|
||||
data: {};
|
||||
};
|
||||
|
||||
export type fullSnapshotEvent = {
|
||||
type: EventType.FullSnapshot;
|
||||
data: {
|
||||
node: serializedNodeWithId;
|
||||
initialOffset: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type incrementalSnapshotEvent = {
|
||||
type: EventType.IncrementalSnapshot;
|
||||
data: incrementalData;
|
||||
};
|
||||
|
||||
export type metaEvent = {
|
||||
type: EventType.Meta;
|
||||
data: {
|
||||
href: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type customEvent<T = unknown> = {
|
||||
type: EventType.Custom;
|
||||
data: {
|
||||
tag: string;
|
||||
payload: T;
|
||||
};
|
||||
};
|
||||
|
||||
export type pluginEvent<T = unknown> = {
|
||||
type: EventType.Plugin;
|
||||
data: {
|
||||
plugin: string;
|
||||
payload: T;
|
||||
};
|
||||
};
|
||||
|
||||
export type styleSheetEvent = {};
|
||||
|
||||
export enum IncrementalSource {
|
||||
Mutation,
|
||||
MouseMove,
|
||||
MouseInteraction,
|
||||
Scroll,
|
||||
ViewportResize,
|
||||
Input,
|
||||
TouchMove,
|
||||
MediaInteraction,
|
||||
StyleSheetRule,
|
||||
CanvasMutation,
|
||||
Font,
|
||||
Log,
|
||||
Drag,
|
||||
}
|
||||
|
||||
export type mutationData = {
|
||||
source: IncrementalSource.Mutation;
|
||||
} & mutationCallbackParam;
|
||||
|
||||
export type mousemoveData = {
|
||||
source:
|
||||
| IncrementalSource.MouseMove
|
||||
| IncrementalSource.TouchMove
|
||||
| IncrementalSource.Drag;
|
||||
positions: mousePosition[];
|
||||
};
|
||||
|
||||
export type mouseInteractionData = {
|
||||
source: IncrementalSource.MouseInteraction;
|
||||
} & mouseInteractionParam;
|
||||
|
||||
export type scrollData = {
|
||||
source: IncrementalSource.Scroll;
|
||||
} & scrollPosition;
|
||||
|
||||
export type viewportResizeData = {
|
||||
source: IncrementalSource.ViewportResize;
|
||||
} & viewportResizeDimension;
|
||||
|
||||
export type inputData = {
|
||||
source: IncrementalSource.Input;
|
||||
id: number;
|
||||
} & inputValue;
|
||||
|
||||
export type mediaInteractionData = {
|
||||
source: IncrementalSource.MediaInteraction;
|
||||
} & mediaInteractionParam;
|
||||
|
||||
export type styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule;
|
||||
} & styleSheetRuleParam;
|
||||
|
||||
export type canvasMutationData = {
|
||||
source: IncrementalSource.CanvasMutation;
|
||||
} & canvasMutationParam;
|
||||
|
||||
export type fontData = {
|
||||
source: IncrementalSource.Font;
|
||||
} & fontParam;
|
||||
|
||||
export type incrementalData =
|
||||
| mutationData
|
||||
| mousemoveData
|
||||
| mouseInteractionData
|
||||
| scrollData
|
||||
| viewportResizeData
|
||||
| inputData
|
||||
| mediaInteractionData
|
||||
| styleSheetRuleData
|
||||
| canvasMutationData
|
||||
| fontData;
|
||||
|
||||
export type event =
|
||||
| domContentLoadedEvent
|
||||
| loadedEvent
|
||||
| fullSnapshotEvent
|
||||
| incrementalSnapshotEvent
|
||||
| metaEvent
|
||||
| customEvent
|
||||
| pluginEvent;
|
||||
|
||||
export type eventWithTime = event & {
|
||||
timestamp: number;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export type blockClass = string | RegExp;
|
||||
|
||||
export type maskTextClass = string | RegExp;
|
||||
|
||||
export type SamplingStrategy = Partial<{
|
||||
/**
|
||||
* false means not to record mouse/touch move events
|
||||
* number is the throttle threshold of recording mouse/touch move
|
||||
*/
|
||||
mousemove: boolean | number;
|
||||
/**
|
||||
* number is the throttle threshold of mouse/touch move callback
|
||||
*/
|
||||
mousemoveCallback: number;
|
||||
/**
|
||||
* false means not to record mouse interaction events
|
||||
* can also specify record some kinds of mouse interactions
|
||||
*/
|
||||
mouseInteraction: boolean | Record<string, boolean | undefined>;
|
||||
/**
|
||||
* number is the throttle threshold of recording scroll
|
||||
*/
|
||||
scroll: number;
|
||||
/**
|
||||
* 'all' will record all the input events
|
||||
* 'last' will only record the last input value while input a sequence of chars
|
||||
*/
|
||||
input: 'all' | 'last';
|
||||
}>;
|
||||
|
||||
export type RecordPlugin<TOptions = unknown> = {
|
||||
name: string;
|
||||
observer: (cb: Function, options: TOptions) => listenerHandler;
|
||||
options: TOptions;
|
||||
};
|
||||
|
||||
export type recordOptions<T> = {
|
||||
emit?: (e: T, isCheckout?: boolean) => void;
|
||||
checkoutEveryNth?: number;
|
||||
checkoutEveryNms?: number;
|
||||
blockClass?: blockClass;
|
||||
blockSelector?: string;
|
||||
ignoreClass?: string;
|
||||
maskTextClass?: maskTextClass;
|
||||
maskTextSelector?: string;
|
||||
maskAllInputs?: boolean;
|
||||
maskInputOptions?: MaskInputOptions;
|
||||
maskInputFn?: MaskInputFn;
|
||||
maskTextFn?: MaskTextFn;
|
||||
slimDOMOptions?: SlimDOMOptions | 'all' | true;
|
||||
inlineStylesheet?: boolean;
|
||||
hooks?: hooksParam;
|
||||
packFn?: PackFn;
|
||||
sampling?: SamplingStrategy;
|
||||
recordCanvas?: boolean;
|
||||
userTriggeredOnInput?: boolean;
|
||||
collectFonts?: boolean;
|
||||
plugins?: RecordPlugin[];
|
||||
// departed, please use sampling options
|
||||
mousemoveWait?: number;
|
||||
keepIframeSrcFn?: KeepIframeSrcFn;
|
||||
};
|
||||
|
||||
export type observerParam = {
|
||||
mutationCb: mutationCallBack;
|
||||
mousemoveCb: mousemoveCallBack;
|
||||
mouseInteractionCb: mouseInteractionCallBack;
|
||||
scrollCb: scrollCallback;
|
||||
viewportResizeCb: viewportResizeCallback;
|
||||
inputCb: inputCallback;
|
||||
mediaInteractionCb: mediaInteractionCallback;
|
||||
blockClass: blockClass;
|
||||
blockSelector: string | null;
|
||||
ignoreClass: string;
|
||||
maskTextClass: maskTextClass;
|
||||
maskTextSelector: string | null;
|
||||
maskInputOptions: MaskInputOptions;
|
||||
maskInputFn?: MaskInputFn;
|
||||
maskTextFn?: MaskTextFn;
|
||||
inlineStylesheet: boolean;
|
||||
styleSheetRuleCb: styleSheetRuleCallback;
|
||||
canvasMutationCb: canvasMutationCallback;
|
||||
fontCb: fontCallback;
|
||||
sampling: SamplingStrategy;
|
||||
recordCanvas: boolean;
|
||||
userTriggeredOnInput: boolean;
|
||||
collectFonts: boolean;
|
||||
slimDOMOptions: SlimDOMOptions;
|
||||
doc: Document;
|
||||
mirror: Mirror;
|
||||
iframeManager: IframeManager;
|
||||
shadowDomManager: ShadowDomManager;
|
||||
plugins: Array<{
|
||||
observer: Function;
|
||||
callback: Function;
|
||||
options: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type hooksParam = {
|
||||
mutation?: mutationCallBack;
|
||||
mousemove?: mousemoveCallBack;
|
||||
mouseInteraction?: mouseInteractionCallBack;
|
||||
scroll?: scrollCallback;
|
||||
viewportResize?: viewportResizeCallback;
|
||||
input?: inputCallback;
|
||||
mediaInteaction?: mediaInteractionCallback;
|
||||
styleSheetRule?: styleSheetRuleCallback;
|
||||
canvasMutation?: canvasMutationCallback;
|
||||
font?: fontCallback;
|
||||
};
|
||||
|
||||
// https://dom.spec.whatwg.org/#interface-mutationrecord
|
||||
export type mutationRecord = {
|
||||
type: string;
|
||||
target: Node;
|
||||
oldValue: string | null;
|
||||
addedNodes: NodeList;
|
||||
removedNodes: NodeList;
|
||||
attributeName: string | null;
|
||||
};
|
||||
|
||||
export type textCursor = {
|
||||
node: Node;
|
||||
value: string | null;
|
||||
};
|
||||
export type textMutation = {
|
||||
id: number;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export type styleAttributeValue = {
|
||||
[key:string]: styleValueWithPriority | string | false;
|
||||
};
|
||||
|
||||
export type styleValueWithPriority = [string, string];
|
||||
|
||||
export type attributeCursor = {
|
||||
node: Node;
|
||||
attributes: {
|
||||
[key: string]: string | styleAttributeValue | null;
|
||||
};
|
||||
};
|
||||
export type attributeMutation = {
|
||||
id: number;
|
||||
attributes: {
|
||||
[key: string]: string | styleAttributeValue | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type removedNodeMutation = {
|
||||
parentId: number;
|
||||
id: number;
|
||||
isShadow?: boolean;
|
||||
};
|
||||
|
||||
export type addedNodeMutation = {
|
||||
parentId: number;
|
||||
// Newly recorded mutations will not have previousId any more, just for compatibility
|
||||
previousId?: number | null;
|
||||
nextId: number | null;
|
||||
node: serializedNodeWithId;
|
||||
};
|
||||
|
||||
export type mutationCallbackParam = {
|
||||
texts: textMutation[];
|
||||
attributes: attributeMutation[];
|
||||
removes: removedNodeMutation[];
|
||||
adds: addedNodeMutation[];
|
||||
isAttachIframe?: true;
|
||||
};
|
||||
|
||||
export type mutationCallBack = (m: mutationCallbackParam) => void;
|
||||
|
||||
export type mousemoveCallBack = (
|
||||
p: mousePosition[],
|
||||
source:
|
||||
| IncrementalSource.MouseMove
|
||||
| IncrementalSource.TouchMove
|
||||
| IncrementalSource.Drag,
|
||||
) => void;
|
||||
|
||||
export type mousePosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
id: number;
|
||||
timeOffset: number;
|
||||
};
|
||||
|
||||
export enum MouseInteractions {
|
||||
MouseUp,
|
||||
MouseDown,
|
||||
Click,
|
||||
ContextMenu,
|
||||
DblClick,
|
||||
Focus,
|
||||
Blur,
|
||||
TouchStart,
|
||||
TouchMove_Departed, // we will start a separate observer for touch move event
|
||||
TouchEnd,
|
||||
}
|
||||
|
||||
type mouseInteractionParam = {
|
||||
type: MouseInteractions;
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type mouseInteractionCallBack = (d: mouseInteractionParam) => void;
|
||||
|
||||
export type scrollPosition = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
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 canvasMutationCallback = (p: canvasMutationParam) => void;
|
||||
|
||||
export type canvasMutationParam = {
|
||||
id: number;
|
||||
property: string;
|
||||
args: Array<unknown>;
|
||||
setter?: true;
|
||||
};
|
||||
|
||||
export type fontParam = {
|
||||
family: string;
|
||||
fontSource: string;
|
||||
buffer: boolean;
|
||||
descriptors?: FontFaceDescriptors;
|
||||
};
|
||||
|
||||
export type fontCallback = (p: fontParam) => void;
|
||||
|
||||
export type viewportResizeDimension = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type viewportResizeCallback = (d: viewportResizeDimension) => void;
|
||||
|
||||
export type inputValue = {
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
|
||||
// `userTriggered` indicates if this event was triggered directly by user (userTriggered: true)
|
||||
// or was triggered indirectly (userTriggered: false)
|
||||
// Example of `userTriggered` in action:
|
||||
// User clicks on radio element (userTriggered: true) which triggers the other radio element to change (userTriggered: false)
|
||||
userTriggered?: boolean;
|
||||
};
|
||||
|
||||
export type inputCallback = (v: inputValue & { id: number }) => void;
|
||||
|
||||
export const enum MediaInteractions {
|
||||
Play,
|
||||
Pause,
|
||||
Seeked,
|
||||
}
|
||||
|
||||
export type mediaInteractionParam = {
|
||||
type: MediaInteractions;
|
||||
id: number;
|
||||
currentTime?: number;
|
||||
};
|
||||
|
||||
export type mediaInteractionCallback = (p: mediaInteractionParam) => void;
|
||||
|
||||
export type DocumentDimension = {
|
||||
x: number;
|
||||
y: number;
|
||||
// scale value relative to its parent iframe
|
||||
relativeScale: number;
|
||||
// scale value relative to the root iframe
|
||||
absoluteScale: number;
|
||||
};
|
||||
|
||||
export type Mirror = {
|
||||
map: idNodeMap;
|
||||
getId: (n: INode) => number;
|
||||
getNode: (id: number) => INode | null;
|
||||
removeNodeFromMap: (n: INode) => void;
|
||||
has: (id: number) => boolean;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export type throttleOptions = {
|
||||
leading?: boolean;
|
||||
trailing?: boolean;
|
||||
};
|
||||
|
||||
export type listenerHandler = () => void;
|
||||
export type hookResetter = () => void;
|
||||
|
||||
export type ReplayPlugin = {
|
||||
handler: (
|
||||
event: eventWithTime,
|
||||
isSync: boolean,
|
||||
context: { replayer: Replayer },
|
||||
) => void;
|
||||
};
|
||||
export type playerConfig = {
|
||||
speed: number;
|
||||
maxSpeed: number;
|
||||
root: Element;
|
||||
loadTimeout: number;
|
||||
skipInactive: boolean;
|
||||
showWarning: boolean;
|
||||
showDebug: boolean;
|
||||
blockClass: string;
|
||||
liveMode: boolean;
|
||||
insertStyleRules: string[];
|
||||
triggerFocus: boolean;
|
||||
UNSAFE_replayCanvas: boolean;
|
||||
pauseAnimation?: boolean;
|
||||
mouseTail:
|
||||
| boolean
|
||||
| {
|
||||
duration?: number;
|
||||
lineCap?: string;
|
||||
lineWidth?: number;
|
||||
strokeStyle?: string;
|
||||
};
|
||||
unpackFn?: UnpackFn;
|
||||
plugins?: ReplayPlugin[];
|
||||
};
|
||||
|
||||
export type playerMetaData = {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
totalTime: number;
|
||||
};
|
||||
|
||||
export type missingNode = {
|
||||
node: Node;
|
||||
mutation: addedNodeMutation;
|
||||
};
|
||||
export type missingNodeMap = {
|
||||
[id: number]: missingNode;
|
||||
};
|
||||
|
||||
export type actionWithDelay = {
|
||||
doAction: () => void;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
export type Handler = (event?: unknown) => void;
|
||||
|
||||
export type Emitter = {
|
||||
on(type: string, handler: Handler): void;
|
||||
emit(type: string, event?: unknown): void;
|
||||
off(type: string, handler: Handler): void;
|
||||
};
|
||||
|
||||
export type Arguments<T> = T extends (...payload: infer U) => unknown
|
||||
? U
|
||||
: unknown;
|
||||
|
||||
export enum ReplayerEvents {
|
||||
Start = 'start',
|
||||
Pause = 'pause',
|
||||
Resume = 'resume',
|
||||
Resize = 'resize',
|
||||
Finish = 'finish',
|
||||
FullsnapshotRebuilded = 'fullsnapshot-rebuilded',
|
||||
LoadStylesheetStart = 'load-stylesheet-start',
|
||||
LoadStylesheetEnd = 'load-stylesheet-end',
|
||||
SkipStart = 'skip-start',
|
||||
SkipEnd = 'skip-end',
|
||||
MouseInteraction = 'mouse-interaction',
|
||||
EventCast = 'event-cast',
|
||||
CustomEvent = 'custom-event',
|
||||
Flush = 'flush',
|
||||
StateChange = 'state-change',
|
||||
PlayBack = 'play-back',
|
||||
}
|
||||
|
||||
// store the state that would be changed during the process(unmount from dom and mount again)
|
||||
export type ElementState = {
|
||||
// [scrollLeft,scrollTop]
|
||||
scroll?: [number, number];
|
||||
};
|
||||
|
||||
export type KeepIframeSrcFn = (src: string) => boolean;
|
||||
662
packages/rrweb/src/utils.ts
Normal file
662
packages/rrweb/src/utils.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
import {
|
||||
Mirror,
|
||||
throttleOptions,
|
||||
listenerHandler,
|
||||
hookResetter,
|
||||
blockClass,
|
||||
eventWithTime,
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
addedNodeMutation,
|
||||
removedNodeMutation,
|
||||
textMutation,
|
||||
attributeMutation,
|
||||
mutationData,
|
||||
scrollData,
|
||||
inputData,
|
||||
DocumentDimension,
|
||||
} from './types';
|
||||
import {
|
||||
INode,
|
||||
IGNORED_NODE,
|
||||
serializedNodeWithId,
|
||||
NodeType,
|
||||
isShadowRoot,
|
||||
} from 'rrweb-snapshot';
|
||||
|
||||
export function on(
|
||||
type: string,
|
||||
fn: EventListenerOrEventListenerObject,
|
||||
target: Document | Window = document,
|
||||
): listenerHandler {
|
||||
const options = { capture: true, passive: true };
|
||||
target.addEventListener(type, fn, options);
|
||||
return () => target.removeEventListener(type, fn, options);
|
||||
}
|
||||
|
||||
export function createMirror(): Mirror {
|
||||
return {
|
||||
map: {},
|
||||
getId(n) {
|
||||
// if n is not a serialized INode, use -1 as its id.
|
||||
if (!n.__sn) {
|
||||
return -1;
|
||||
}
|
||||
return n.__sn.id;
|
||||
},
|
||||
getNode(id) {
|
||||
return this.map[id] || null;
|
||||
},
|
||||
// TODO: use a weakmap to get rid of manually memory management
|
||||
removeNodeFromMap(n) {
|
||||
const id = n.__sn && n.__sn.id;
|
||||
delete this.map[id];
|
||||
if (n.childNodes) {
|
||||
n.childNodes.forEach((child) =>
|
||||
this.removeNodeFromMap((child as Node) as INode),
|
||||
);
|
||||
}
|
||||
},
|
||||
has(id) {
|
||||
return this.map.hasOwnProperty(id);
|
||||
},
|
||||
reset() {
|
||||
this.map = {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// https://github.com/rrweb-io/rrweb/pull/407
|
||||
const DEPARTED_MIRROR_ACCESS_WARNING =
|
||||
'Please stop import mirror directly. Instead of that,' +
|
||||
'\r\n' +
|
||||
'now you can use replayer.getMirror() to access the mirror instance of a replayer,' +
|
||||
'\r\n' +
|
||||
'or you can use record.mirror to access the mirror instance during recording.';
|
||||
export let _mirror: Mirror = {
|
||||
map: {},
|
||||
getId() {
|
||||
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
|
||||
return -1;
|
||||
},
|
||||
getNode() {
|
||||
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
|
||||
return null;
|
||||
},
|
||||
removeNodeFromMap() {
|
||||
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
|
||||
},
|
||||
has() {
|
||||
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
|
||||
return false;
|
||||
},
|
||||
reset() {
|
||||
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
|
||||
},
|
||||
};
|
||||
if (typeof window !== 'undefined' && window.Proxy && window.Reflect) {
|
||||
_mirror = new Proxy(_mirror, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'map') {
|
||||
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// copy from underscore and modified
|
||||
export function throttle<T>(
|
||||
func: (arg: T) => void,
|
||||
wait: number,
|
||||
options: throttleOptions = {},
|
||||
) {
|
||||
let timeout: number | null = null;
|
||||
let previous = 0;
|
||||
// tslint:disable-next-line: only-arrow-functions
|
||||
return function (arg: T) {
|
||||
let now = Date.now();
|
||||
if (!previous && options.leading === false) {
|
||||
previous = now;
|
||||
}
|
||||
let remaining = wait - (now - previous);
|
||||
let context = this;
|
||||
let args = arguments;
|
||||
if (remaining <= 0 || remaining > wait) {
|
||||
if (timeout) {
|
||||
window.clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
previous = now;
|
||||
func.apply(context, args);
|
||||
} else if (!timeout && options.trailing !== false) {
|
||||
timeout = window.setTimeout(() => {
|
||||
previous = options.leading === false ? 0 : Date.now();
|
||||
timeout = null;
|
||||
func.apply(context, args);
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hookSetter<T>(
|
||||
target: T,
|
||||
key: string | number | symbol,
|
||||
d: PropertyDescriptor,
|
||||
isRevoked?: boolean,
|
||||
win = window,
|
||||
): hookResetter {
|
||||
const original = win.Object.getOwnPropertyDescriptor(target, key);
|
||||
win.Object.defineProperty(
|
||||
target,
|
||||
key,
|
||||
isRevoked
|
||||
? d
|
||||
: {
|
||||
set(value) {
|
||||
// put hooked setter into event loop to avoid of set latency
|
||||
setTimeout(() => {
|
||||
d.set!.call(this, value);
|
||||
}, 0);
|
||||
if (original && original.set) {
|
||||
original.set.call(this, value);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
return () => hookSetter(target, key, original || {}, true);
|
||||
}
|
||||
|
||||
// copy from https://github.com/getsentry/sentry-javascript/blob/b2109071975af8bf0316d3b5b38f519bdaf5dc15/packages/utils/src/object.ts
|
||||
export function patch(
|
||||
// tslint:disable-next-line:no-any
|
||||
source: { [key: string]: any },
|
||||
name: string,
|
||||
// tslint:disable-next-line:no-any
|
||||
replacement: (...args: any[]) => any,
|
||||
): () => void {
|
||||
try {
|
||||
if (!(name in source)) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
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') {
|
||||
wrapped.prototype = wrapped.prototype || {};
|
||||
Object.defineProperties(wrapped, {
|
||||
__rrweb_original__: {
|
||||
enumerable: false,
|
||||
value: original,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export function getWindowHeight(): number {
|
||||
return (
|
||||
window.innerHeight ||
|
||||
(document.documentElement && document.documentElement.clientHeight) ||
|
||||
(document.body && document.body.clientHeight)
|
||||
);
|
||||
}
|
||||
|
||||
export function getWindowWidth(): number {
|
||||
return (
|
||||
window.innerWidth ||
|
||||
(document.documentElement && document.documentElement.clientWidth) ||
|
||||
(document.body && document.body.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
export function isBlocked(node: Node | null, blockClass: blockClass): boolean {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (node.nodeType === node.ELEMENT_NODE) {
|
||||
let needBlock = false;
|
||||
if (typeof blockClass === 'string') {
|
||||
needBlock = (node as HTMLElement).classList.contains(blockClass);
|
||||
} else {
|
||||
(node as HTMLElement).classList.forEach((className) => {
|
||||
if (blockClass.test(className)) {
|
||||
needBlock = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return needBlock || isBlocked(node.parentNode, blockClass);
|
||||
}
|
||||
if (node.nodeType === node.TEXT_NODE) {
|
||||
// check parent node since text node do not have class name
|
||||
return isBlocked(node.parentNode, blockClass);
|
||||
}
|
||||
return isBlocked(node.parentNode, blockClass);
|
||||
}
|
||||
|
||||
export function isIgnored(n: Node | INode): boolean {
|
||||
if ('__sn' in n) {
|
||||
return (n as INode).__sn.id === IGNORED_NODE;
|
||||
}
|
||||
// The main part of the slimDOM check happens in
|
||||
// rrweb-snapshot::serializeNodeWithId
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAncestorRemoved(target: INode, mirror: Mirror): boolean {
|
||||
if (isShadowRoot(target)) {
|
||||
return false;
|
||||
}
|
||||
const id = mirror.getId(target);
|
||||
if (!mirror.has(id)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
target.parentNode &&
|
||||
target.parentNode.nodeType === target.DOCUMENT_NODE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// if the root is not document, it means the node is not in the DOM tree anymore
|
||||
if (!target.parentNode) {
|
||||
return true;
|
||||
}
|
||||
return isAncestorRemoved((target.parentNode as unknown) as INode, mirror);
|
||||
}
|
||||
|
||||
export function isTouchEvent(
|
||||
event: MouseEvent | TouchEvent,
|
||||
): event is TouchEvent {
|
||||
return Boolean((event as TouchEvent).changedTouches);
|
||||
}
|
||||
|
||||
export function polyfill(win = window) {
|
||||
if ('NodeList' in win && !win.NodeList.prototype.forEach) {
|
||||
win.NodeList.prototype.forEach = (Array.prototype
|
||||
.forEach as unknown) as NodeList['forEach'];
|
||||
}
|
||||
|
||||
if ('DOMTokenList' in win && !win.DOMTokenList.prototype.forEach) {
|
||||
win.DOMTokenList.prototype.forEach = (Array.prototype
|
||||
.forEach as unknown) as DOMTokenList['forEach'];
|
||||
}
|
||||
|
||||
// https://github.com/Financial-Times/polyfill-service/pull/183
|
||||
if (!Node.prototype.contains) {
|
||||
Node.prototype.contains = function contains(node) {
|
||||
if (!(0 in arguments)) {
|
||||
throw new TypeError('1 argument is required');
|
||||
}
|
||||
|
||||
do {
|
||||
if (this === node) {
|
||||
return true;
|
||||
}
|
||||
// tslint:disable-next-line: no-conditional-assignment
|
||||
} while ((node = node && node.parentNode));
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function needCastInSyncMode(event: eventWithTime): boolean {
|
||||
switch (event.type) {
|
||||
case EventType.DomContentLoaded:
|
||||
case EventType.Load:
|
||||
case EventType.Custom:
|
||||
return false;
|
||||
case EventType.FullSnapshot:
|
||||
case EventType.Meta:
|
||||
case EventType.Plugin:
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
switch (event.data.source) {
|
||||
case IncrementalSource.MouseMove:
|
||||
case IncrementalSource.MouseInteraction:
|
||||
case IncrementalSource.TouchMove:
|
||||
case IncrementalSource.MediaInteraction:
|
||||
return false;
|
||||
case IncrementalSource.ViewportResize:
|
||||
case IncrementalSource.StyleSheetRule:
|
||||
case IncrementalSource.Scroll:
|
||||
case IncrementalSource.Input:
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type TreeNode = {
|
||||
id: number;
|
||||
mutation: addedNodeMutation;
|
||||
parent?: TreeNode;
|
||||
children: Record<number, TreeNode>;
|
||||
texts: textMutation[];
|
||||
attributes: attributeMutation[];
|
||||
};
|
||||
export class TreeIndex {
|
||||
public tree!: Record<number, TreeNode>;
|
||||
|
||||
private removeNodeMutations!: removedNodeMutation[];
|
||||
private textMutations!: textMutation[];
|
||||
private attributeMutations!: attributeMutation[];
|
||||
private indexes!: Map<number, TreeNode>;
|
||||
private removeIdSet!: Set<number>;
|
||||
private scrollMap!: Map<number, scrollData>;
|
||||
private inputMap!: Map<number, inputData>;
|
||||
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public add(mutation: addedNodeMutation) {
|
||||
const parentTreeNode = this.indexes.get(mutation.parentId);
|
||||
const treeNode: TreeNode = {
|
||||
id: mutation.node.id,
|
||||
mutation,
|
||||
children: [],
|
||||
texts: [],
|
||||
attributes: [],
|
||||
};
|
||||
if (!parentTreeNode) {
|
||||
this.tree[treeNode.id] = treeNode;
|
||||
} else {
|
||||
treeNode.parent = parentTreeNode;
|
||||
parentTreeNode.children[treeNode.id] = treeNode;
|
||||
}
|
||||
this.indexes.set(treeNode.id, treeNode);
|
||||
}
|
||||
|
||||
public remove(mutation: removedNodeMutation, mirror: Mirror) {
|
||||
const parentTreeNode = this.indexes.get(mutation.parentId);
|
||||
const treeNode = this.indexes.get(mutation.id);
|
||||
|
||||
const deepRemoveFromMirror = (id: number) => {
|
||||
this.removeIdSet.add(id);
|
||||
const node = mirror.getNode(id);
|
||||
node?.childNodes.forEach((childNode) => {
|
||||
if ('__sn' in childNode) {
|
||||
deepRemoveFromMirror(((childNode as unknown) as INode).__sn.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
const deepRemoveFromTreeIndex = (node: TreeNode) => {
|
||||
this.removeIdSet.add(node.id);
|
||||
Object.values(node.children).forEach((n) => deepRemoveFromTreeIndex(n));
|
||||
const _treeNode = this.indexes.get(node.id);
|
||||
if (_treeNode) {
|
||||
const _parentTreeNode = _treeNode.parent;
|
||||
if (_parentTreeNode) {
|
||||
delete _treeNode.parent;
|
||||
delete _parentTreeNode.children[_treeNode.id];
|
||||
this.indexes.delete(mutation.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!treeNode) {
|
||||
this.removeNodeMutations.push(mutation);
|
||||
deepRemoveFromMirror(mutation.id);
|
||||
} else if (!parentTreeNode) {
|
||||
delete this.tree[treeNode.id];
|
||||
this.indexes.delete(treeNode.id);
|
||||
deepRemoveFromTreeIndex(treeNode);
|
||||
} else {
|
||||
delete treeNode.parent;
|
||||
delete parentTreeNode.children[treeNode.id];
|
||||
this.indexes.delete(mutation.id);
|
||||
deepRemoveFromTreeIndex(treeNode);
|
||||
}
|
||||
}
|
||||
|
||||
public text(mutation: textMutation) {
|
||||
const treeNode = this.indexes.get(mutation.id);
|
||||
if (treeNode) {
|
||||
treeNode.texts.push(mutation);
|
||||
} else {
|
||||
this.textMutations.push(mutation);
|
||||
}
|
||||
}
|
||||
|
||||
public attribute(mutation: attributeMutation) {
|
||||
const treeNode = this.indexes.get(mutation.id);
|
||||
if (treeNode) {
|
||||
treeNode.attributes.push(mutation);
|
||||
} else {
|
||||
this.attributeMutations.push(mutation);
|
||||
}
|
||||
}
|
||||
|
||||
public scroll(d: scrollData) {
|
||||
this.scrollMap.set(d.id, d);
|
||||
}
|
||||
|
||||
public input(d: inputData) {
|
||||
this.inputMap.set(d.id, d);
|
||||
}
|
||||
|
||||
public flush(): {
|
||||
mutationData: mutationData;
|
||||
scrollMap: TreeIndex['scrollMap'];
|
||||
inputMap: TreeIndex['inputMap'];
|
||||
} {
|
||||
const {
|
||||
tree,
|
||||
removeNodeMutations,
|
||||
textMutations,
|
||||
attributeMutations,
|
||||
} = this;
|
||||
|
||||
const batchMutationData: mutationData = {
|
||||
source: IncrementalSource.Mutation,
|
||||
removes: removeNodeMutations,
|
||||
texts: textMutations,
|
||||
attributes: attributeMutations,
|
||||
adds: [],
|
||||
};
|
||||
|
||||
const walk = (treeNode: TreeNode, removed: boolean) => {
|
||||
if (removed) {
|
||||
this.removeIdSet.add(treeNode.id);
|
||||
}
|
||||
batchMutationData.texts = batchMutationData.texts
|
||||
.concat(removed ? [] : treeNode.texts)
|
||||
.filter((m) => !this.removeIdSet.has(m.id));
|
||||
batchMutationData.attributes = batchMutationData.attributes
|
||||
.concat(removed ? [] : treeNode.attributes)
|
||||
.filter((m) => !this.removeIdSet.has(m.id));
|
||||
if (
|
||||
!this.removeIdSet.has(treeNode.id) &&
|
||||
!this.removeIdSet.has(treeNode.mutation.parentId) &&
|
||||
!removed
|
||||
) {
|
||||
batchMutationData.adds.push(treeNode.mutation);
|
||||
if (treeNode.children) {
|
||||
Object.values(treeNode.children).forEach((n) => walk(n, false));
|
||||
}
|
||||
} else {
|
||||
Object.values(treeNode.children).forEach((n) => walk(n, true));
|
||||
}
|
||||
};
|
||||
|
||||
Object.values(tree).forEach((n) => walk(n, false));
|
||||
|
||||
for (const id of this.scrollMap.keys()) {
|
||||
if (this.removeIdSet.has(id)) {
|
||||
this.scrollMap.delete(id);
|
||||
}
|
||||
}
|
||||
for (const id of this.inputMap.keys()) {
|
||||
if (this.removeIdSet.has(id)) {
|
||||
this.inputMap.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const scrollMap = new Map(this.scrollMap);
|
||||
const inputMap = new Map(this.inputMap);
|
||||
|
||||
this.reset();
|
||||
|
||||
return {
|
||||
mutationData: batchMutationData,
|
||||
scrollMap,
|
||||
inputMap,
|
||||
};
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.tree = [];
|
||||
this.indexes = new Map();
|
||||
this.removeNodeMutations = [];
|
||||
this.textMutations = [];
|
||||
this.attributeMutations = [];
|
||||
this.removeIdSet = new Set();
|
||||
this.scrollMap = new Map();
|
||||
this.inputMap = new Map();
|
||||
}
|
||||
|
||||
public idRemoved(id: number): boolean {
|
||||
return this.removeIdSet.has(id);
|
||||
}
|
||||
}
|
||||
|
||||
type ResolveTree = {
|
||||
value: addedNodeMutation;
|
||||
children: ResolveTree[];
|
||||
parent: ResolveTree | null;
|
||||
};
|
||||
|
||||
export function queueToResolveTrees(queue: addedNodeMutation[]): ResolveTree[] {
|
||||
const queueNodeMap: Record<number, ResolveTree> = {};
|
||||
const putIntoMap = (
|
||||
m: addedNodeMutation,
|
||||
parent: ResolveTree | null,
|
||||
): ResolveTree => {
|
||||
const nodeInTree: ResolveTree = {
|
||||
value: m,
|
||||
parent,
|
||||
children: [],
|
||||
};
|
||||
queueNodeMap[m.node.id] = nodeInTree;
|
||||
return nodeInTree;
|
||||
};
|
||||
|
||||
const queueNodeTrees: ResolveTree[] = [];
|
||||
for (const mutation of queue) {
|
||||
const { nextId, parentId } = mutation;
|
||||
if (nextId && nextId in queueNodeMap) {
|
||||
const nextInTree = queueNodeMap[nextId];
|
||||
if (nextInTree.parent) {
|
||||
const idx = nextInTree.parent.children.indexOf(nextInTree);
|
||||
nextInTree.parent.children.splice(
|
||||
idx,
|
||||
0,
|
||||
putIntoMap(mutation, nextInTree.parent),
|
||||
);
|
||||
} else {
|
||||
const idx = queueNodeTrees.indexOf(nextInTree);
|
||||
queueNodeTrees.splice(idx, 0, putIntoMap(mutation, null));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (parentId in queueNodeMap) {
|
||||
const parentInTree = queueNodeMap[parentId];
|
||||
parentInTree.children.push(putIntoMap(mutation, parentInTree));
|
||||
continue;
|
||||
}
|
||||
queueNodeTrees.push(putIntoMap(mutation, null));
|
||||
}
|
||||
|
||||
return queueNodeTrees;
|
||||
}
|
||||
|
||||
export function iterateResolveTree(
|
||||
tree: ResolveTree,
|
||||
cb: (mutation: addedNodeMutation) => unknown,
|
||||
) {
|
||||
cb(tree.value);
|
||||
/**
|
||||
* The resolve tree was designed to reflect the DOM layout,
|
||||
* but we need append next sibling first, so we do a reverse
|
||||
* loop here.
|
||||
*/
|
||||
for (let i = tree.children.length - 1; i >= 0; i--) {
|
||||
iterateResolveTree(tree.children[i], cb);
|
||||
}
|
||||
}
|
||||
|
||||
type HTMLIFrameINode = HTMLIFrameElement & {
|
||||
__sn: serializedNodeWithId;
|
||||
};
|
||||
export type AppendedIframe = {
|
||||
mutationInQueue: addedNodeMutation;
|
||||
builtNode: HTMLIFrameINode;
|
||||
};
|
||||
|
||||
export function isIframeINode(
|
||||
node: INode | ShadowRoot,
|
||||
): node is HTMLIFrameINode {
|
||||
if ('__sn' in node) {
|
||||
return (
|
||||
node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe'
|
||||
);
|
||||
}
|
||||
// node can be document fragment when using the virtual parent feature
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getBaseDimension(
|
||||
node: Node,
|
||||
rootIframe: Node,
|
||||
): DocumentDimension {
|
||||
const frameElement = node.ownerDocument?.defaultView?.frameElement;
|
||||
if (!frameElement || frameElement === rootIframe) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
relativeScale: 1,
|
||||
absoluteScale: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const frameDimension = frameElement.getBoundingClientRect();
|
||||
const frameBaseDimension = getBaseDimension(frameElement, rootIframe);
|
||||
// the iframe element may have a scale transform
|
||||
const relativeScale = frameDimension.height / frameElement.clientHeight;
|
||||
return {
|
||||
x:
|
||||
frameDimension.x * frameBaseDimension.relativeScale +
|
||||
frameBaseDimension.x,
|
||||
y:
|
||||
frameDimension.y * frameBaseDimension.relativeScale +
|
||||
frameBaseDimension.y,
|
||||
relativeScale,
|
||||
absoluteScale: frameBaseDimension.absoluteScale * relativeScale,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasShadowRoot<T extends Node>(
|
||||
n: T,
|
||||
): n is T & { shadowRoot: ShadowRoot } {
|
||||
return Boolean(((n as unknown) as Element)?.shadowRoot);
|
||||
}
|
||||
16
packages/rrweb/test.d.ts
vendored
Normal file
16
packages/rrweb/test.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
declare module 'jest-snapshot' {
|
||||
export class SnapshotState {
|
||||
constructor(testFile: string, options: any);
|
||||
|
||||
save(): any;
|
||||
}
|
||||
type matchResult = {
|
||||
pass: boolean;
|
||||
report(): string;
|
||||
};
|
||||
export function toMatchSnapshot(
|
||||
received: any,
|
||||
propertyMatchers?: any,
|
||||
testName?: string,
|
||||
): matchResult;
|
||||
}
|
||||
9350
packages/rrweb/test/__snapshots__/integration.test.ts.snap
Normal file
9350
packages/rrweb/test/__snapshots__/integration.test.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/rrweb/test/__snapshots__/packer.test.ts.snap
Normal file
BIN
packages/rrweb/test/__snapshots__/packer.test.ts.snap
Normal file
Binary file not shown.
461
packages/rrweb/test/__snapshots__/record.test.ts.snap
Normal file
461
packages/rrweb/test/__snapshots__/record.test.ts.snap
Normal file
@@ -0,0 +1,461 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`async-checkout 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\\",
|
||||
\\"size\\": \\"40\\"
|
||||
},
|
||||
\\"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\\": 0,
|
||||
\\"texts\\": [],
|
||||
\\"attributes\\": [],
|
||||
\\"removes\\": [
|
||||
{
|
||||
\\"parentId\\": 4,
|
||||
\\"id\\": 6
|
||||
}
|
||||
],
|
||||
\\"adds\\": [
|
||||
{
|
||||
\\"parentId\\": 4,
|
||||
\\"nextId\\": null,
|
||||
\\"node\\": {
|
||||
\\"type\\": 2,
|
||||
\\"tagName\\": \\"p\\",
|
||||
\\"attributes\\": {},
|
||||
\\"childNodes\\": [],
|
||||
\\"id\\": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
\\"parentId\\": 8,
|
||||
\\"nextId\\": null,
|
||||
\\"node\\": {
|
||||
\\"type\\": 2,
|
||||
\\"tagName\\": \\"span\\",
|
||||
\\"attributes\\": {},
|
||||
\\"childNodes\\": [],
|
||||
\\"id\\": 9
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"data\\": {
|
||||
\\"source\\": 0,
|
||||
\\"texts\\": [],
|
||||
\\"attributes\\": [],
|
||||
\\"removes\\": [],
|
||||
\\"adds\\": [
|
||||
{
|
||||
\\"parentId\\": 9,
|
||||
\\"nextId\\": null,
|
||||
\\"node\\": {
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"test\\",
|
||||
\\"id\\": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
\\"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\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
|
||||
\\"id\\": 7
|
||||
},
|
||||
{
|
||||
\\"type\\": 2,
|
||||
\\"tagName\\": \\"p\\",
|
||||
\\"attributes\\": {},
|
||||
\\"childNodes\\": [
|
||||
{
|
||||
\\"type\\": 2,
|
||||
\\"tagName\\": \\"span\\",
|
||||
\\"attributes\\": {},
|
||||
\\"childNodes\\": [
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"test\\",
|
||||
\\"id\\": 10
|
||||
}
|
||||
],
|
||||
\\"id\\": 9
|
||||
}
|
||||
],
|
||||
\\"id\\": 8
|
||||
}
|
||||
],
|
||||
\\"id\\": 4
|
||||
}
|
||||
],
|
||||
\\"id\\": 2
|
||||
}
|
||||
],
|
||||
\\"id\\": 1
|
||||
},
|
||||
\\"initialOffset\\": {
|
||||
\\"left\\": 0,
|
||||
\\"top\\": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"data\\": {
|
||||
\\"source\\": 0,
|
||||
\\"texts\\": [],
|
||||
\\"attributes\\": [],
|
||||
\\"removes\\": [
|
||||
{
|
||||
\\"parentId\\": 8,
|
||||
\\"id\\": 9
|
||||
}
|
||||
],
|
||||
\\"adds\\": [
|
||||
{
|
||||
\\"parentId\\": 4,
|
||||
\\"nextId\\": null,
|
||||
\\"node\\": {
|
||||
\\"type\\": 2,
|
||||
\\"tagName\\": \\"span\\",
|
||||
\\"attributes\\": {},
|
||||
\\"childNodes\\": [],
|
||||
\\"id\\": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
\\"parentId\\": 9,
|
||||
\\"nextId\\": null,
|
||||
\\"node\\": {
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"test\\",
|
||||
\\"id\\": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]"
|
||||
`;
|
||||
|
||||
exports[`custom-event 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\\",
|
||||
\\"size\\": \\"40\\"
|
||||
},
|
||||
\\"childNodes\\": [],
|
||||
\\"id\\": 6
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
|
||||
\\"id\\": 7
|
||||
}
|
||||
],
|
||||
\\"id\\": 4
|
||||
}
|
||||
],
|
||||
\\"id\\": 2
|
||||
}
|
||||
],
|
||||
\\"id\\": 1
|
||||
},
|
||||
\\"initialOffset\\": {
|
||||
\\"left\\": 0,
|
||||
\\"top\\": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
\\"type\\": 5,
|
||||
\\"data\\": {
|
||||
\\"tag\\": \\"tag1\\",
|
||||
\\"payload\\": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
\\"type\\": 5,
|
||||
\\"data\\": {
|
||||
\\"tag\\": \\"tag2\\",
|
||||
\\"payload\\": {
|
||||
\\"a\\": \\"b\\"
|
||||
}
|
||||
}
|
||||
}
|
||||
]"
|
||||
`;
|
||||
|
||||
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\\",
|
||||
\\"size\\": \\"40\\"
|
||||
},
|
||||
\\"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\\": 0,
|
||||
\\"texts\\": [],
|
||||
\\"attributes\\": [],
|
||||
\\"removes\\": [],
|
||||
\\"adds\\": [
|
||||
{
|
||||
\\"parentId\\": 3,
|
||||
\\"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; }\\"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]"
|
||||
`;
|
||||
278
packages/rrweb/test/__snapshots__/replayer.test.ts.snap
Normal file
278
packages/rrweb/test/__snapshots__/replayer.test.ts.snap
Normal file
@@ -0,0 +1,278 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`style-sheet-remove-events-play-at-2500 1`] = `
|
||||
"file-frame-1
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
|
||||
</head>
|
||||
<body>
|
||||
<div class=\\"replayer-wrapper\\">
|
||||
<div class=\\"replayer-mouse\\"></div>
|
||||
<canvas
|
||||
class=\\"replayer-mouse-tail\\"
|
||||
width=\\"1000\\"
|
||||
height=\\"800\\"
|
||||
style=\\"display: inherit\\"
|
||||
></canvas
|
||||
><iframe
|
||||
sandbox=\\"allow-same-origin\\"
|
||||
scrolling=\\"no\\"
|
||||
width=\\"1000\\"
|
||||
height=\\"800\\"
|
||||
style=\\"display: inherit; pointer-events: none\\"
|
||||
></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
file-frame-2
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
||||
|
||||
file-cid-0
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.rr-block { background: rgb(204, 204, 204); }
|
||||
|
||||
noscript { display: none !important; }
|
||||
|
||||
html.rrweb-paused * { animation-play-state: paused !important; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`style-sheet-rule-events-pause-at-2500 1`] = `
|
||||
"file-frame-4
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<div class=\\"replayer-wrapper\\">
|
||||
<div class=\\"replayer-mouse\\"></div>
|
||||
<canvas class=\\"replayer-mouse-tail\\" width=\\"1000\\" height=\\"800\\" style=
|
||||
\\"display: inherit;\\"></canvas><iframe sandbox=\\"allow-same-origin\\" scrolling=
|
||||
\\"no\\" width=\\"1000\\" height=\\"800\\" style=
|
||||
\\"display: inherit; pointer-events: none;\\"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
file-frame-5
|
||||
<!DOCTYPE html>
|
||||
<html lang=\\"en\\">
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\">
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-1\\">
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-2\\">
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-3\\">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<a class=\\"css-added-at-1000-deleted-at-2500\\">string</a>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
file-cid-0
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.rr-block { background: rgb(204, 204, 204); }
|
||||
|
||||
noscript { display: none !important; }
|
||||
|
||||
html.rrweb-paused * { animation-play-state: paused !important; }
|
||||
|
||||
|
||||
file-cid-1
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.c011xx { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
|
||||
|
||||
|
||||
file-cid-2
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.c01x { opacity: 1; transform: translateX(0px); }
|
||||
|
||||
.css-added-at-400 { border: 1px solid blue; }
|
||||
|
||||
|
||||
file-cid-3
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }
|
||||
|
||||
.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
|
||||
|
||||
.css-lsxxx { padding-left: 4rem; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`style-sheet-rule-events-play-at-1500 1`] = `
|
||||
"file-frame-4
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
|
||||
</head>
|
||||
<body>
|
||||
<div class=\\"replayer-wrapper\\">
|
||||
<div class=\\"replayer-mouse\\"></div>
|
||||
<canvas
|
||||
class=\\"replayer-mouse-tail\\"
|
||||
width=\\"1000\\"
|
||||
height=\\"800\\"
|
||||
style=\\"display: inherit\\"
|
||||
></canvas
|
||||
><iframe
|
||||
sandbox=\\"allow-same-origin\\"
|
||||
scrolling=\\"no\\"
|
||||
width=\\"1000\\"
|
||||
height=\\"800\\"
|
||||
style=\\"display: inherit; pointer-events: none\\"
|
||||
></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
file-frame-5
|
||||
<!DOCTYPE html>
|
||||
<html lang=\\"en\\">
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-1\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-2\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-3\\" />
|
||||
</head>
|
||||
<body>
|
||||
<a class=\\"css-added-at-1000-deleted-at-2500\\">string</a>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
file-cid-0
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.rr-block { background: rgb(204, 204, 204); }
|
||||
|
||||
noscript { display: none !important; }
|
||||
|
||||
html.rrweb-paused * { animation-play-state: paused !important; }
|
||||
|
||||
|
||||
file-cid-1
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.c011xx { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
|
||||
|
||||
|
||||
file-cid-2
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.c01x { opacity: 1; transform: translateX(0px); }
|
||||
|
||||
.css-added-at-400 { border: 1px solid blue; }
|
||||
|
||||
|
||||
file-cid-3
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }
|
||||
|
||||
.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
|
||||
|
||||
.css-added-at-1000-deleted-at-2500 { display: flex; flex-direction: column; min-width: 60rem; min-height: 100vh; color: blue; }
|
||||
|
||||
.css-lsxxx { padding-left: 4rem; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`style-sheet-rule-events-play-at-2500 1`] = `
|
||||
"file-frame-4
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
|
||||
</head>
|
||||
<body>
|
||||
<div class=\\"replayer-wrapper\\">
|
||||
<div class=\\"replayer-mouse\\"></div>
|
||||
<canvas
|
||||
class=\\"replayer-mouse-tail\\"
|
||||
width=\\"1000\\"
|
||||
height=\\"800\\"
|
||||
style=\\"display: inherit\\"
|
||||
></canvas
|
||||
><iframe
|
||||
sandbox=\\"allow-same-origin\\"
|
||||
scrolling=\\"no\\"
|
||||
width=\\"1000\\"
|
||||
height=\\"800\\"
|
||||
style=\\"display: inherit; pointer-events: none\\"
|
||||
></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
file-frame-5
|
||||
<!DOCTYPE html>
|
||||
<html lang=\\"en\\">
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-1\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-2\\" />
|
||||
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-3\\" />
|
||||
</head>
|
||||
<body>
|
||||
<a class=\\"css-added-at-1000-deleted-at-2500\\">string</a>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
file-cid-0
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.rr-block { background: rgb(204, 204, 204); }
|
||||
|
||||
noscript { display: none !important; }
|
||||
|
||||
html.rrweb-paused * { animation-play-state: paused !important; }
|
||||
|
||||
|
||||
file-cid-1
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.c011xx { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
|
||||
|
||||
|
||||
file-cid-2
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.c01x { opacity: 1; transform: translateX(0px); }
|
||||
|
||||
.css-added-at-400 { border: 1px solid blue; }
|
||||
|
||||
|
||||
file-cid-3
|
||||
@charset \\"utf-8\\";
|
||||
|
||||
.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }
|
||||
|
||||
.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
|
||||
|
||||
.css-lsxxx { padding-left: 4rem; }
|
||||
"
|
||||
`;
|
||||
189
packages/rrweb/test/events/style-sheet-rule-events.ts
Normal file
189
packages/rrweb/test/events/style-sheet-rule-events.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { EventType, eventWithTime, IncrementalSource } from '../../src/types';
|
||||
|
||||
const now = Date.now();
|
||||
const events: eventWithTime[] = [
|
||||
{
|
||||
type: EventType.DomContentLoaded,
|
||||
data: {},
|
||||
timestamp: now,
|
||||
},
|
||||
{
|
||||
type: EventType.Load,
|
||||
data: {},
|
||||
timestamp: now + 100,
|
||||
},
|
||||
{
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
href: 'http://localhost',
|
||||
width: 1000,
|
||||
height: 800,
|
||||
},
|
||||
timestamp: now + 100,
|
||||
},
|
||||
// full snapshot:
|
||||
{
|
||||
data: {
|
||||
node: {
|
||||
id: 1,
|
||||
type: 0,
|
||||
childNodes: [
|
||||
{ id: 2, name: 'html', type: 1, publicId: '', systemId: '' },
|
||||
{
|
||||
id: 3,
|
||||
type: 2,
|
||||
tagName: 'html',
|
||||
attributes: { lang: 'en' },
|
||||
childNodes: [
|
||||
{
|
||||
id: 4,
|
||||
type: 2,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
id: 101,
|
||||
type: 2,
|
||||
tagName: 'style',
|
||||
attributes: {
|
||||
'data-meta': 'from full-snapshot, gets rule added at 500',
|
||||
},
|
||||
childNodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: 3,
|
||||
isStyle: true,
|
||||
textContent:
|
||||
'\n.c01x {\n opacity: 1;\n transform: translateX(0);\n}\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
type: 2,
|
||||
tagName: 'style',
|
||||
attributes: {
|
||||
_cssText:
|
||||
'.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }.css-lsxxx { padding-left: 4rem; }',
|
||||
'data-emotion': 'css',
|
||||
},
|
||||
childNodes: [
|
||||
{ id: 106, type: 3, isStyle: true, textContent: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 107,
|
||||
type: 2,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
id: 108,
|
||||
type: 2,
|
||||
tagName: 'a',
|
||||
attributes: {
|
||||
class: 'css-added-at-1000-deleted-at-2500',
|
||||
},
|
||||
childNodes: [
|
||||
{
|
||||
id: 109,
|
||||
type: 3,
|
||||
textContent: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
initialOffset: { top: 0, left: 0 },
|
||||
},
|
||||
type: EventType.FullSnapshot,
|
||||
timestamp: now + 100,
|
||||
},
|
||||
// mutation that adds style rule to existing stylesheet
|
||||
{
|
||||
data: {
|
||||
id: 101,
|
||||
adds: [
|
||||
{
|
||||
rule: '.css-added-at-400{border: 1px solid blue;}',
|
||||
index: 1,
|
||||
},
|
||||
],
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
},
|
||||
type: EventType.IncrementalSnapshot,
|
||||
timestamp: now + 400,
|
||||
},
|
||||
// mutation that adds stylesheet
|
||||
{
|
||||
data: {
|
||||
adds: [
|
||||
{
|
||||
node: {
|
||||
id: 255,
|
||||
type: 2,
|
||||
tagName: 'style',
|
||||
attributes: { 'data-jss': '', 'data-meta': 'Col, Themed, Dynamic' },
|
||||
childNodes: [],
|
||||
},
|
||||
nextId: 101,
|
||||
parentId: 4,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
id: 256,
|
||||
type: 3,
|
||||
isStyle: true,
|
||||
textContent:
|
||||
'\n.c011xx {\n padding: 1.3125rem;\n flex: none;\n width: 100%;\n}\n',
|
||||
},
|
||||
nextId: null,
|
||||
parentId: 255,
|
||||
},
|
||||
],
|
||||
texts: [],
|
||||
source: IncrementalSource.Mutation,
|
||||
removes: [],
|
||||
attributes: [],
|
||||
},
|
||||
type: EventType.IncrementalSnapshot,
|
||||
timestamp: now + 500,
|
||||
},
|
||||
// adds StyleSheetRule
|
||||
{
|
||||
data: {
|
||||
id: 105,
|
||||
adds: [
|
||||
{
|
||||
rule:
|
||||
'.css-added-at-1000-deleted-at-2500{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;min-width:60rem;min-height:100vh;color:blue;}',
|
||||
index: 2,
|
||||
},
|
||||
],
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
},
|
||||
type: EventType.IncrementalSnapshot,
|
||||
timestamp: now + 1000,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 105,
|
||||
removes: [
|
||||
{
|
||||
index: 2,
|
||||
},
|
||||
],
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
},
|
||||
type: EventType.IncrementalSnapshot,
|
||||
timestamp: now + 2500,
|
||||
},
|
||||
];
|
||||
|
||||
export default events;
|
||||
14
packages/rrweb/test/html/block.html
Normal file
14
packages/rrweb/test/html/block.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Block record</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="rr-block" style="width: 50px; height: 50px;">
|
||||
<input type="text" /> <span id="text"></span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
34
packages/rrweb/test/html/canvas.html
Normal file
34
packages/rrweb/test/html/canvas.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!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>
|
||||
var c = document.getElementById('myCanvas');
|
||||
var ctx = c.getContext('2d');
|
||||
// Create gradient
|
||||
var grd = ctx.createLinearGradient(0, 0, 200, 0);
|
||||
grd.addColorStop(0, 'red');
|
||||
grd.addColorStop(1, 'white');
|
||||
|
||||
// Fill with gradient
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(10, 10, 150, 80);
|
||||
setTimeout(() => {
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(200, 100);
|
||||
ctx.stroke();
|
||||
}, 10);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
38
packages/rrweb/test/html/form.html
Normal file
38
packages/rrweb/test/html/form.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>form fields</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<form>
|
||||
<label for="text">
|
||||
<input type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="toggle" value="on" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="toggle" value="off" checked />
|
||||
</label>
|
||||
<label for="checkbox">
|
||||
<input type="checkbox" />
|
||||
</label>
|
||||
<label for="textarea">
|
||||
<textarea name="" id="" cols="30" rows="10"></textarea>
|
||||
</label>
|
||||
<label for="select">
|
||||
<select name="" id="">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</label>
|
||||
<label for="password">
|
||||
<input type="password" />
|
||||
</label>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
13
packages/rrweb/test/html/frame1.html
Normal file
13
packages/rrweb/test/html/frame1.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Frame 1</title>
|
||||
</head>
|
||||
<body>
|
||||
frame 1
|
||||
<iframe id="three" frameborder="0"></iframe>
|
||||
<iframe id="four" src="./frame2.html" frameborder="0"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
18
packages/rrweb/test/html/frame2.html
Normal file
18
packages/rrweb/test/html/frame2.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Frame 2</title>
|
||||
</head>
|
||||
<body>
|
||||
frame 2
|
||||
</body>
|
||||
<script>
|
||||
const iframe5 = document.createElement('iframe');
|
||||
iframe5.id = 'five';
|
||||
setTimeout(() => {
|
||||
document.body.appendChild(iframe5);
|
||||
}, 10);
|
||||
</script>
|
||||
</html>
|
||||
15
packages/rrweb/test/html/ignore.html
Normal file
15
packages/rrweb/test/html/ignore.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>ignore fields</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<form>
|
||||
<label for="ignore text"> <input type="text" class="rr-ignore" /> </label>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
10
packages/rrweb/test/html/log.html
Normal file
10
packages/rrweb/test/html/log.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Log record</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
25
packages/rrweb/test/html/main.html
Normal file
25
packages/rrweb/test/html/main.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Main</title>
|
||||
<style>
|
||||
iframe {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="one"></iframe>
|
||||
</body>
|
||||
<script>
|
||||
const iframe2 = document.createElement('iframe');
|
||||
iframe2.id = 'two';
|
||||
iframe2.src = './html/frame1.html';
|
||||
setTimeout(() => {
|
||||
document.body.appendChild(iframe2);
|
||||
}, 10);
|
||||
</script>
|
||||
</html>
|
||||
20
packages/rrweb/test/html/mask-text.html
Normal file
20
packages/rrweb/test/html/mask-text.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Mask text</title>
|
||||
</head>
|
||||
<body>
|
||||
<p class="rr-mask">mask1</p>
|
||||
<div class="rr-mask">
|
||||
<span>mask2</span>
|
||||
</div>
|
||||
<div data-masking="true">
|
||||
<div>
|
||||
<div>mask3</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
12
packages/rrweb/test/html/move-node.html
Normal file
12
packages/rrweb/test/html/move-node.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<p></p>
|
||||
</div>
|
||||
<span>
|
||||
<i>
|
||||
<b>1</b>
|
||||
</i>
|
||||
</span>
|
||||
</body>
|
||||
</html>
|
||||
6
packages/rrweb/test/html/mutation-observer.html
Normal file
6
packages/rrweb/test/html/mutation-observer.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<body>
|
||||
<p>mutation observer</p>
|
||||
<ul>
|
||||
<li></li>
|
||||
</ul>
|
||||
</body>
|
||||
17
packages/rrweb/test/html/password.html
Normal file
17
packages/rrweb/test/html/password.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
<input type="password" id="password" />
|
||||
<script>
|
||||
const password = document.getElementById('password');
|
||||
password.addEventListener('keyup', (event) => {
|
||||
password.setAttribute('value', password.value);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
65
packages/rrweb/test/html/react-styled-components.html
Normal file
65
packages/rrweb/test/html/react-styled-components.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>react styled components</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/react@16/umd/react.production.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/react-dom@16/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/react-is@16.13.1/umd/react-is.production.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/styled-components@5.0.1/dist/styled-components.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/babel-standalone@6/babel.min.js"></script>
|
||||
<script type="text/babel">
|
||||
const e = React.createElement;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
color: ${(props) => props.color || 'palevioletred'};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.section`
|
||||
padding: 4em;
|
||||
background: papayawhip;
|
||||
`;
|
||||
|
||||
class MyComponent extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
color: 'rebeccapurple',
|
||||
};
|
||||
}
|
||||
|
||||
toggle = () => {
|
||||
this.setState(({ color }) => ({
|
||||
color: color === 'rebeccapurple' ? 'pink' : 'rebeccapurple',
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<styled.StyleSheetManager disableCSSOMInjection={false}>
|
||||
<Wrapper>
|
||||
<Title>Hello World!</Title>
|
||||
<Title
|
||||
className="toggle"
|
||||
color={this.state.color}
|
||||
onClick={this.toggle}
|
||||
>
|
||||
Hello World!
|
||||
</Title>
|
||||
</Wrapper>
|
||||
</styled.StyleSheetManager>
|
||||
);
|
||||
}
|
||||
}
|
||||
const domContainer = document.querySelector('#app');
|
||||
ReactDOM.render(e(MyComponent), domContainer);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
packages/rrweb/test/html/select2.html
Normal file
26
packages/rrweb/test/html/select2.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Select2 3.5</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2@3.5.1/select2.css">
|
||||
</head>
|
||||
<body>
|
||||
<blockquote>
|
||||
Select2 is a jQuery replacement for select boxes.
|
||||
<br>
|
||||
In the 3.5 version it use a quite complicated DOM generation strategy which is a good battle-test for rrweb's recorder.
|
||||
</blockquote>
|
||||
<select id="el">
|
||||
<option value="a">A</option>
|
||||
<option value="b">B</option>
|
||||
</select>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@3.5.2-browserify/select2.min.js"></script>
|
||||
<script>
|
||||
$('#el').select2();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
83
packages/rrweb/test/html/shadow-dom.html
Normal file
83
packages/rrweb/test/html/shadow-dom.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shadow DOM Observer</title>
|
||||
<style>
|
||||
.my-element {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
iframe {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 400px;
|
||||
margin: 1rem auto;
|
||||
padding: 0 1rem;
|
||||
font-family: 'comic sans ms';
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit
|
||||
officiis necessitatibus laborum asperiores et adipisci dolores corporis,
|
||||
vero distinctio voluptas, suscipit commodi architecto, aliquam fugit.
|
||||
Nesciunt labore reiciendis blanditiis!
|
||||
</p>
|
||||
|
||||
<div class="my-element">
|
||||
<!-- Also could be a
|
||||
<custom-element />
|
||||
-->
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit
|
||||
officiis necessitatibus laborum asperiores et adipisci dolores corporis,
|
||||
vero distinctio voluptas, suscipit commodi architecto, aliquam fugit.
|
||||
Nesciunt labore reiciendis blanditiis!
|
||||
</p>
|
||||
<script>
|
||||
let content = `
|
||||
<style>
|
||||
body { /* for fallback iframe */
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
border: 1px solid #ccc;
|
||||
padding: 1rem;
|
||||
color: red;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
<p>Element with Shadow DOM</p>
|
||||
`;
|
||||
|
||||
let myElements = document.querySelectorAll('.my-element');
|
||||
|
||||
if (document.body.attachShadow) {
|
||||
myElements.forEach((el) => {
|
||||
var shadow = el.attachShadow({
|
||||
mode: 'open',
|
||||
});
|
||||
shadow.innerHTML = content;
|
||||
});
|
||||
} else {
|
||||
let newiframe = document.createElement('iframe');
|
||||
newiframe.srcdoc = content;
|
||||
|
||||
myElements.forEach((el) => {
|
||||
let parent = el.parentNode;
|
||||
parent.replaceChild(newiframe, el);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
12
packages/rrweb/test/html/shuffle.html
Normal file
12
packages/rrweb/test/html/shuffle.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>shuffle</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- prettier-ignore -->
|
||||
<ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>
|
||||
</body>
|
||||
</html>
|
||||
552
packages/rrweb/test/integration.test.ts
Normal file
552
packages/rrweb/test/integration.test.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
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 { Suite } from 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { recordOptions, eventWithTime, EventType } from '../src/types';
|
||||
import { visitSnapshot, NodeType } from 'rrweb-snapshot';
|
||||
|
||||
interface ISuite extends Suite {
|
||||
server: http.Server;
|
||||
code: string;
|
||||
browser: puppeteer.Browser;
|
||||
}
|
||||
|
||||
interface IMimeType {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const server = () =>
|
||||
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) {
|
||||
this.timeout(10_000);
|
||||
|
||||
const getHtml = (
|
||||
fileName: string,
|
||||
options: recordOptions<eventWithTime> = {},
|
||||
): string => {
|
||||
const filePath = path.resolve(__dirname, `./html/${fileName}`);
|
||||
const html = fs.readFileSync(filePath, 'utf8');
|
||||
return html.replace(
|
||||
'</body>',
|
||||
`
|
||||
<script>
|
||||
${this.code}
|
||||
window.Date.now = () => new Date(Date.UTC(2018, 10, 15, 8)).valueOf();
|
||||
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>
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
this.server = await server();
|
||||
this.browser = await launchPuppeteer();
|
||||
|
||||
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
|
||||
const pluginsCode = [
|
||||
path.resolve(__dirname, '../dist/plugins/console-record.min.js'),
|
||||
]
|
||||
.map((path) => fs.readFileSync(path, 'utf8'))
|
||||
.join();
|
||||
this.code = fs.readFileSync(bundlePath, 'utf8') + pluginsCode;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await this.browser.close();
|
||||
this.server.close();
|
||||
});
|
||||
|
||||
it('can record form interactions', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'form.html'));
|
||||
|
||||
await page.type('input[type="text"]', 'test');
|
||||
await page.click('input[type="radio"]');
|
||||
await page.click('input[type="checkbox"]');
|
||||
await page.type('textarea', 'textarea test');
|
||||
await page.select('select', '1');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'form');
|
||||
});
|
||||
|
||||
it('can record childList mutations', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const li = document.createElement('li');
|
||||
const ul = document.querySelector('ul') as HTMLUListElement;
|
||||
ul.appendChild(li);
|
||||
document.body.removeChild(ul);
|
||||
const p = document.querySelector('p') as HTMLParagraphElement;
|
||||
p.appendChild(document.createElement('span'));
|
||||
});
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'child-list');
|
||||
});
|
||||
|
||||
it('can record character data muatations', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const li = document.createElement('li');
|
||||
const ul = document.querySelector('ul') as HTMLUListElement;
|
||||
ul.appendChild(li);
|
||||
li.innerText = 'new list item';
|
||||
li.innerText = 'new list item edit';
|
||||
document.body.removeChild(ul);
|
||||
const p = document.querySelector('p') as HTMLParagraphElement;
|
||||
p.innerText = 'mutated';
|
||||
});
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'character-data');
|
||||
});
|
||||
|
||||
it('can record attribute mutation', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const li = document.createElement('li');
|
||||
const ul = document.querySelector('ul') as HTMLUListElement;
|
||||
ul.appendChild(li);
|
||||
li.setAttribute('foo', 'bar');
|
||||
document.body.removeChild(ul);
|
||||
document.body.setAttribute('test', 'true');
|
||||
});
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'attributes');
|
||||
});
|
||||
|
||||
it('can record node mutations', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'select2.html'), {
|
||||
waitUntil: 'networkidle0',
|
||||
});
|
||||
|
||||
// toggle the select box
|
||||
await page.click('.select2-container', { clickCount: 2, delay: 100 });
|
||||
// test storage of !important style
|
||||
await page.evaluate('document.getElementById("select2-drop").setAttribute("style", document.getElementById("select2-drop").style.cssText + "color:black !important")');
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'select2');
|
||||
});
|
||||
|
||||
it('can freeze mutations', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const li = document.createElement('li');
|
||||
const ul = document.querySelector('ul') as HTMLUListElement;
|
||||
ul.appendChild(li);
|
||||
li.setAttribute('foo', 'bar');
|
||||
document.body.setAttribute('test', 'true');
|
||||
});
|
||||
await page.evaluate('rrweb.freezePage()');
|
||||
await page.evaluate(() => {
|
||||
document.body.setAttribute('test', 'bad');
|
||||
const ul = document.querySelector('ul') as HTMLUListElement;
|
||||
const li = document.createElement('li');
|
||||
li.setAttribute('bad-attr', 'bad');
|
||||
li.innerText = 'bad text';
|
||||
ul.appendChild(li);
|
||||
document.body.removeChild(ul);
|
||||
});
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'frozen');
|
||||
});
|
||||
|
||||
it('should not record input events on ignored elements', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'ignore.html'));
|
||||
|
||||
await page.type('.rr-ignore', 'secret');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'ignore');
|
||||
});
|
||||
|
||||
it('should not record input values if maskAllInputs is enabled', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'form.html', { maskAllInputs: true }),
|
||||
);
|
||||
|
||||
await page.type('input[type="text"]', 'test');
|
||||
await page.click('input[type="radio"]');
|
||||
await page.click('input[type="checkbox"]');
|
||||
await page.type('input[type="password"]', 'password');
|
||||
await page.type('textarea', 'textarea test');
|
||||
await page.select('select', '1');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'mask');
|
||||
});
|
||||
|
||||
it('can use maskInputOptions to configure which type of inputs should be masked', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'form.html', {
|
||||
maskInputOptions: {
|
||||
text: false,
|
||||
textarea: false,
|
||||
password: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.type('input[type="text"]', 'test');
|
||||
await page.click('input[type="radio"]');
|
||||
await page.click('input[type="checkbox"]');
|
||||
await page.type('textarea', 'textarea test');
|
||||
await page.type('input[type="password"]', 'password');
|
||||
await page.select('select', '1');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'maskInputOptions');
|
||||
});
|
||||
|
||||
it('should mask value attribute with maskInputOptions', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'password.html', {
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.type('input[type="password"]', 'secr3t');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'maskPassword');
|
||||
});
|
||||
|
||||
it('should record input userTriggered values if userTriggeredOnInput is enabled', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'form.html', { userTriggeredOnInput: true }),
|
||||
);
|
||||
|
||||
await page.type('input[type="text"]', 'test');
|
||||
await page.click('input[type="radio"]');
|
||||
await page.click('input[type="checkbox"]');
|
||||
await page.type('input[type="password"]', 'password');
|
||||
await page.type('textarea', 'textarea test');
|
||||
await page.select('select', '1');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'userTriggered');
|
||||
});
|
||||
|
||||
it('should not record blocked elements and its child nodes', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'block.html'));
|
||||
|
||||
await page.type('input', 'should not be record');
|
||||
await page.evaluate(`document.getElementById('text').innerText = '1'`);
|
||||
await page.click('#text');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'block');
|
||||
});
|
||||
|
||||
it('should record DOM node movement 1', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'move-node.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const div = document.querySelector('div')!;
|
||||
const p = document.querySelector('p')!;
|
||||
const span = document.querySelector('span')!;
|
||||
document.body.removeChild(span);
|
||||
p.appendChild(span);
|
||||
p.removeChild(span);
|
||||
div.appendChild(span);
|
||||
});
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'move-node-1');
|
||||
});
|
||||
|
||||
it('should record DOM node movement 2', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'move-node.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const div = document.createElement('div');
|
||||
const span = document.querySelector('span')!;
|
||||
document.body.appendChild(div);
|
||||
div.appendChild(span);
|
||||
});
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'move-node-2');
|
||||
});
|
||||
|
||||
it('should record dynamic CSS changes', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'react-styled-components.html'));
|
||||
await page.click('.toggle');
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
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.waitForTimeout(50);
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
for (const event of snapshots) {
|
||||
if (event.type === EventType.FullSnapshot) {
|
||||
visitSnapshot(event.data.node, (n) => {
|
||||
if (n.type === NodeType.Element && n.attributes.rr_dataURL) {
|
||||
n.attributes.rr_dataURL = `LOOKS LIKE WE COULD NOT GET STABLE BASE64 FROM SAME IMAGE.`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
assertSnapshot(snapshots, __filename, 'canvas');
|
||||
});
|
||||
|
||||
it('will serialize node before record', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const ul = document.querySelector('ul') as HTMLUListElement;
|
||||
let count = 3;
|
||||
while (count > 0) {
|
||||
count--;
|
||||
const li = document.createElement('li');
|
||||
ul.appendChild(li);
|
||||
}
|
||||
});
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'serialize-before-record');
|
||||
});
|
||||
|
||||
it('will defer missing next node mutation', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'shuffle.html'));
|
||||
|
||||
const text = await page.evaluate(() => {
|
||||
const els = Array.prototype.slice.call(document.querySelectorAll('li'));
|
||||
const parent = document.querySelector('ul')!;
|
||||
parent.removeChild(els[3]);
|
||||
parent.removeChild(els[2]);
|
||||
parent.removeChild(els[1]);
|
||||
parent.removeChild(els[0]);
|
||||
parent.insertBefore(els[3], els[4]);
|
||||
parent.insertBefore(els[2], els[4]);
|
||||
parent.insertBefore(els[1], els[4]);
|
||||
parent.insertBefore(els[0], els[4]);
|
||||
return parent.innerText;
|
||||
});
|
||||
|
||||
expect(text).to.equal('4\n3\n2\n1\n5');
|
||||
});
|
||||
|
||||
it('should record console messages', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'log.html', {
|
||||
plugins: '[rrwebConsoleRecord.getRecordConsolePlugin()]',
|
||||
}),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
console.assert(0 == 0, 'assert');
|
||||
console.count('count');
|
||||
console.countReset('count');
|
||||
console.debug('debug');
|
||||
console.dir('dir');
|
||||
console.dirxml('dirxml');
|
||||
console.group();
|
||||
console.groupCollapsed();
|
||||
console.info('info');
|
||||
console.log('log');
|
||||
console.table('table');
|
||||
console.time();
|
||||
console.timeEnd();
|
||||
console.timeLog();
|
||||
console.trace('trace');
|
||||
console.warn('warn');
|
||||
console.clear();
|
||||
});
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'log');
|
||||
});
|
||||
|
||||
it('should nest record iframe', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto(`http://localhost:3030/html`);
|
||||
await page.setContent(getHtml.call(this, 'main.html'));
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'iframe');
|
||||
});
|
||||
|
||||
it('should record shadow DOM', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'shadow-dom.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const el = document.querySelector('.my-element') as HTMLDivElement;
|
||||
const shadowRoot = el.shadowRoot as ShadowRoot;
|
||||
shadowRoot.appendChild(document.createElement('p'));
|
||||
sleep(1)
|
||||
.then(() => {
|
||||
shadowRoot.lastChild!.appendChild(document.createElement('p'));
|
||||
return sleep(1);
|
||||
})
|
||||
.then(() => {
|
||||
const firstP = shadowRoot.querySelector('p') as HTMLParagraphElement;
|
||||
shadowRoot.removeChild(firstP);
|
||||
return sleep(1);
|
||||
})
|
||||
.then(() => {
|
||||
(shadowRoot.lastChild!.childNodes[0] as HTMLElement).innerText = 'hi';
|
||||
return sleep(1);
|
||||
})
|
||||
.then(() => {
|
||||
(shadowRoot.lastChild!.childNodes[0] as HTMLElement).innerText =
|
||||
'123';
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'shadow-dom');
|
||||
});
|
||||
|
||||
it('should mask texts', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'mask-text.html', {
|
||||
maskTextSelector: '[data-masking="true"]',
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'mask-text');
|
||||
});
|
||||
|
||||
it('should mask texts using maskTextFn', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'mask-text.html', {
|
||||
maskTextSelector: '[data-masking="true"]',
|
||||
maskTextFn: (t: string) => t.replace(/[a-z]/g, '*'),
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'mask-text-fn');
|
||||
});
|
||||
|
||||
it('can mask character data mutations', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const li = document.createElement('li');
|
||||
const ul = document.querySelector('ul') as HTMLUListElement;
|
||||
const p = document.querySelector('p') as HTMLParagraphElement;
|
||||
[li, p].forEach((element) => {
|
||||
element.className = 'rr-mask';
|
||||
});
|
||||
ul.appendChild(li);
|
||||
li.innerText = 'new list item';
|
||||
p.innerText = 'mutated';
|
||||
});
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'mask-character-data');
|
||||
});
|
||||
});
|
||||
48
packages/rrweb/test/machine.test.ts
Normal file
48
packages/rrweb/test/machine.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect } from 'chai';
|
||||
import { discardPriorSnapshots } from '../src/replay/machine';
|
||||
import { sampleEvents } from './utils';
|
||||
import { EventType } from '../src/types';
|
||||
|
||||
const events = sampleEvents.filter(
|
||||
(e) => ![EventType.DomContentLoaded, EventType.Load].includes(e.type),
|
||||
);
|
||||
const nextEvents = events.map((e) => ({
|
||||
...e,
|
||||
timestamp: e.timestamp + 1000,
|
||||
}));
|
||||
const nextNextEvents = nextEvents.map((e) => ({
|
||||
...e,
|
||||
timestamp: e.timestamp + 1000,
|
||||
}));
|
||||
|
||||
describe('get last session', () => {
|
||||
it('will return all the events when there is only one session', () => {
|
||||
expect(discardPriorSnapshots(events, events[0].timestamp)).to.deep.equal(events);
|
||||
});
|
||||
|
||||
it('will return last session when there is more than one in the events', () => {
|
||||
const multiple = events.concat(nextEvents).concat(nextNextEvents);
|
||||
expect(
|
||||
discardPriorSnapshots(
|
||||
multiple,
|
||||
nextNextEvents[nextNextEvents.length - 1].timestamp,
|
||||
),
|
||||
).to.deep.equal(nextNextEvents);
|
||||
});
|
||||
|
||||
it('will return last session when baseline time is future time', () => {
|
||||
const multiple = events.concat(nextEvents).concat(nextNextEvents);
|
||||
expect(
|
||||
discardPriorSnapshots(
|
||||
multiple,
|
||||
nextNextEvents[nextNextEvents.length - 1].timestamp + 1000,
|
||||
),
|
||||
).to.deep.equal(nextNextEvents);
|
||||
});
|
||||
|
||||
it('will return all sessions when baseline time is prior time', () => {
|
||||
expect(discardPriorSnapshots(events, events[0].timestamp - 1000)).to.deep.equal(
|
||||
events,
|
||||
);
|
||||
});
|
||||
});
|
||||
44
packages/rrweb/test/packer.test.ts
Normal file
44
packages/rrweb/test/packer.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from 'chai';
|
||||
import { matchSnapshot } from './utils';
|
||||
import { pack, unpack } from '../src/packer';
|
||||
import { eventWithTime, EventType } from '../src/types';
|
||||
import { MARK } from '../src/packer/base';
|
||||
|
||||
const event: eventWithTime = {
|
||||
type: EventType.DomContentLoaded,
|
||||
data: {},
|
||||
timestamp: new Date('2020-01-01').getTime(),
|
||||
};
|
||||
|
||||
describe('pack', () => {
|
||||
it('can pack event', () => {
|
||||
const packedData = pack(event);
|
||||
const result = matchSnapshot(packedData, __filename, 'pack');
|
||||
expect(result.pass).to.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpack', () => {
|
||||
it('is compatible with unpacked data 1', () => {
|
||||
const result = unpack((event as unknown) as string);
|
||||
expect(result).to.deep.equal(event);
|
||||
});
|
||||
|
||||
it('is compatible with unpacked data 2', () => {
|
||||
const result = unpack(JSON.stringify(event));
|
||||
expect(result).to.deep.equal(event);
|
||||
});
|
||||
|
||||
it('stop on unknown data format', () => {
|
||||
expect(() => unpack('[""]')).to.throw('');
|
||||
});
|
||||
|
||||
it('can unpack packed data', () => {
|
||||
const packedData = pack(event);
|
||||
const result = unpack(packedData);
|
||||
expect(result).to.deep.equal({
|
||||
...event,
|
||||
v: MARK,
|
||||
});
|
||||
});
|
||||
});
|
||||
292
packages/rrweb/test/record.test.ts
Normal file
292
packages/rrweb/test/record.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/* tslint:disable no-console */
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
recordOptions,
|
||||
listenerHandler,
|
||||
eventWithTime,
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
styleSheetRuleData,
|
||||
} from '../src/types';
|
||||
import { assertSnapshot, launchPuppeteer } from './utils';
|
||||
import { Suite } from 'mocha';
|
||||
|
||||
interface ISuite extends Suite {
|
||||
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 = async function (this: ISuite, content: string) {
|
||||
before(async () => {
|
||||
this.browser = await launchPuppeteer();
|
||||
|
||||
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
|
||||
this.code = fs.readFileSync(bundlePath, 'utf8');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(content);
|
||||
await page.evaluate(this.code);
|
||||
this.page = page;
|
||||
this.events = [];
|
||||
await this.page.exposeFunction('emit', (e: eventWithTime) => {
|
||||
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
|
||||
return;
|
||||
}
|
||||
this.events.push(e);
|
||||
});
|
||||
|
||||
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await this.page.close();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await this.browser.close();
|
||||
});
|
||||
};
|
||||
|
||||
describe('record', function (this: ISuite) {
|
||||
this.timeout(10_000);
|
||||
|
||||
setup.call(
|
||||
this,
|
||||
`
|
||||
<html>
|
||||
<body>
|
||||
<input type="text" size="40" />
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
);
|
||||
|
||||
it('will only have one full snapshot without checkout config', async () => {
|
||||
await this.page.evaluate(() => {
|
||||
const { record } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
});
|
||||
});
|
||||
let count = 30;
|
||||
while (count--) {
|
||||
await this.page.type('input', 'a');
|
||||
}
|
||||
await this.page.waitForTimeout(10);
|
||||
expect(this.events.length).to.equal(33);
|
||||
expect(
|
||||
this.events.filter(
|
||||
(event: eventWithTime) => event.type === EventType.Meta,
|
||||
).length,
|
||||
).to.equal(1);
|
||||
expect(
|
||||
this.events.filter(
|
||||
(event: eventWithTime) => event.type === EventType.FullSnapshot,
|
||||
).length,
|
||||
).to.equal(1);
|
||||
});
|
||||
|
||||
it('can checkout full snapshot by count', async () => {
|
||||
await this.page.evaluate(() => {
|
||||
const { record } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
checkoutEveryNth: 10,
|
||||
});
|
||||
});
|
||||
let count = 30;
|
||||
while (count--) {
|
||||
await this.page.type('input', 'a');
|
||||
}
|
||||
await this.page.waitForTimeout(10);
|
||||
expect(this.events.length).to.equal(39);
|
||||
expect(
|
||||
this.events.filter(
|
||||
(event: eventWithTime) => event.type === EventType.Meta,
|
||||
).length,
|
||||
).to.equal(4);
|
||||
expect(
|
||||
this.events.filter(
|
||||
(event: eventWithTime) => event.type === EventType.FullSnapshot,
|
||||
).length,
|
||||
).to.equal(4);
|
||||
expect(this.events[1].type).to.equal(EventType.FullSnapshot);
|
||||
expect(this.events[13].type).to.equal(EventType.FullSnapshot);
|
||||
expect(this.events[25].type).to.equal(EventType.FullSnapshot);
|
||||
expect(this.events[37].type).to.equal(EventType.FullSnapshot);
|
||||
});
|
||||
|
||||
it('can checkout full snapshot by time', async () => {
|
||||
await this.page.evaluate(() => {
|
||||
const { record } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
checkoutEveryNms: 500,
|
||||
});
|
||||
});
|
||||
let count = 30;
|
||||
while (count--) {
|
||||
await this.page.type('input', 'a');
|
||||
}
|
||||
await this.page.waitForTimeout(300);
|
||||
expect(this.events.length).to.equal(33); // before first automatic snapshot
|
||||
await this.page.waitForTimeout(200); // could be 33 or 35 events by now depending on speed of test env
|
||||
await this.page.type('input', 'a');
|
||||
await this.page.waitForTimeout(10);
|
||||
expect(this.events.length).to.equal(36); // additionally includes the 2 checkout events
|
||||
expect(
|
||||
this.events.filter(
|
||||
(event: eventWithTime) => event.type === EventType.Meta,
|
||||
).length,
|
||||
).to.equal(2);
|
||||
expect(
|
||||
this.events.filter(
|
||||
(event: eventWithTime) => event.type === EventType.FullSnapshot,
|
||||
).length,
|
||||
).to.equal(2);
|
||||
expect(this.events[1].type).to.equal(EventType.FullSnapshot);
|
||||
expect(this.events[35].type).to.equal(EventType.FullSnapshot);
|
||||
});
|
||||
|
||||
it('is safe to checkout during async callbacks', async () => {
|
||||
await this.page.evaluate(() => {
|
||||
const { record } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
checkoutEveryNth: 2,
|
||||
});
|
||||
const p = document.createElement('p');
|
||||
const span = document.createElement('span');
|
||||
setTimeout(() => {
|
||||
document.body.appendChild(p);
|
||||
p.appendChild(span);
|
||||
document.body.removeChild(document.querySelector('input')!);
|
||||
}, 0);
|
||||
setTimeout(() => {
|
||||
span.innerText = 'test';
|
||||
}, 10);
|
||||
setTimeout(() => {
|
||||
p.removeChild(span);
|
||||
document.body.appendChild(span);
|
||||
}, 10);
|
||||
});
|
||||
await this.page.waitForTimeout(100);
|
||||
assertSnapshot(this.events, __filename, 'async-checkout');
|
||||
});
|
||||
|
||||
it('can add custom event', async () => {
|
||||
await this.page.evaluate(() => {
|
||||
const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
});
|
||||
addCustomEvent<number>('tag1', 1);
|
||||
addCustomEvent<{ a: string }>('tag2', {
|
||||
a: 'b',
|
||||
});
|
||||
});
|
||||
await this.page.waitForTimeout(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 = <CSSStyleSheet>styleElement.sheet;
|
||||
const ruleIdx0 = styleSheet.insertRule('body { background: #000; }');
|
||||
const ruleIdx1 = styleSheet.insertRule('body { background: #111; }');
|
||||
styleSheet.deleteRule(ruleIdx1);
|
||||
setTimeout(() => {
|
||||
styleSheet.insertRule('body { color: #fff; }');
|
||||
}, 0);
|
||||
setTimeout(() => {
|
||||
styleSheet.deleteRule(ruleIdx0);
|
||||
}, 5);
|
||||
setTimeout(() => {
|
||||
styleSheet.insertRule('body { color: #ccc; }');
|
||||
}, 10);
|
||||
});
|
||||
await this.page.waitForTimeout(50);
|
||||
const styleSheetRuleEvents = this.events.filter(
|
||||
(e) =>
|
||||
e.type === EventType.IncrementalSnapshot &&
|
||||
e.data.source === IncrementalSource.StyleSheetRule,
|
||||
);
|
||||
const addRuleCount = styleSheetRuleEvents.filter((e) =>
|
||||
Boolean((e.data as styleSheetRuleData).adds),
|
||||
).length;
|
||||
const removeRuleCount = styleSheetRuleEvents.filter((e) =>
|
||||
Boolean((e.data as styleSheetRuleData).removes),
|
||||
).length;
|
||||
// sync insert/delete should be ignored
|
||||
expect(addRuleCount).to.equal(2);
|
||||
expect(removeRuleCount).to.equal(1);
|
||||
assertSnapshot(this.events, __filename, 'stylesheet-rules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('record iframes', function (this: ISuite) {
|
||||
this.timeout(10_000);
|
||||
|
||||
setup.call(
|
||||
this,
|
||||
`
|
||||
<html>
|
||||
<body>
|
||||
<iframe srcdoc="<button>Mysterious Button</button>" />
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
);
|
||||
|
||||
it('captures iframe content in correct order', async () => {
|
||||
await this.page.evaluate(() => {
|
||||
const { record } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
});
|
||||
});
|
||||
await this.page.waitForTimeout(10);
|
||||
// console.log(JSON.stringify(this.events));
|
||||
|
||||
expect(this.events.length).to.equal(3);
|
||||
const eventTypes = this.events
|
||||
.filter(
|
||||
(e) =>
|
||||
e.type === EventType.IncrementalSnapshot ||
|
||||
e.type === EventType.FullSnapshot,
|
||||
)
|
||||
.map((e) => e.type);
|
||||
expect(eventTypes).to.have.ordered.members([
|
||||
EventType.FullSnapshot,
|
||||
EventType.IncrementalSnapshot,
|
||||
]);
|
||||
});
|
||||
});
|
||||
113
packages/rrweb/test/replay/virtual-styles.test.ts
Normal file
113
packages/rrweb/test/replay/virtual-styles.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect } from 'chai';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import {
|
||||
applyVirtualStyleRulesToNode,
|
||||
StyleRuleType,
|
||||
VirtualStyleRules,
|
||||
} from '../../src/replay/virtual-styles';
|
||||
|
||||
describe('virtual styles', () => {
|
||||
describe('applyVirtualStyleRulesToNode', () => {
|
||||
it('should insert rule at index 0 in empty sheet', () => {
|
||||
const dom = new JSDOM(`
|
||||
<style></style>
|
||||
`);
|
||||
const styleEl = dom.window.document.getElementsByTagName('style')[0];
|
||||
|
||||
const cssText = '.added-rule {border: 1px solid yellow;}';
|
||||
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ cssText, index: 0, type: StyleRuleType.Insert },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).to.equal(1);
|
||||
expect(styleEl.sheet?.cssRules[0].cssText).to.equal(cssText);
|
||||
});
|
||||
|
||||
it('should insert rule at index 0 and keep exsisting rules', () => {
|
||||
const dom = new JSDOM(`
|
||||
<style>
|
||||
a {color: blue}
|
||||
div {color: black}
|
||||
</style>
|
||||
`);
|
||||
const styleEl = dom.window.document.getElementsByTagName('style')[0];
|
||||
|
||||
const cssText = '.added-rule {border: 1px solid yellow;}';
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ cssText, index: 0, type: StyleRuleType.Insert },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).to.equal(3);
|
||||
expect(styleEl.sheet?.cssRules[0].cssText).to.equal(cssText);
|
||||
});
|
||||
|
||||
it('should delete rule at index 1', () => {
|
||||
const dom = new JSDOM(`
|
||||
<style>
|
||||
a {color: blue;}
|
||||
div {color: black;}
|
||||
</style>
|
||||
`);
|
||||
const styleEl = dom.window.document.getElementsByTagName('style')[0];
|
||||
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ index: 0, type: StyleRuleType.Remove },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).to.equal(1);
|
||||
expect(styleEl.sheet?.cssRules[0].cssText).to.equal(
|
||||
'div {color: black;}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should restore a snapshot by inserting missing rules', () => {
|
||||
const dom = new JSDOM(`
|
||||
<style>
|
||||
a {color: blue;}
|
||||
.deleted-rule {color: pink;}
|
||||
div {color: black;}
|
||||
</style>
|
||||
`);
|
||||
const styleEl = dom.window.document.getElementsByTagName('style')[0];
|
||||
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{
|
||||
cssTexts: ['a {color: blue;}', 'div {color: black;}'],
|
||||
type: StyleRuleType.Snapshot,
|
||||
},
|
||||
];
|
||||
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).to.equal(2);
|
||||
});
|
||||
|
||||
it('should restore a snapshot by fixing order of rules', () => {
|
||||
const dom = new JSDOM(`
|
||||
<style>
|
||||
div {color: black;}
|
||||
a {color: blue;}
|
||||
</style>
|
||||
`);
|
||||
const styleEl = dom.window.document.getElementsByTagName('style')[0];
|
||||
|
||||
const cssTexts = ['a {color: blue;}', 'div {color: black;}'];
|
||||
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{
|
||||
cssTexts,
|
||||
type: StyleRuleType.Snapshot,
|
||||
},
|
||||
];
|
||||
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).to.equal(2);
|
||||
expect(
|
||||
Array.from(styleEl.sheet?.cssRules || []).map((rule) => rule.cssText),
|
||||
).to.have.ordered.members(cssTexts);
|
||||
});
|
||||
});
|
||||
});
|
||||
250
packages/rrweb/test/replayer.test.ts
Normal file
250
packages/rrweb/test/replayer.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/* tslint:disable no-string-literal no-console */
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import { expect } from 'chai';
|
||||
import { Suite } from 'mocha';
|
||||
import {
|
||||
assertDomSnapshot,
|
||||
launchPuppeteer,
|
||||
sampleEvents as events,
|
||||
sampleStyleSheetRemoveEvents as stylesheetRemoveEvents,
|
||||
} from './utils';
|
||||
import styleSheetRuleEvents from './events/style-sheet-rule-events';
|
||||
|
||||
interface ISuite extends Suite {
|
||||
code: string;
|
||||
browser: puppeteer.Browser;
|
||||
page: puppeteer.Page;
|
||||
}
|
||||
|
||||
describe('replayer', function (this: ISuite) {
|
||||
this.timeout(10_000);
|
||||
|
||||
before(async () => {
|
||||
this.browser = await launchPuppeteer();
|
||||
|
||||
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
|
||||
this.code = fs.readFileSync(bundlePath, 'utf8');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.evaluate(this.code);
|
||||
await page.evaluate(`let events = ${JSON.stringify(events)}`);
|
||||
this.page = page;
|
||||
|
||||
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await this.page.close();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await this.browser.close();
|
||||
});
|
||||
|
||||
it('can get meta data', async () => {
|
||||
const meta = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.getMetaData();
|
||||
`);
|
||||
expect(meta).to.deep.equal({
|
||||
startTime: events[0].timestamp,
|
||||
endTime: events[events.length - 1].timestamp,
|
||||
totalTime: events[events.length - 1].timestamp - events[0].timestamp,
|
||||
});
|
||||
});
|
||||
|
||||
it('will start actions when play', async () => {
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play();
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
expect(actionLength).to.equal(events.length);
|
||||
});
|
||||
|
||||
it('will clean actions when pause', async () => {
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play();
|
||||
replayer.pause();
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
expect(actionLength).to.equal(0);
|
||||
});
|
||||
|
||||
it('can play at any time offset', async () => {
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play(1500);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
expect(actionLength).to.equal(
|
||||
events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length,
|
||||
);
|
||||
});
|
||||
|
||||
it('can play a second time in the future', async () => {
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play(500);
|
||||
replayer.play(1500);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
expect(actionLength).to.equal(
|
||||
events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length,
|
||||
);
|
||||
});
|
||||
|
||||
it('can play a second time to the past', async () => {
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play(1500);
|
||||
replayer.play(500);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
expect(actionLength).to.equal(
|
||||
events.filter((e) => e.timestamp - events[0].timestamp >= 500).length,
|
||||
);
|
||||
});
|
||||
|
||||
it('can pause at any time offset', async () => {
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.pause(2500);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
const currentTime = await this.page.evaluate(`
|
||||
replayer.getCurrentTime();
|
||||
`);
|
||||
const currentState = await this.page.evaluate(`
|
||||
replayer['service']['state']['value'];
|
||||
`);
|
||||
expect(actionLength).to.equal(0);
|
||||
expect(currentTime).to.equal(2500);
|
||||
expect(currentState).to.equal('paused');
|
||||
});
|
||||
|
||||
it('can fast forward past StyleSheetRule changes on virtual elements', async () => {
|
||||
await this.page.evaluate(
|
||||
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
|
||||
);
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play(1500);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
|
||||
expect(actionLength).to.equal(
|
||||
styleSheetRuleEvents.filter(
|
||||
(e) => e.timestamp - styleSheetRuleEvents[0].timestamp >= 1500,
|
||||
).length,
|
||||
);
|
||||
|
||||
await assertDomSnapshot(
|
||||
this.page,
|
||||
__filename,
|
||||
'style-sheet-rule-events-play-at-1500',
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply fast forwarded StyleSheetRules that where added', async () => {
|
||||
await this.page.evaluate(
|
||||
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
|
||||
);
|
||||
const result = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.pause(1500);
|
||||
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
|
||||
(sheet) => [...sheet.rules],
|
||||
).flat();
|
||||
rules.some((x) => x.selectorText === '.css-added-at-1000-deleted-at-2500');
|
||||
`);
|
||||
|
||||
expect(result).to.equal(true);
|
||||
});
|
||||
|
||||
it('can handle removing style elements', async () => {
|
||||
await this.page.evaluate(
|
||||
`events = ${JSON.stringify(stylesheetRemoveEvents)}`,
|
||||
);
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play(2500);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
expect(actionLength).to.equal(
|
||||
stylesheetRemoveEvents.filter(
|
||||
(e) => e.timestamp - stylesheetRemoveEvents[0].timestamp >= 2500,
|
||||
).length,
|
||||
);
|
||||
|
||||
await assertDomSnapshot(
|
||||
this.page,
|
||||
__filename,
|
||||
'style-sheet-remove-events-play-at-2500',
|
||||
);
|
||||
});
|
||||
|
||||
it('can fast forward past StyleSheetRule deletion on virtual elements', async () => {
|
||||
await this.page.evaluate(
|
||||
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
|
||||
);
|
||||
const actionLength = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.play(2500);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
|
||||
await assertDomSnapshot(
|
||||
this.page,
|
||||
__filename,
|
||||
'style-sheet-rule-events-play-at-2500',
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete fast forwarded StyleSheetRules that where removed', async () => {
|
||||
await this.page.evaluate(
|
||||
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
|
||||
);
|
||||
const result = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.pause(3000);
|
||||
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
|
||||
(sheet) => [...sheet.rules],
|
||||
).flat();
|
||||
rules.some((x) => x.selectorText === '.css-added-at-1000-deleted-at-2500');
|
||||
`);
|
||||
|
||||
expect(result).to.equal(false);
|
||||
});
|
||||
|
||||
it('can stream events in live mode', async () => {
|
||||
const status = await this.page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events, {
|
||||
liveMode: true
|
||||
});
|
||||
replayer.startLive();
|
||||
replayer.service.state.value;
|
||||
`);
|
||||
expect(status).to.equal('live');
|
||||
});
|
||||
});
|
||||
366
packages/rrweb/test/utils.ts
Normal file
366
packages/rrweb/test/utils.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { SnapshotState, toMatchSnapshot } from 'jest-snapshot';
|
||||
import { NodeType } from 'rrweb-snapshot';
|
||||
import { assert } from 'chai';
|
||||
import {
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
eventWithTime,
|
||||
MouseInteractions,
|
||||
} from '../src/types';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import { format } from 'prettier';
|
||||
|
||||
export async function launchPuppeteer() {
|
||||
return await puppeteer.launch({
|
||||
headless: process.env.PUPPETEER_HEADLESS ? true : false,
|
||||
defaultViewport: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
args: ['--no-sandbox'],
|
||||
});
|
||||
}
|
||||
|
||||
export function matchSnapshot(
|
||||
actual: string,
|
||||
testFile: string,
|
||||
testTitle: string,
|
||||
) {
|
||||
const snapshotState = new SnapshotState(testFile, {
|
||||
updateSnapshot: process.env.SNAPSHOT_UPDATE ? 'all' : 'new',
|
||||
});
|
||||
|
||||
const matcher = toMatchSnapshot.bind({
|
||||
snapshotState,
|
||||
currentTestName: testTitle,
|
||||
});
|
||||
const result = matcher(actual);
|
||||
snapshotState.save();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puppeteer may cast random mouse move which make our tests flaky.
|
||||
* So we only do snapshot test with filtered events.
|
||||
* Also remove timestamp from event.
|
||||
* @param snapshots incrementalSnapshotEvent[]
|
||||
*/
|
||||
function stringifySnapshots(snapshots: eventWithTime[]): string {
|
||||
return JSON.stringify(
|
||||
snapshots
|
||||
.filter((s) => {
|
||||
if (
|
||||
s.type === EventType.IncrementalSnapshot &&
|
||||
s.data.source === IncrementalSource.MouseMove
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((s) => {
|
||||
if (s.type === EventType.Meta) {
|
||||
s.data.href = 'about:blank';
|
||||
}
|
||||
// FIXME: travis coordinates seems different with my laptop
|
||||
const coordinatesReg = /(bottom|top|left|right|width|height): \d+(\.\d+)?px/g;
|
||||
if (
|
||||
s.type === EventType.IncrementalSnapshot &&
|
||||
s.data.source === IncrementalSource.MouseInteraction
|
||||
) {
|
||||
delete s.data.x;
|
||||
delete s.data.y;
|
||||
}
|
||||
if (
|
||||
s.type === EventType.IncrementalSnapshot &&
|
||||
s.data.source === IncrementalSource.Mutation
|
||||
) {
|
||||
s.data.attributes.forEach((a) => {
|
||||
if (
|
||||
'style' in a.attributes &&
|
||||
a.attributes.style &&
|
||||
typeof a.attributes.style === 'object'
|
||||
) {
|
||||
for (const [k, v] of Object.entries(a.attributes.style)) {
|
||||
if (Array.isArray(v)) {
|
||||
if (coordinatesReg.test(k + ': ' + v[0])) {
|
||||
// TODO: could round the number here instead depending on what's coming out of various test envs
|
||||
a.attributes.style[k] = ['Npx', v[1]];
|
||||
}
|
||||
} else if (typeof v === 'string') {
|
||||
if (coordinatesReg.test(k + ': ' + v)) {
|
||||
a.attributes.style[k] = 'Npx';
|
||||
}
|
||||
}
|
||||
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
|
||||
}
|
||||
}
|
||||
});
|
||||
s.data.adds.forEach((add) => {
|
||||
if (
|
||||
add.node.type === NodeType.Element &&
|
||||
'style' in add.node.attributes &&
|
||||
typeof add.node.attributes.style === 'string' &&
|
||||
coordinatesReg.test(add.node.attributes.style)
|
||||
) {
|
||||
add.node.attributes.style = add.node.attributes.style.replace(
|
||||
coordinatesReg,
|
||||
'$1: Npx',
|
||||
);
|
||||
}
|
||||
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
|
||||
});
|
||||
}
|
||||
delete s.timestamp;
|
||||
return s;
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyDomSnapshot(mhtml: string): string {
|
||||
const { Parser } = require('fast-mhtml');
|
||||
const resources: string[] = [];
|
||||
const p = new Parser({
|
||||
rewriteFn: (filename: string): string => {
|
||||
const index = resources.indexOf(filename);
|
||||
const prefix = /^\w+/.exec(filename);
|
||||
if (index !== -1) {
|
||||
return `file-${prefix}-${index}`;
|
||||
} else {
|
||||
return `file-${prefix}-${resources.push(filename) - 1}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = p
|
||||
.parse(mhtml) // parse file
|
||||
.rewrite() // rewrite all links
|
||||
.spit(); // return all contents
|
||||
|
||||
const newResult: { filename: string; content: string }[] = result.map(
|
||||
(asset: { filename: string; content: string }) => {
|
||||
let { filename, content } = asset;
|
||||
let res: string | undefined;
|
||||
if (filename.includes('frame')) {
|
||||
res = format(content, {
|
||||
parser: 'html',
|
||||
});
|
||||
}
|
||||
return { filename, content: res || content };
|
||||
},
|
||||
);
|
||||
return newResult.map((asset) => Object.values(asset).join('\n')).join('\n\n');
|
||||
}
|
||||
|
||||
export function assertSnapshot(
|
||||
snapshots: eventWithTime[],
|
||||
filename: string,
|
||||
name: string,
|
||||
) {
|
||||
const result = matchSnapshot(stringifySnapshots(snapshots), filename, name);
|
||||
assert(result.pass, result.pass ? '' : result.report());
|
||||
}
|
||||
|
||||
export async function assertDomSnapshot(
|
||||
page: puppeteer.Page,
|
||||
filename: string,
|
||||
name: string,
|
||||
) {
|
||||
const cdp = await page.target().createCDPSession();
|
||||
const { data } = await cdp.send('Page.captureSnapshot', {
|
||||
format: 'mhtml',
|
||||
});
|
||||
|
||||
const result = matchSnapshot(stringifyDomSnapshot(data), filename, name);
|
||||
assert(result.pass, result.pass ? '' : result.report());
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
export const sampleEvents: eventWithTime[] = [
|
||||
{
|
||||
type: EventType.DomContentLoaded,
|
||||
data: {},
|
||||
timestamp: now,
|
||||
},
|
||||
{
|
||||
type: EventType.Load,
|
||||
data: {},
|
||||
timestamp: now + 1000,
|
||||
},
|
||||
{
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
href: 'http://localhost',
|
||||
width: 1000,
|
||||
height: 800,
|
||||
},
|
||||
timestamp: now + 1000,
|
||||
},
|
||||
{
|
||||
type: EventType.FullSnapshot,
|
||||
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: [],
|
||||
id: 4,
|
||||
},
|
||||
],
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
id: 1,
|
||||
},
|
||||
initialOffset: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
},
|
||||
timestamp: now + 1000,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.MouseInteraction,
|
||||
type: MouseInteractions.Click,
|
||||
id: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
timestamp: now + 2000,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.MouseInteraction,
|
||||
type: MouseInteractions.Click,
|
||||
id: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
timestamp: now + 3000,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.MouseInteraction,
|
||||
type: MouseInteractions.Click,
|
||||
id: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
timestamp: now + 4000,
|
||||
},
|
||||
];
|
||||
|
||||
export const sampleStyleSheetRemoveEvents: eventWithTime[] = [
|
||||
{
|
||||
type: EventType.DomContentLoaded,
|
||||
data: {},
|
||||
timestamp: now,
|
||||
},
|
||||
{
|
||||
type: EventType.Load,
|
||||
data: {},
|
||||
timestamp: now + 1000,
|
||||
},
|
||||
{
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
href: 'http://localhost',
|
||||
width: 1000,
|
||||
height: 800,
|
||||
},
|
||||
timestamp: now + 1000,
|
||||
},
|
||||
{
|
||||
type: EventType.FullSnapshot,
|
||||
data: {
|
||||
node: {
|
||||
type: 0,
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'html',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'style',
|
||||
attributes: {
|
||||
'data-jss': '',
|
||||
'data-meta': 'OverlayDrawer',
|
||||
_cssText:
|
||||
'.OverlayDrawer-modal-187 { }.OverlayDrawer-paper-188 { width: 100%; }@media (min-width: 48em) {\n .OverlayDrawer-paper-188 { width: 38rem; }\n}@media (min-width: 48em) {\n}@media (min-width: 48em) {\n}',
|
||||
},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n',
|
||||
isStyle: true,
|
||||
id: 5,
|
||||
},
|
||||
],
|
||||
id: 4,
|
||||
},
|
||||
],
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 6,
|
||||
},
|
||||
],
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
id: 1,
|
||||
},
|
||||
initialOffset: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
},
|
||||
timestamp: now + 1000,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Mutation,
|
||||
texts: [],
|
||||
attributes: [],
|
||||
removes: [
|
||||
{
|
||||
parentId: 3,
|
||||
id: 4,
|
||||
},
|
||||
],
|
||||
adds: [],
|
||||
},
|
||||
timestamp: now + 2000,
|
||||
},
|
||||
];
|
||||
22
packages/rrweb/tsconfig.json
Normal file
22
packages/rrweb/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES5",
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "build",
|
||||
"lib": ["es6", "dom"],
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"exclude": ["test"],
|
||||
"include": [
|
||||
"src",
|
||||
"test.d.ts",
|
||||
"node_modules/@types/css-font-loading-module/index.d.ts"
|
||||
]
|
||||
}
|
||||
27
packages/rrweb/tslint.json
Normal file
27
packages/rrweb/tslint.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"defaultSeverity": "error",
|
||||
"extends": ["tslint:recommended"],
|
||||
"jsRules": {},
|
||||
"rules": {
|
||||
"no-any": true,
|
||||
"quotemark": [true, "single"],
|
||||
"ordered-imports": false,
|
||||
"object-literal-sort-keys": false,
|
||||
"no-unused-variable": true,
|
||||
"object-literal-key-quotes": false,
|
||||
"variable-name": [
|
||||
true,
|
||||
"ban-keywords",
|
||||
"check-format",
|
||||
"allow-leading-underscore"
|
||||
],
|
||||
"arrow-parens": false,
|
||||
"only-arrow-functions": false,
|
||||
"max-line-length": false,
|
||||
"no-empty": false,
|
||||
"max-classes-per-file": false,
|
||||
"semicolon": false,
|
||||
"trailing-comma": false
|
||||
},
|
||||
"rulesDirectory": []
|
||||
}
|
||||
2
packages/rrweb/typings/boost.d.ts
vendored
Normal file
2
packages/rrweb/typings/boost.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './index';
|
||||
export * from './packer';
|
||||
4
packages/rrweb/typings/entries/all.d.ts
vendored
Normal file
4
packages/rrweb/typings/entries/all.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from '../index';
|
||||
export * from '../packer';
|
||||
export * from '../plugins/console/record';
|
||||
export * from '../plugins/console/replay';
|
||||
2
packages/rrweb/typings/entries/record-pack.d.ts
vendored
Normal file
2
packages/rrweb/typings/entries/record-pack.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../record/index';
|
||||
export * from '../packer/pack';
|
||||
2
packages/rrweb/typings/entries/replay-unpack.d.ts
vendored
Normal file
2
packages/rrweb/typings/entries/replay-unpack.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../replay';
|
||||
export * from '../packer/unpack';
|
||||
8
packages/rrweb/typings/index.d.ts
vendored
Normal file
8
packages/rrweb/typings/index.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import record from './record';
|
||||
import { Replayer } from './replay';
|
||||
import { _mirror } from './utils';
|
||||
import * as utils from './utils';
|
||||
export { EventType, IncrementalSource, MouseInteractions, ReplayerEvents, } from './types';
|
||||
declare const addCustomEvent: <T>(tag: string, payload: T) => void;
|
||||
declare const freezePage: () => void;
|
||||
export { record, addCustomEvent, freezePage, Replayer, _mirror as mirror, utils, };
|
||||
7
packages/rrweb/typings/packer/base.d.ts
vendored
Normal file
7
packages/rrweb/typings/packer/base.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { eventWithTime } from '../types';
|
||||
export declare type PackFn = (event: eventWithTime) => string;
|
||||
export declare type UnpackFn = (raw: string) => eventWithTime;
|
||||
export declare type eventWithTimeAndPacker = eventWithTime & {
|
||||
v: string;
|
||||
};
|
||||
export declare const MARK = "v1";
|
||||
2
packages/rrweb/typings/packer/index.d.ts
vendored
Normal file
2
packages/rrweb/typings/packer/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export { pack } from './pack';
|
||||
export { unpack } from './unpack';
|
||||
2
packages/rrweb/typings/packer/pack.d.ts
vendored
Normal file
2
packages/rrweb/typings/packer/pack.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { PackFn } from './base';
|
||||
export declare const pack: PackFn;
|
||||
2
packages/rrweb/typings/packer/unpack.d.ts
vendored
Normal file
2
packages/rrweb/typings/packer/unpack.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { UnpackFn } from './base';
|
||||
export declare const unpack: UnpackFn;
|
||||
37
packages/rrweb/typings/plugins/console/record/error-stack-parser.d.ts
vendored
Normal file
37
packages/rrweb/typings/plugins/console/record/error-stack-parser.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
export declare class StackFrame {
|
||||
private fileName;
|
||||
private functionName;
|
||||
private lineNumber?;
|
||||
private columnNumber?;
|
||||
constructor(obj: {
|
||||
fileName?: string;
|
||||
functionName?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
});
|
||||
toString(): string;
|
||||
}
|
||||
export declare const ErrorStackParser: {
|
||||
parse: (error: Error) => StackFrame[];
|
||||
extractLocation: (urlLike: string) => (string | undefined)[];
|
||||
parseV8OrIE: (error: {
|
||||
stack: string;
|
||||
}) => StackFrame[];
|
||||
parseFFOrSafari: (error: {
|
||||
stack: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera: (e: {
|
||||
stacktrace?: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera9: (e: {
|
||||
message: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera10: (e: {
|
||||
stacktrace: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera11: (error: {
|
||||
stack: string;
|
||||
}) => StackFrame[];
|
||||
};
|
||||
41
packages/rrweb/typings/plugins/console/record/index.d.ts
vendored
Normal file
41
packages/rrweb/typings/plugins/console/record/index.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
import { RecordPlugin } from '../../../types';
|
||||
export declare type StringifyOptions = {
|
||||
stringLengthLimit?: number;
|
||||
numOfKeysLimit: number;
|
||||
};
|
||||
declare type LogRecordOptions = {
|
||||
level?: LogLevel[] | undefined;
|
||||
lengthThreshold?: number;
|
||||
stringifyOptions?: StringifyOptions;
|
||||
logger?: Logger;
|
||||
};
|
||||
export declare type LogData = {
|
||||
level: LogLevel;
|
||||
trace: string[];
|
||||
payload: string[];
|
||||
};
|
||||
export declare type LogLevel = 'assert' | 'clear' | 'count' | 'countReset' | 'debug' | 'dir' | 'dirxml' | 'error' | 'group' | 'groupCollapsed' | 'groupEnd' | 'info' | 'log' | 'table' | 'time' | 'timeEnd' | 'timeLog' | 'trace' | 'warn';
|
||||
export declare type Logger = {
|
||||
assert?: typeof console.assert;
|
||||
clear?: typeof console.clear;
|
||||
count?: typeof console.count;
|
||||
countReset?: typeof console.countReset;
|
||||
debug?: typeof console.debug;
|
||||
dir?: typeof console.dir;
|
||||
dirxml?: typeof console.dirxml;
|
||||
error?: typeof console.error;
|
||||
group?: typeof console.group;
|
||||
groupCollapsed?: typeof console.groupCollapsed;
|
||||
groupEnd?: () => void;
|
||||
info?: typeof console.info;
|
||||
log?: typeof console.log;
|
||||
table?: typeof console.table;
|
||||
time?: typeof console.time;
|
||||
timeEnd?: typeof console.timeEnd;
|
||||
timeLog?: typeof console.timeLog;
|
||||
trace?: typeof console.trace;
|
||||
warn?: typeof console.warn;
|
||||
};
|
||||
export declare const PLUGIN_NAME = "rrweb/console@1";
|
||||
export declare const getRecordConsolePlugin: (options?: LogRecordOptions) => RecordPlugin;
|
||||
export {};
|
||||
2
packages/rrweb/typings/plugins/console/record/stringify.d.ts
vendored
Normal file
2
packages/rrweb/typings/plugins/console/record/stringify.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { StringifyOptions } from './index';
|
||||
export declare function stringify(obj: any, stringifyOptions?: StringifyOptions): string;
|
||||
9
packages/rrweb/typings/plugins/console/replay/index.d.ts
vendored
Normal file
9
packages/rrweb/typings/plugins/console/replay/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { LogLevel, LogData } from '../record';
|
||||
import { ReplayPlugin } from '../../../types';
|
||||
declare type ReplayLogger = Partial<Record<LogLevel, (data: LogData) => void>>;
|
||||
declare type LogReplayConfig = {
|
||||
level?: LogLevel[] | undefined;
|
||||
replayLogger: ReplayLogger | undefined;
|
||||
};
|
||||
export declare const getReplayConsolePlugin: (options?: LogReplayConfig) => ReplayPlugin;
|
||||
export {};
|
||||
4
packages/rrweb/typings/record/collection.d.ts
vendored
Normal file
4
packages/rrweb/typings/record/collection.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import { removedNodeMutation } from '../types';
|
||||
export declare function deepDelete(addsSet: Set<Node>, n: Node): void;
|
||||
export declare function isParentRemoved(removes: removedNodeMutation[], n: Node): boolean;
|
||||
export declare function isAncestorInSet(set: Set<Node>, n: Node): boolean;
|
||||
37
packages/rrweb/typings/record/error-stack-parser.d.ts
vendored
Normal file
37
packages/rrweb/typings/record/error-stack-parser.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
export declare class StackFrame {
|
||||
private fileName;
|
||||
private functionName;
|
||||
private lineNumber?;
|
||||
private columnNumber?;
|
||||
constructor(obj: {
|
||||
fileName?: string;
|
||||
functionName?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
});
|
||||
toString(): string;
|
||||
}
|
||||
export declare const ErrorStackParser: {
|
||||
parse: (error: Error) => StackFrame[];
|
||||
extractLocation: (urlLike: string) => (string | undefined)[];
|
||||
parseV8OrIE: (error: {
|
||||
stack: string;
|
||||
}) => StackFrame[];
|
||||
parseFFOrSafari: (error: {
|
||||
stack: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera: (e: {
|
||||
stacktrace?: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera9: (e: {
|
||||
message: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera10: (e: {
|
||||
stacktrace: string;
|
||||
}) => StackFrame[];
|
||||
parseOpera11: (error: {
|
||||
stack: string;
|
||||
}) => StackFrame[];
|
||||
};
|
||||
13
packages/rrweb/typings/record/iframe-manager.d.ts
vendored
Normal file
13
packages/rrweb/typings/record/iframe-manager.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import { serializedNodeWithId, INode } from 'rrweb-snapshot';
|
||||
import { mutationCallBack } from '../types';
|
||||
export declare class IframeManager {
|
||||
private iframes;
|
||||
private mutationCb;
|
||||
private loadListener?;
|
||||
constructor(options: {
|
||||
mutationCb: mutationCallBack;
|
||||
});
|
||||
addIframe(iframeEl: HTMLIFrameElement): void;
|
||||
addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown): void;
|
||||
attachIframe(iframeEl: INode, childSn: serializedNodeWithId): void;
|
||||
}
|
||||
9
packages/rrweb/typings/record/index.d.ts
vendored
Normal file
9
packages/rrweb/typings/record/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { eventWithTime, recordOptions, listenerHandler } from '../types';
|
||||
declare function record<T = eventWithTime>(options?: recordOptions<T>): listenerHandler | undefined;
|
||||
declare namespace record {
|
||||
var addCustomEvent: <T>(tag: string, payload: T) => void;
|
||||
var freezePage: () => void;
|
||||
var takeFullSnapshot: (isCheckout?: boolean | undefined) => void;
|
||||
var mirror: import("../types").Mirror;
|
||||
}
|
||||
export default record;
|
||||
41
packages/rrweb/typings/record/mutation.d.ts
vendored
Normal file
41
packages/rrweb/typings/record/mutation.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
import { MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn } from 'rrweb-snapshot';
|
||||
import { mutationRecord, blockClass, maskTextClass, mutationCallBack, Mirror } from '../types';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
import { ShadowDomManager } from './shadow-dom-manager';
|
||||
export default class MutationBuffer {
|
||||
private frozen;
|
||||
private locked;
|
||||
private texts;
|
||||
private attributes;
|
||||
private removes;
|
||||
private mapRemoves;
|
||||
private movedMap;
|
||||
private addedSet;
|
||||
private movedSet;
|
||||
private droppedSet;
|
||||
private emissionCallback;
|
||||
private blockClass;
|
||||
private blockSelector;
|
||||
private maskTextClass;
|
||||
private maskTextSelector;
|
||||
private inlineStylesheet;
|
||||
private maskInputOptions;
|
||||
private maskTextFn;
|
||||
private maskInputFn;
|
||||
private recordCanvas;
|
||||
private slimDOMOptions;
|
||||
private doc;
|
||||
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, slimDOMOptions: SlimDOMOptions, doc: Document, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager): void;
|
||||
freeze(): void;
|
||||
unfreeze(): void;
|
||||
isFrozen(): boolean;
|
||||
lock(): void;
|
||||
unlock(): void;
|
||||
processMutations: (mutations: mutationRecord[]) => void;
|
||||
emit: () => void;
|
||||
private processMutation;
|
||||
private genAdds;
|
||||
}
|
||||
10
packages/rrweb/typings/record/observer.d.ts
vendored
Normal file
10
packages/rrweb/typings/record/observer.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot';
|
||||
import { mutationCallBack, observerParam, listenerHandler, scrollCallback, blockClass, maskTextClass, hooksParam, SamplingStrategy, Mirror } from '../types';
|
||||
import MutationBuffer from './mutation';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
import { ShadowDomManager } from './shadow-dom-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, slimDOMOptions: SlimDOMOptions, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, 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;
|
||||
31
packages/rrweb/typings/record/shadow-dom-manager.d.ts
vendored
Normal file
31
packages/rrweb/typings/record/shadow-dom-manager.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mutationCallBack, blockClass, maskTextClass, Mirror, scrollCallback, SamplingStrategy } from '../types';
|
||||
import { MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn } from 'rrweb-snapshot';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
declare type BypassOptions = {
|
||||
blockClass: blockClass;
|
||||
blockSelector: string | null;
|
||||
maskTextClass: maskTextClass;
|
||||
maskTextSelector: string | null;
|
||||
inlineStylesheet: boolean;
|
||||
maskInputOptions: MaskInputOptions;
|
||||
maskTextFn: MaskTextFn | undefined;
|
||||
maskInputFn: MaskInputFn | undefined;
|
||||
recordCanvas: boolean;
|
||||
sampling: SamplingStrategy;
|
||||
slimDOMOptions: SlimDOMOptions;
|
||||
iframeManager: IframeManager;
|
||||
};
|
||||
export declare class ShadowDomManager {
|
||||
private mutationCb;
|
||||
private scrollCb;
|
||||
private bypassOptions;
|
||||
private mirror;
|
||||
constructor(options: {
|
||||
mutationCb: mutationCallBack;
|
||||
scrollCb: scrollCallback;
|
||||
bypassOptions: BypassOptions;
|
||||
mirror: Mirror;
|
||||
});
|
||||
addShadowRoot(shadowRoot: ShadowRoot, doc: Document): void;
|
||||
}
|
||||
export {};
|
||||
2
packages/rrweb/typings/record/stringify.d.ts
vendored
Normal file
2
packages/rrweb/typings/record/stringify.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { StringifyOptions } from '../types';
|
||||
export declare function stringify(obj: any, stringifyOptions?: StringifyOptions): string;
|
||||
69
packages/rrweb/typings/replay/index.d.ts
vendored
Normal file
69
packages/rrweb/typings/replay/index.d.ts
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Timer } from './timer';
|
||||
import { createPlayerService, createSpeedService } from './machine';
|
||||
import { eventWithTime, playerConfig, playerMetaData, Handler, Mirror } from '../types';
|
||||
import './styles/style.css';
|
||||
export declare class Replayer {
|
||||
wrapper: HTMLDivElement;
|
||||
iframe: HTMLIFrameElement;
|
||||
service: ReturnType<typeof createPlayerService>;
|
||||
speedService: ReturnType<typeof createSpeedService>;
|
||||
get timer(): Timer;
|
||||
config: playerConfig;
|
||||
private mouse;
|
||||
private mouseTail;
|
||||
private tailPositions;
|
||||
private emitter;
|
||||
private nextUserInteractionEvent;
|
||||
private legacy_missingNodeRetryMap;
|
||||
private treeIndex;
|
||||
private fragmentParentMap;
|
||||
private elementStateMap;
|
||||
private virtualStyleRulesMap;
|
||||
private imageMap;
|
||||
private mirror;
|
||||
private firstFullSnapshot;
|
||||
private newDocumentQueue;
|
||||
constructor(events: Array<eventWithTime | string>, config?: Partial<playerConfig>);
|
||||
on(event: string, handler: Handler): this;
|
||||
off(event: string, handler: Handler): this;
|
||||
setConfig(config: Partial<playerConfig>): void;
|
||||
getMetaData(): playerMetaData;
|
||||
getCurrentTime(): number;
|
||||
getTimeOffset(): number;
|
||||
getMirror(): Mirror;
|
||||
play(timeOffset?: number): void;
|
||||
pause(timeOffset?: number): void;
|
||||
resume(timeOffset?: number): void;
|
||||
startLive(baselineTime?: number): void;
|
||||
addEvent(rawEvent: eventWithTime | string): void;
|
||||
enableInteract(): void;
|
||||
disableInteract(): void;
|
||||
private setupDom;
|
||||
private handleResize;
|
||||
private getCastFn;
|
||||
private rebuildFullSnapshot;
|
||||
private insertStyleRules;
|
||||
private attachDocumentToIframe;
|
||||
private collectIframeAndAttachDocument;
|
||||
private waitForStylesheetLoad;
|
||||
private preloadAllImages;
|
||||
private applyIncremental;
|
||||
private applyMutation;
|
||||
private applyScroll;
|
||||
private applyInput;
|
||||
private legacy_resolveMissingNode;
|
||||
private moveAndHover;
|
||||
private drawMouseTail;
|
||||
private hoverElements;
|
||||
private isUserInteraction;
|
||||
private backToNormal;
|
||||
private restoreRealParent;
|
||||
private storeState;
|
||||
private restoreState;
|
||||
private restoreNodeSheet;
|
||||
private warnNodeNotFound;
|
||||
private warnCanvasMutationFailed;
|
||||
private debugNodeNotFound;
|
||||
private warn;
|
||||
private debug;
|
||||
}
|
||||
79
packages/rrweb/typings/replay/machine.d.ts
vendored
Normal file
79
packages/rrweb/typings/replay/machine.d.ts
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
import { StateMachine } from '@xstate/fsm';
|
||||
import { playerConfig, eventWithTime, Emitter } from '../types';
|
||||
import { Timer } from './timer';
|
||||
export declare type PlayerContext = {
|
||||
events: eventWithTime[];
|
||||
timer: Timer;
|
||||
timeOffset: number;
|
||||
baselineTime: number;
|
||||
lastPlayedEvent: eventWithTime | null;
|
||||
};
|
||||
export declare type PlayerEvent = {
|
||||
type: 'PLAY';
|
||||
payload: {
|
||||
timeOffset: number;
|
||||
};
|
||||
} | {
|
||||
type: 'CAST_EVENT';
|
||||
payload: {
|
||||
event: eventWithTime;
|
||||
};
|
||||
} | {
|
||||
type: 'PAUSE';
|
||||
} | {
|
||||
type: 'TO_LIVE';
|
||||
payload: {
|
||||
baselineTime?: number;
|
||||
};
|
||||
} | {
|
||||
type: 'ADD_EVENT';
|
||||
payload: {
|
||||
event: eventWithTime;
|
||||
};
|
||||
} | {
|
||||
type: 'END';
|
||||
};
|
||||
export declare type PlayerState = {
|
||||
value: 'playing';
|
||||
context: PlayerContext;
|
||||
} | {
|
||||
value: 'paused';
|
||||
context: PlayerContext;
|
||||
} | {
|
||||
value: 'live';
|
||||
context: PlayerContext;
|
||||
};
|
||||
export declare function discardPriorSnapshots(events: eventWithTime[], baselineTime: number): eventWithTime[];
|
||||
declare type PlayerAssets = {
|
||||
emitter: Emitter;
|
||||
getCastFn(event: eventWithTime, isSync: boolean): () => void;
|
||||
};
|
||||
export declare function createPlayerService(context: PlayerContext, { getCastFn, emitter }: PlayerAssets): StateMachine.Service<PlayerContext, PlayerEvent, PlayerState>;
|
||||
export declare type SpeedContext = {
|
||||
normalSpeed: playerConfig['speed'];
|
||||
timer: Timer;
|
||||
};
|
||||
export declare type SpeedEvent = {
|
||||
type: 'FAST_FORWARD';
|
||||
payload: {
|
||||
speed: playerConfig['speed'];
|
||||
};
|
||||
} | {
|
||||
type: 'BACK_TO_NORMAL';
|
||||
} | {
|
||||
type: 'SET_SPEED';
|
||||
payload: {
|
||||
speed: playerConfig['speed'];
|
||||
};
|
||||
};
|
||||
export declare type SpeedState = {
|
||||
value: 'normal';
|
||||
context: SpeedContext;
|
||||
} | {
|
||||
value: 'skipping';
|
||||
context: SpeedContext;
|
||||
};
|
||||
export declare function createSpeedService(context: SpeedContext): StateMachine.Service<SpeedContext, SpeedEvent, SpeedState>;
|
||||
export declare type PlayerMachineState = StateMachine.State<PlayerContext, PlayerEvent, PlayerState>;
|
||||
export declare type SpeedMachineState = StateMachine.State<SpeedContext, SpeedEvent, SpeedState>;
|
||||
export {};
|
||||
1
packages/rrweb/typings/replay/smoothscroll.d.ts
vendored
Normal file
1
packages/rrweb/typings/replay/smoothscroll.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function polyfill(w?: Window, d?: Document): void;
|
||||
2
packages/rrweb/typings/replay/styles/inject-style.d.ts
vendored
Normal file
2
packages/rrweb/typings/replay/styles/inject-style.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const rules: (blockClass: string) => string[];
|
||||
export default rules;
|
||||
18
packages/rrweb/typings/replay/timer.d.ts
vendored
Normal file
18
packages/rrweb/typings/replay/timer.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import { actionWithDelay, eventWithTime } from '../types';
|
||||
export declare class Timer {
|
||||
timeOffset: number;
|
||||
speed: number;
|
||||
private actions;
|
||||
private raf;
|
||||
private liveMode;
|
||||
constructor(actions: actionWithDelay[] | undefined, speed: number);
|
||||
addAction(action: actionWithDelay): void;
|
||||
addActions(actions: actionWithDelay[]): void;
|
||||
start(): void;
|
||||
clear(): void;
|
||||
setSpeed(speed: number): void;
|
||||
toggleLiveMode(mode: boolean): void;
|
||||
isActive(): boolean;
|
||||
private findActionIndex;
|
||||
}
|
||||
export declare function addDelay(event: eventWithTime, baselineTime: number): number;
|
||||
24
packages/rrweb/typings/replay/virtual-styles.d.ts
vendored
Normal file
24
packages/rrweb/typings/replay/virtual-styles.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import { INode } from 'rrweb-snapshot';
|
||||
export declare enum StyleRuleType {
|
||||
Insert = 0,
|
||||
Remove = 1,
|
||||
Snapshot = 2
|
||||
}
|
||||
declare type InsertRule = {
|
||||
cssText: string;
|
||||
type: StyleRuleType.Insert;
|
||||
index?: number;
|
||||
};
|
||||
declare type RemoveRule = {
|
||||
type: StyleRuleType.Remove;
|
||||
index: number;
|
||||
};
|
||||
declare type SnapshotRule = {
|
||||
type: StyleRuleType.Snapshot;
|
||||
cssTexts: string[];
|
||||
};
|
||||
export declare type VirtualStyleRules = Array<InsertRule | RemoveRule | SnapshotRule>;
|
||||
export declare type VirtualStyleRulesMap = Map<INode, VirtualStyleRules>;
|
||||
export declare function applyVirtualStyleRulesToNode(storedRules: VirtualStyleRules, styleNode: HTMLStyleElement): void;
|
||||
export declare function storeCSSRules(parentElement: HTMLStyleElement, virtualStyleRulesMap: VirtualStyleRulesMap): void;
|
||||
export {};
|
||||
1
packages/rrweb/typings/rrdom/index.d.ts
vendored
Normal file
1
packages/rrweb/typings/rrdom/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/rrweb/typings/rrdom/tree-node.d.ts
vendored
Normal file
20
packages/rrweb/typings/rrdom/tree-node.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
export declare type AnyObject = {
|
||||
[key: string]: any;
|
||||
__rrdom__?: RRdomTreeNode;
|
||||
};
|
||||
export declare class RRdomTreeNode implements AnyObject {
|
||||
parent: AnyObject | null;
|
||||
previousSibling: AnyObject | null;
|
||||
nextSibling: AnyObject | null;
|
||||
firstChild: AnyObject | null;
|
||||
lastChild: AnyObject | null;
|
||||
childrenVersion: number;
|
||||
childIndexCachedUpTo: AnyObject | null;
|
||||
cachedIndex: number;
|
||||
cachedIndexVersion: number;
|
||||
get isAttached(): boolean;
|
||||
get hasChildren(): boolean;
|
||||
childrenChanged(): void;
|
||||
getCachedIndex(parentNode: AnyObject): number;
|
||||
setCachedIndex(parentNode: AnyObject, index: number): void;
|
||||
}
|
||||
427
packages/rrweb/typings/types.d.ts
vendored
Normal file
427
packages/rrweb/typings/types.d.ts
vendored
Normal file
@@ -0,0 +1,427 @@
|
||||
import { serializedNodeWithId, idNodeMap, INode, MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot';
|
||||
import { PackFn, UnpackFn } from './packer/base';
|
||||
import { FontFaceDescriptors } from 'css-font-loading-module';
|
||||
import { IframeManager } from './record/iframe-manager';
|
||||
import { ShadowDomManager } from './record/shadow-dom-manager';
|
||||
import type { Replayer } from './replay';
|
||||
export declare enum EventType {
|
||||
DomContentLoaded = 0,
|
||||
Load = 1,
|
||||
FullSnapshot = 2,
|
||||
IncrementalSnapshot = 3,
|
||||
Meta = 4,
|
||||
Custom = 5,
|
||||
Plugin = 6
|
||||
}
|
||||
export declare type domContentLoadedEvent = {
|
||||
type: EventType.DomContentLoaded;
|
||||
data: {};
|
||||
};
|
||||
export declare type loadedEvent = {
|
||||
type: EventType.Load;
|
||||
data: {};
|
||||
};
|
||||
export declare type fullSnapshotEvent = {
|
||||
type: EventType.FullSnapshot;
|
||||
data: {
|
||||
node: serializedNodeWithId;
|
||||
initialOffset: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
export declare type incrementalSnapshotEvent = {
|
||||
type: EventType.IncrementalSnapshot;
|
||||
data: incrementalData;
|
||||
};
|
||||
export declare type metaEvent = {
|
||||
type: EventType.Meta;
|
||||
data: {
|
||||
href: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
export declare type customEvent<T = unknown> = {
|
||||
type: EventType.Custom;
|
||||
data: {
|
||||
tag: string;
|
||||
payload: T;
|
||||
};
|
||||
};
|
||||
export declare type pluginEvent<T = unknown> = {
|
||||
type: EventType.Plugin;
|
||||
data: {
|
||||
plugin: string;
|
||||
payload: T;
|
||||
};
|
||||
};
|
||||
export declare type styleSheetEvent = {};
|
||||
export declare enum IncrementalSource {
|
||||
Mutation = 0,
|
||||
MouseMove = 1,
|
||||
MouseInteraction = 2,
|
||||
Scroll = 3,
|
||||
ViewportResize = 4,
|
||||
Input = 5,
|
||||
TouchMove = 6,
|
||||
MediaInteraction = 7,
|
||||
StyleSheetRule = 8,
|
||||
CanvasMutation = 9,
|
||||
Font = 10,
|
||||
Log = 11,
|
||||
Drag = 12
|
||||
}
|
||||
export declare type mutationData = {
|
||||
source: IncrementalSource.Mutation;
|
||||
} & mutationCallbackParam;
|
||||
export declare type mousemoveData = {
|
||||
source: IncrementalSource.MouseMove | IncrementalSource.TouchMove | IncrementalSource.Drag;
|
||||
positions: mousePosition[];
|
||||
};
|
||||
export declare type mouseInteractionData = {
|
||||
source: IncrementalSource.MouseInteraction;
|
||||
} & mouseInteractionParam;
|
||||
export declare type scrollData = {
|
||||
source: IncrementalSource.Scroll;
|
||||
} & scrollPosition;
|
||||
export declare type viewportResizeData = {
|
||||
source: IncrementalSource.ViewportResize;
|
||||
} & viewportResizeDimension;
|
||||
export declare type inputData = {
|
||||
source: IncrementalSource.Input;
|
||||
id: number;
|
||||
} & inputValue;
|
||||
export declare type mediaInteractionData = {
|
||||
source: IncrementalSource.MediaInteraction;
|
||||
} & mediaInteractionParam;
|
||||
export declare type styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule;
|
||||
} & styleSheetRuleParam;
|
||||
export declare type canvasMutationData = {
|
||||
source: IncrementalSource.CanvasMutation;
|
||||
} & canvasMutationParam;
|
||||
export declare type fontData = {
|
||||
source: IncrementalSource.Font;
|
||||
} & fontParam;
|
||||
export declare type incrementalData = mutationData | mousemoveData | mouseInteractionData | scrollData | viewportResizeData | inputData | mediaInteractionData | styleSheetRuleData | canvasMutationData | fontData;
|
||||
export declare type event = domContentLoadedEvent | loadedEvent | fullSnapshotEvent | incrementalSnapshotEvent | metaEvent | customEvent | pluginEvent;
|
||||
export declare type eventWithTime = event & {
|
||||
timestamp: number;
|
||||
delay?: number;
|
||||
};
|
||||
export declare type blockClass = string | RegExp;
|
||||
export declare type maskTextClass = string | RegExp;
|
||||
export declare type SamplingStrategy = Partial<{
|
||||
mousemove: boolean | number;
|
||||
mousemoveCallback: number;
|
||||
mouseInteraction: boolean | Record<string, boolean | undefined>;
|
||||
scroll: number;
|
||||
input: 'all' | 'last';
|
||||
}>;
|
||||
export declare type RecordPlugin<TOptions = unknown> = {
|
||||
name: string;
|
||||
observer: (cb: Function, options: TOptions) => listenerHandler;
|
||||
options: TOptions;
|
||||
};
|
||||
export declare type recordOptions<T> = {
|
||||
emit?: (e: T, isCheckout?: boolean) => void;
|
||||
checkoutEveryNth?: number;
|
||||
checkoutEveryNms?: number;
|
||||
blockClass?: blockClass;
|
||||
blockSelector?: string;
|
||||
ignoreClass?: string;
|
||||
maskTextClass?: maskTextClass;
|
||||
maskTextSelector?: string;
|
||||
maskAllInputs?: boolean;
|
||||
maskInputOptions?: MaskInputOptions;
|
||||
maskInputFn?: MaskInputFn;
|
||||
maskTextFn?: MaskTextFn;
|
||||
slimDOMOptions?: SlimDOMOptions | 'all' | true;
|
||||
inlineStylesheet?: boolean;
|
||||
hooks?: hooksParam;
|
||||
packFn?: PackFn;
|
||||
sampling?: SamplingStrategy;
|
||||
recordCanvas?: boolean;
|
||||
userTriggeredOnInput?: boolean;
|
||||
collectFonts?: boolean;
|
||||
plugins?: RecordPlugin[];
|
||||
mousemoveWait?: number;
|
||||
keepIframeSrcFn?: KeepIframeSrcFn;
|
||||
};
|
||||
export declare type observerParam = {
|
||||
mutationCb: mutationCallBack;
|
||||
mousemoveCb: mousemoveCallBack;
|
||||
mouseInteractionCb: mouseInteractionCallBack;
|
||||
scrollCb: scrollCallback;
|
||||
viewportResizeCb: viewportResizeCallback;
|
||||
inputCb: inputCallback;
|
||||
mediaInteractionCb: mediaInteractionCallback;
|
||||
blockClass: blockClass;
|
||||
blockSelector: string | null;
|
||||
ignoreClass: string;
|
||||
maskTextClass: maskTextClass;
|
||||
maskTextSelector: string | null;
|
||||
maskInputOptions: MaskInputOptions;
|
||||
maskInputFn?: MaskInputFn;
|
||||
maskTextFn?: MaskTextFn;
|
||||
inlineStylesheet: boolean;
|
||||
styleSheetRuleCb: styleSheetRuleCallback;
|
||||
canvasMutationCb: canvasMutationCallback;
|
||||
fontCb: fontCallback;
|
||||
sampling: SamplingStrategy;
|
||||
recordCanvas: boolean;
|
||||
userTriggeredOnInput: boolean;
|
||||
collectFonts: boolean;
|
||||
slimDOMOptions: SlimDOMOptions;
|
||||
doc: Document;
|
||||
mirror: Mirror;
|
||||
iframeManager: IframeManager;
|
||||
shadowDomManager: ShadowDomManager;
|
||||
plugins: Array<{
|
||||
observer: Function;
|
||||
callback: Function;
|
||||
options: unknown;
|
||||
}>;
|
||||
};
|
||||
export declare type hooksParam = {
|
||||
mutation?: mutationCallBack;
|
||||
mousemove?: mousemoveCallBack;
|
||||
mouseInteraction?: mouseInteractionCallBack;
|
||||
scroll?: scrollCallback;
|
||||
viewportResize?: viewportResizeCallback;
|
||||
input?: inputCallback;
|
||||
mediaInteaction?: mediaInteractionCallback;
|
||||
styleSheetRule?: styleSheetRuleCallback;
|
||||
canvasMutation?: canvasMutationCallback;
|
||||
font?: fontCallback;
|
||||
};
|
||||
export declare type mutationRecord = {
|
||||
type: string;
|
||||
target: Node;
|
||||
oldValue: string | null;
|
||||
addedNodes: NodeList;
|
||||
removedNodes: NodeList;
|
||||
attributeName: string | null;
|
||||
};
|
||||
export declare type textCursor = {
|
||||
node: Node;
|
||||
value: string | null;
|
||||
};
|
||||
export declare type textMutation = {
|
||||
id: number;
|
||||
value: string | null;
|
||||
};
|
||||
export declare type styleAttributeValue = {
|
||||
[key: string]: styleValueWithPriority | string | false;
|
||||
};
|
||||
export declare type styleValueWithPriority = [string, string];
|
||||
export declare type attributeCursor = {
|
||||
node: Node;
|
||||
attributes: {
|
||||
[key: string]: string | styleAttributeValue | null;
|
||||
};
|
||||
};
|
||||
export declare type attributeMutation = {
|
||||
id: number;
|
||||
attributes: {
|
||||
[key: string]: string | styleAttributeValue | null;
|
||||
};
|
||||
};
|
||||
export declare type removedNodeMutation = {
|
||||
parentId: number;
|
||||
id: number;
|
||||
isShadow?: boolean;
|
||||
};
|
||||
export declare type addedNodeMutation = {
|
||||
parentId: number;
|
||||
previousId?: number | null;
|
||||
nextId: number | null;
|
||||
node: serializedNodeWithId;
|
||||
};
|
||||
export declare type mutationCallbackParam = {
|
||||
texts: textMutation[];
|
||||
attributes: attributeMutation[];
|
||||
removes: removedNodeMutation[];
|
||||
adds: addedNodeMutation[];
|
||||
isAttachIframe?: true;
|
||||
};
|
||||
export declare type mutationCallBack = (m: mutationCallbackParam) => void;
|
||||
export declare type mousemoveCallBack = (p: mousePosition[], source: IncrementalSource.MouseMove | IncrementalSource.TouchMove | IncrementalSource.Drag) => void;
|
||||
export declare type mousePosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
id: number;
|
||||
timeOffset: number;
|
||||
};
|
||||
export declare enum MouseInteractions {
|
||||
MouseUp = 0,
|
||||
MouseDown = 1,
|
||||
Click = 2,
|
||||
ContextMenu = 3,
|
||||
DblClick = 4,
|
||||
Focus = 5,
|
||||
Blur = 6,
|
||||
TouchStart = 7,
|
||||
TouchMove_Departed = 8,
|
||||
TouchEnd = 9
|
||||
}
|
||||
declare type mouseInteractionParam = {
|
||||
type: MouseInteractions;
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
export declare type mouseInteractionCallBack = (d: mouseInteractionParam) => void;
|
||||
export declare type scrollPosition = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
export declare type scrollCallback = (p: scrollPosition) => void;
|
||||
export declare type styleSheetAddRule = {
|
||||
rule: string;
|
||||
index?: number;
|
||||
};
|
||||
export declare type styleSheetDeleteRule = {
|
||||
index: number;
|
||||
};
|
||||
export declare type styleSheetRuleParam = {
|
||||
id: number;
|
||||
removes?: styleSheetDeleteRule[];
|
||||
adds?: styleSheetAddRule[];
|
||||
};
|
||||
export declare type styleSheetRuleCallback = (s: styleSheetRuleParam) => void;
|
||||
export declare type canvasMutationCallback = (p: canvasMutationParam) => void;
|
||||
export declare type canvasMutationParam = {
|
||||
id: number;
|
||||
property: string;
|
||||
args: Array<unknown>;
|
||||
setter?: true;
|
||||
};
|
||||
export declare type fontParam = {
|
||||
family: string;
|
||||
fontSource: string;
|
||||
buffer: boolean;
|
||||
descriptors?: FontFaceDescriptors;
|
||||
};
|
||||
export declare type fontCallback = (p: fontParam) => void;
|
||||
export declare type viewportResizeDimension = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
export declare type viewportResizeCallback = (d: viewportResizeDimension) => void;
|
||||
export declare type inputValue = {
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
userTriggered?: boolean;
|
||||
};
|
||||
export declare type inputCallback = (v: inputValue & {
|
||||
id: number;
|
||||
}) => void;
|
||||
export declare const enum MediaInteractions {
|
||||
Play = 0,
|
||||
Pause = 1,
|
||||
Seeked = 2
|
||||
}
|
||||
export declare type mediaInteractionParam = {
|
||||
type: MediaInteractions;
|
||||
id: number;
|
||||
currentTime?: number;
|
||||
};
|
||||
export declare type mediaInteractionCallback = (p: mediaInteractionParam) => void;
|
||||
export declare type DocumentDimension = {
|
||||
x: number;
|
||||
y: number;
|
||||
relativeScale: number;
|
||||
absoluteScale: number;
|
||||
};
|
||||
export declare type Mirror = {
|
||||
map: idNodeMap;
|
||||
getId: (n: INode) => number;
|
||||
getNode: (id: number) => INode | null;
|
||||
removeNodeFromMap: (n: INode) => void;
|
||||
has: (id: number) => boolean;
|
||||
reset: () => void;
|
||||
};
|
||||
export declare type throttleOptions = {
|
||||
leading?: boolean;
|
||||
trailing?: boolean;
|
||||
};
|
||||
export declare type listenerHandler = () => void;
|
||||
export declare type hookResetter = () => void;
|
||||
export declare type ReplayPlugin = {
|
||||
handler: (event: eventWithTime, isSync: boolean, context: {
|
||||
replayer: Replayer;
|
||||
}) => void;
|
||||
};
|
||||
export declare type playerConfig = {
|
||||
speed: number;
|
||||
maxSpeed: number;
|
||||
root: Element;
|
||||
loadTimeout: number;
|
||||
skipInactive: boolean;
|
||||
showWarning: boolean;
|
||||
showDebug: boolean;
|
||||
blockClass: string;
|
||||
liveMode: boolean;
|
||||
insertStyleRules: string[];
|
||||
triggerFocus: boolean;
|
||||
UNSAFE_replayCanvas: boolean;
|
||||
pauseAnimation?: boolean;
|
||||
mouseTail: boolean | {
|
||||
duration?: number;
|
||||
lineCap?: string;
|
||||
lineWidth?: number;
|
||||
strokeStyle?: string;
|
||||
};
|
||||
unpackFn?: UnpackFn;
|
||||
plugins?: ReplayPlugin[];
|
||||
};
|
||||
export declare type playerMetaData = {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
totalTime: number;
|
||||
};
|
||||
export declare type missingNode = {
|
||||
node: Node;
|
||||
mutation: addedNodeMutation;
|
||||
};
|
||||
export declare type missingNodeMap = {
|
||||
[id: number]: missingNode;
|
||||
};
|
||||
export declare type actionWithDelay = {
|
||||
doAction: () => void;
|
||||
delay: number;
|
||||
};
|
||||
export declare type Handler = (event?: unknown) => void;
|
||||
export declare type Emitter = {
|
||||
on(type: string, handler: Handler): void;
|
||||
emit(type: string, event?: unknown): void;
|
||||
off(type: string, handler: Handler): void;
|
||||
};
|
||||
export declare type Arguments<T> = T extends (...payload: infer U) => unknown ? U : unknown;
|
||||
export declare enum ReplayerEvents {
|
||||
Start = "start",
|
||||
Pause = "pause",
|
||||
Resume = "resume",
|
||||
Resize = "resize",
|
||||
Finish = "finish",
|
||||
FullsnapshotRebuilded = "fullsnapshot-rebuilded",
|
||||
LoadStylesheetStart = "load-stylesheet-start",
|
||||
LoadStylesheetEnd = "load-stylesheet-end",
|
||||
SkipStart = "skip-start",
|
||||
SkipEnd = "skip-end",
|
||||
MouseInteraction = "mouse-interaction",
|
||||
EventCast = "event-cast",
|
||||
CustomEvent = "custom-event",
|
||||
Flush = "flush",
|
||||
StateChange = "state-change",
|
||||
PlayBack = "play-back"
|
||||
}
|
||||
export declare type ElementState = {
|
||||
scroll?: [number, number];
|
||||
};
|
||||
export declare type KeepIframeSrcFn = (src: string) => boolean;
|
||||
export {};
|
||||
70
packages/rrweb/typings/utils.d.ts
vendored
Normal file
70
packages/rrweb/typings/utils.d.ts
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Mirror, throttleOptions, listenerHandler, hookResetter, blockClass, eventWithTime, addedNodeMutation, removedNodeMutation, textMutation, attributeMutation, mutationData, scrollData, inputData, DocumentDimension } from './types';
|
||||
import { INode, serializedNodeWithId } from 'rrweb-snapshot';
|
||||
export declare function on(type: string, fn: EventListenerOrEventListenerObject, target?: Document | Window): listenerHandler;
|
||||
export declare function createMirror(): Mirror;
|
||||
export declare let _mirror: Mirror;
|
||||
export declare function throttle<T>(func: (arg: T) => void, wait: number, options?: throttleOptions): (arg: T) => void;
|
||||
export declare function hookSetter<T>(target: T, key: string | number | symbol, d: PropertyDescriptor, isRevoked?: boolean, win?: Window & typeof globalThis): hookResetter;
|
||||
export declare function patch(source: {
|
||||
[key: string]: any;
|
||||
}, name: string, replacement: (...args: any[]) => any): () => void;
|
||||
export declare function getWindowHeight(): number;
|
||||
export declare function getWindowWidth(): number;
|
||||
export declare function isBlocked(node: Node | null, blockClass: blockClass): boolean;
|
||||
export declare function isIgnored(n: Node | INode): boolean;
|
||||
export declare function isAncestorRemoved(target: INode, mirror: Mirror): boolean;
|
||||
export declare function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent;
|
||||
export declare function polyfill(win?: Window & typeof globalThis): void;
|
||||
export declare function needCastInSyncMode(event: eventWithTime): boolean;
|
||||
export declare type TreeNode = {
|
||||
id: number;
|
||||
mutation: addedNodeMutation;
|
||||
parent?: TreeNode;
|
||||
children: Record<number, TreeNode>;
|
||||
texts: textMutation[];
|
||||
attributes: attributeMutation[];
|
||||
};
|
||||
export declare class TreeIndex {
|
||||
tree: Record<number, TreeNode>;
|
||||
private removeNodeMutations;
|
||||
private textMutations;
|
||||
private attributeMutations;
|
||||
private indexes;
|
||||
private removeIdSet;
|
||||
private scrollMap;
|
||||
private inputMap;
|
||||
constructor();
|
||||
add(mutation: addedNodeMutation): void;
|
||||
remove(mutation: removedNodeMutation, mirror: Mirror): void;
|
||||
text(mutation: textMutation): void;
|
||||
attribute(mutation: attributeMutation): void;
|
||||
scroll(d: scrollData): void;
|
||||
input(d: inputData): void;
|
||||
flush(): {
|
||||
mutationData: mutationData;
|
||||
scrollMap: TreeIndex['scrollMap'];
|
||||
inputMap: TreeIndex['inputMap'];
|
||||
};
|
||||
private reset;
|
||||
idRemoved(id: number): boolean;
|
||||
}
|
||||
declare type ResolveTree = {
|
||||
value: addedNodeMutation;
|
||||
children: ResolveTree[];
|
||||
parent: ResolveTree | null;
|
||||
};
|
||||
export declare function queueToResolveTrees(queue: addedNodeMutation[]): ResolveTree[];
|
||||
export declare function iterateResolveTree(tree: ResolveTree, cb: (mutation: addedNodeMutation) => unknown): void;
|
||||
declare type HTMLIFrameINode = HTMLIFrameElement & {
|
||||
__sn: serializedNodeWithId;
|
||||
};
|
||||
export declare type AppendedIframe = {
|
||||
mutationInQueue: addedNodeMutation;
|
||||
builtNode: HTMLIFrameINode;
|
||||
};
|
||||
export declare function isIframeINode(node: INode | ShadowRoot): node is HTMLIFrameINode;
|
||||
export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension;
|
||||
export declare function hasShadowRoot<T extends Node>(n: T): n is T & {
|
||||
shadowRoot: ShadowRoot;
|
||||
};
|
||||
export {};
|
||||
Reference in New Issue
Block a user