#853 Second try: fast-forward implementation v2: virtual dom optimization (#895)

* rrdom: add a diff function for properties

* implement diffChildren function and unit tests

* finish basic functions of diff algorithm

* fix several bugs in the diff algorithm

* replace the virtual parent optimization in applyMutation()

* fix: moveAndHover after the diff algorithm is executed

* replace virtual style map with rrdom

cssom version has to be above 0.5.0 to pass virtual style tests

* fix: failed virtual style tests in replayer.test.ts

* fix: failed polyfill tests caused by nodejs compatibility of different versions

* fix: svg viewBox attribute doesn't work

Cause the attribute viewBox is case sensitive, set value for viewbox doesn't work

* feat: replace treeIndex optimization with rrdom

* fix bug of diffProps and disable smooth scrolling animation in fast-forward mode

* feat: add iframe support

* fix: @rollup/plugin-typescript build errors in rrweb-player

Error: @rollup/plugin-typescript TS1371: This import is never used as a value and must use 'import type' because the 'importsNotUsedAsValues' is set to 'error'

* fix: bug when fast-forward input events and add test for it

* add test for fast-forward scroll events

* fix: custom style rules don't get inserted into some iframe elements

* code style tweak

* fix: enable to diff iframe elements

* fix  the jest error "Unexpected token 'export'"

* try to fix build error of rrweb-player

* correct the attributes definition in rrdom

* fix: custom style rules are not inserted in some iframes

* add support for shadow dom

* add support for MediaInteraction

* add canvas support

* fix unit test error in rrdom

* add support for Text, Comment

* try to refactor RRDom

* refactor RRDom to reduce duplicate code

* rename document-browser to virtual-dom

* increase the test coverage for document.ts and add ownerDocument for it

* Merge branch 'master' into virtual-dom

* add more test for virtual-dom.ts

* use cssstyle in document-nodejs

* fix: bundle error

* improve document-nodejs

* enable to diff scroll positions of an element

* rename rrdom to virtualDom for more readability and make the tree public

* revert unknown change

* improve the css style parser for comments

* improve code style

* update typings

* add handling for the case where legacy_missingNodeRetryMap is not empty

* only import types from rrweb into rrdom

* Apply suggestions from code review

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* Apply suggestions from code review

* fix building error in rrweb

* add a method setDefaultSN to set a default value for a RRNode's __sn

* fix rrweb test error and bump up other packages

* add support for custom property of css styles

* add a switch for virtual-dom optimization

* Apply suggestions from code review

1. add an enum type for NodeType
2. rename nodeType from rrweb-snapshot to RRNodeType
3. rename notSerializedId to unserializedId
4. add comments for some confusing variables

* adapt changes of #865 to virtual-dom and improve the test case for more coverage

* apply review suggestions

https://github.com/rrweb-io/rrweb/pull/853#pullrequestreview-922474953

* tweak the diff algorithm

* add description of the flag useVirtualDom and remove outdated logConfig

* Remove console.log

* Contain changes to document

* Upgrade rollup to 2.70.2

* Revert "Upgrade rollup to 2.70.2"

This reverts commit b1be81a2a76565935c9dc391f31beb7f64d25956.

* Fix type checking rrdom

* Fix typing error while bundling

* Fix tslib error on build

Rollup would output the following error:
`semantic error TS2343: This syntax requires an imported helper named '__spreadArray' which does not exist in 'tslib'. Consider upgrading your version of 'tslib'.`

* Increase memory limit for rollup

* Use esbuild for bundling

Speeds up bundling significantly

* Avoid circular dependencies and import un-bundled rrdom

* Fix imports

* Revert back to pre-esbuild

This reverts the following commits:
b7b3c8dbaa551a0129da1477136b1baaad28e6e1
72e23b8e27f9030d911358d3a17fe5ad1b3b5d4f
85d600a20c56cfa764cf1f858932ba14e67b1d23
61e1a5d323212ca8fbe0569e0b3062ddd53fc612

* Set node to lts (12 is no longer supported)

* Speed up bundling and use less memory

This fixes the out of memory errors happening while bundling

* remove __sn from rrdom

* fix typo

* test: add a test case for StyleSheet mutation exceptions while fast-forwarding

* rename Array.prototype.slice.call() to Array.from()

* improve test cases

* fix: PR #887 in 'virtual-dom' branch

* apply justin's suggestion on 'Array.from' refactor

related commit 0f6729d27a323260b36fbe79485a86715c0bc98a

* improve import code structure

Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 4f2f739d93
commit 2887c8c7e5
99 changed files with 7087 additions and 2821 deletions

View File

@@ -5,7 +5,7 @@ os: linux
dist: focal
node_js:
- 12
- lts/*
install:
- yarn

View File

@@ -24,8 +24,7 @@
"settings": {
"jest.disabledWorkspaceFolders": [
" rrweb monorepo",
"rrweb-player (package)",
"rrdom (package)"
"rrweb-player (package)"
]
}
}

View File

@@ -299,10 +299,12 @@ The replayer accepts options as its constructor's second parameter, and it has t
| insertStyleRules | [] | accepts multiple CSS rule string, which will be injected into the replay iframe |
| triggerFocus | true | whether to trigger focus during replay |
| UNSAFE_replayCanvas | false | whether to replay the canvas element. **Enable this will remove the sandbox, which is unsafe.** |
| pauseAnimation | true | whether to pause CSS animation when the replayer is paused |
| mouseTail | true | whether to show mouse tail during replay. Set to false to disable mouse tail. A complete config can be found in this [type](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L407) |
| unpackFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
| logConfig | - | configuration of console output playback, refer to the [console recipe](./docs/recipes/console.md) |
| plugins | [] | load plugins to provide extended replay functions. [What is plugins?](./docs/recipes/plugin.md) |
| useVirtualDom | true | whether to use Virtual Dom optimization in the process of skipping to a new point of time |
#### Use rrweb-player

View File

@@ -295,10 +295,11 @@ replayer.pause(5000);
| insertStyleRules | [] | 可以传入多个 CSS rule string用于自定义回放时 iframe 内的样式 |
| triggerFocus | true | 回放时是否回放 focus 交互 |
| UNSAFE_replayCanvas | false | 回放时是否回放 canvas 内容,**开启后将会关闭沙盒策略,导致一定风险** |
| pauseAnimation | true | 当播放器停止播放时,是否将 CSS 动画也停止播放 |
| mouseTail | true | 是否在回放时增加鼠标轨迹。传入 false 可关闭,传入对象可以定制轨迹持续时间、样式等,配置详见[类型](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L407) |
| unpackFn | - | 数据解压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
| logConfig | - | console logger 数据播放设置,详见[console 录制和播放](./docs/recipes/console.zh_CN.md) |
| plugins | [] | 加载插件以获得额外的回放功能. [什么是插件?](./docs/recipes/plugin.zh_CN.md) |
| useVirtualDom | true | 在播放器跳转到一个新的时间点的过程中,是否使用 Virtual Dom 优化 |
#### 使用 rrweb-player

View File

@@ -30,5 +30,8 @@
"test:watch": "yarn lerna run test:watch --parallel",
"dev": "yarn lerna run dev --parallel",
"repl": "cd packages/rrweb && npm run repl"
},
"resolutions": {
"**/jsdom/cssom": "^0.5.0"
}
}

View File

@@ -5,6 +5,7 @@
"dev": "rollup -c -w",
"bundle": "rollup --config",
"bundle:es-only": "cross-env ES_ONLY=true rollup --config",
"check-types": "tsc -noEmit",
"test": "jest",
"prepublish": "npm run bundle"
},
@@ -27,18 +28,24 @@
"@rollup/plugin-commonjs": "^20.0.0",
"@rollup/plugin-node-resolve": "^13.0.4",
"@types/cssom": "^0.4.1",
"@types/jest": "^27.0.1",
"@types/cssstyle": "^2.2.1",
"@types/jest": "^27.4.1",
"@types/nwsapi": "^2.2.2",
"jest": "^27.1.1",
"@types/puppeteer": "^5.4.4",
"compare-versions": "^4.1.3",
"jest": "^27.5.1",
"puppeteer": "^9.1.1",
"rollup": "^2.56.3",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.30.0",
"rollup-plugin-typescript2": "^0.31.2",
"rollup-plugin-web-worker-loader": "^1.6.1",
"rrweb-snapshot": "^1.1.14",
"ts-jest": "^27.0.5",
"typescript": "^3.9.5"
"ts-jest": "^27.1.3",
"typescript": "^4.6.2"
},
"dependencies": {
"cssom": "^0.5.0",
"cssstyle": "^2.3.0",
"nwsapi": "^2.2.0"
}
}

View File

@@ -2,6 +2,7 @@ import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import typescript from 'rollup-plugin-typescript2';
import webWorkerLoader from 'rollup-plugin-web-worker-loader';
import pkg from './package.json';
function toMinPath(path) {
@@ -11,6 +12,10 @@ function toMinPath(path) {
const basePlugins = [
resolve({ browser: true }),
commonjs(),
// supports bundling `web-worker:..filename` from rrweb
webWorkerLoader(),
typescript({
tsconfigOverride: { compilerOptions: { module: 'ESNext' } },
}),
@@ -27,6 +32,11 @@ const baseConfigs = [
name: 'RRDocument',
path: 'document-nodejs',
},
{
input: './src/virtual-dom.ts',
name: 'RRDocument',
path: 'virtual-dom',
},
];
let configs = [];

513
packages/rrdom/src/diff.ts Normal file
View File

@@ -0,0 +1,513 @@
import { NodeType as RRNodeType, Mirror as NodeMirror } from 'rrweb-snapshot';
import type {
canvasMutationData,
canvasEventWithTime,
inputData,
scrollData,
} from 'rrweb/src/types';
import type {
IRRCDATASection,
IRRComment,
IRRDocument,
IRRDocumentType,
IRRElement,
IRRNode,
IRRText,
} from './document';
import type {
RRCanvasElement,
RRElement,
RRIFrameElement,
RRMediaElement,
RRStyleElement,
RRDocument,
Mirror,
} from './virtual-dom';
const NAMESPACES: Record<string, string> = {
svg: 'http://www.w3.org/2000/svg',
'xlink:href': 'http://www.w3.org/1999/xlink',
xmlns: 'http://www.w3.org/2000/xmlns/',
};
// camel case svg element tag names
const SVGTagMap: Record<string, string> = {
altglyph: 'altGlyph',
altglyphdef: 'altGlyphDef',
altglyphitem: 'altGlyphItem',
animatecolor: 'animateColor',
animatemotion: 'animateMotion',
animatetransform: 'animateTransform',
clippath: 'clipPath',
feblend: 'feBlend',
fecolormatrix: 'feColorMatrix',
fecomponenttransfer: 'feComponentTransfer',
fecomposite: 'feComposite',
feconvolvematrix: 'feConvolveMatrix',
fediffuselighting: 'feDiffuseLighting',
fedisplacementmap: 'feDisplacementMap',
fedistantlight: 'feDistantLight',
fedropshadow: 'feDropShadow',
feflood: 'feFlood',
fefunca: 'feFuncA',
fefuncb: 'feFuncB',
fefuncg: 'feFuncG',
fefuncr: 'feFuncR',
fegaussianblur: 'feGaussianBlur',
feimage: 'feImage',
femerge: 'feMerge',
femergenode: 'feMergeNode',
femorphology: 'feMorphology',
feoffset: 'feOffset',
fepointlight: 'fePointLight',
fespecularlighting: 'feSpecularLighting',
fespotlight: 'feSpotLight',
fetile: 'feTile',
feturbulence: 'feTurbulence',
foreignobject: 'foreignObject',
glyphref: 'glyphRef',
lineargradient: 'linearGradient',
radialgradient: 'radialGradient',
};
export type ReplayerHandler = {
mirror: NodeMirror;
applyCanvas: (
canvasEvent: canvasEventWithTime,
canvasMutationData: canvasMutationData,
target: HTMLCanvasElement,
) => void;
applyInput: (data: inputData) => void;
applyScroll: (data: scrollData, isSync: boolean) => void;
};
export function diff(
oldTree: Node,
newTree: IRRNode,
replayer: ReplayerHandler,
rrnodeMirror?: Mirror,
) {
const oldChildren = oldTree.childNodes;
const newChildren = newTree.childNodes;
rrnodeMirror =
rrnodeMirror ||
(newTree as RRDocument).mirror ||
(newTree.ownerDocument as RRDocument).mirror;
if (oldChildren.length > 0 || newChildren.length > 0) {
diffChildren(
Array.from(oldChildren),
newChildren,
oldTree,
replayer,
rrnodeMirror,
);
}
let inputDataToApply = null,
scrollDataToApply = null;
switch (newTree.RRNodeType) {
case RRNodeType.Document:
const newRRDocument = newTree as IRRDocument;
scrollDataToApply = (newRRDocument as RRDocument).scrollData;
break;
case RRNodeType.Element:
const oldElement = (oldTree as Node) as HTMLElement;
const newRRElement = newTree as IRRElement;
diffProps(oldElement, newRRElement, rrnodeMirror);
scrollDataToApply = (newRRElement as RRElement).scrollData;
inputDataToApply = (newRRElement as RRElement).inputData;
switch (newRRElement.tagName) {
case 'AUDIO':
case 'VIDEO':
const oldMediaElement = (oldTree as Node) as HTMLMediaElement;
const newMediaRRElement = newRRElement as RRMediaElement;
if (newMediaRRElement.paused !== undefined)
newMediaRRElement.paused
? oldMediaElement.pause()
: oldMediaElement.play();
if (newMediaRRElement.muted !== undefined)
oldMediaElement.muted = newMediaRRElement.muted;
if (newMediaRRElement.volume !== undefined)
oldMediaElement.volume = newMediaRRElement.volume;
if (newMediaRRElement.currentTime !== undefined)
oldMediaElement.currentTime = newMediaRRElement.currentTime;
break;
case 'CANVAS':
(newTree as RRCanvasElement).canvasMutations.forEach(
(canvasMutation) =>
replayer.applyCanvas(
canvasMutation.event,
canvasMutation.mutation,
(oldTree as Node) as HTMLCanvasElement,
),
);
break;
case 'STYLE':
applyVirtualStyleRulesToNode(
oldElement as HTMLStyleElement,
(newTree as RRStyleElement).rules,
);
break;
}
if (newRRElement.shadowRoot) {
if (!oldElement.shadowRoot) oldElement.attachShadow({ mode: 'open' });
const oldChildren = oldElement.shadowRoot!.childNodes;
const newChildren = newRRElement.shadowRoot.childNodes;
if (oldChildren.length > 0 || newChildren.length > 0)
diffChildren(
Array.from(oldChildren),
newChildren,
oldElement.shadowRoot!,
replayer,
rrnodeMirror,
);
}
break;
case RRNodeType.Text:
case RRNodeType.Comment:
case RRNodeType.CDATA:
if (
oldTree.textContent !==
(newTree as IRRText | IRRComment | IRRCDATASection).data
)
oldTree.textContent = (newTree as
| IRRText
| IRRComment
| IRRCDATASection).data;
break;
default:
}
scrollDataToApply && replayer.applyScroll(scrollDataToApply, true);
/**
* Input data need to get applied after all children of this node are updated.
* Otherwise when we set a value for a select element whose options are empty, the value won't actually update.
*/
inputDataToApply && replayer.applyInput(inputDataToApply);
// IFrame element doesn't have child nodes.
if (newTree.nodeName === 'IFRAME') {
const oldContentDocument = ((oldTree as Node) as HTMLIFrameElement)
.contentDocument;
const newIFrameElement = newTree as RRIFrameElement;
// If the iframe is cross-origin, the contentDocument will be null.
if (oldContentDocument) {
const sn = rrnodeMirror.getMeta(newIFrameElement.contentDocument);
if (sn) {
replayer.mirror.add(oldContentDocument, { ...sn });
}
diff(
oldContentDocument,
newIFrameElement.contentDocument,
replayer,
rrnodeMirror,
);
}
}
}
function diffProps(
oldTree: HTMLElement,
newTree: IRRElement,
rrnodeMirror: Mirror,
) {
const oldAttributes = oldTree.attributes;
const newAttributes = newTree.attributes;
for (const name in newAttributes) {
const newValue = newAttributes[name];
const sn = rrnodeMirror.getMeta(newTree);
if (sn && 'isSVG' in sn && sn.isSVG && NAMESPACES[name])
oldTree.setAttributeNS(NAMESPACES[name], name, newValue);
else if (newTree.tagName === 'CANVAS' && name === 'rr_dataURL') {
const image = document.createElement('img');
image.src = newValue;
image.onload = () => {
const ctx = (oldTree as HTMLCanvasElement).getContext('2d');
if (ctx) {
ctx.drawImage(image, 0, 0, image.width, image.height);
}
};
} else oldTree.setAttribute(name, newValue);
}
for (const { name } of Array.from(oldAttributes))
if (!(name in newAttributes)) oldTree.removeAttribute(name);
newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft);
newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop);
}
function diffChildren(
oldChildren: (Node | undefined)[],
newChildren: IRRNode[],
parentNode: Node,
replayer: ReplayerHandler,
rrnodeMirror: Mirror,
) {
let oldStartIndex = 0,
oldEndIndex = oldChildren.length - 1,
newStartIndex = 0,
newEndIndex = newChildren.length - 1;
let oldStartNode = oldChildren[oldStartIndex],
oldEndNode = oldChildren[oldEndIndex],
newStartNode = newChildren[newStartIndex],
newEndNode = newChildren[newEndIndex];
let oldIdToIndex: Record<number, number> | undefined = undefined,
indexInOld;
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (oldStartNode === undefined) {
oldStartNode = oldChildren[++oldStartIndex];
} else if (oldEndNode === undefined) {
oldEndNode = oldChildren[--oldEndIndex];
} else if (
replayer.mirror.getId(oldStartNode) === rrnodeMirror.getId(newStartNode)
) {
diff(oldStartNode, newStartNode, replayer, rrnodeMirror);
oldStartNode = oldChildren[++oldStartIndex];
newStartNode = newChildren[++newStartIndex];
} else if (
replayer.mirror.getId(oldEndNode) === rrnodeMirror.getId(newEndNode)
) {
diff(oldEndNode, newEndNode, replayer, rrnodeMirror);
oldEndNode = oldChildren[--oldEndIndex];
newEndNode = newChildren[--newEndIndex];
} else if (
replayer.mirror.getId(oldStartNode) === rrnodeMirror.getId(newEndNode)
) {
parentNode.insertBefore(oldStartNode, oldEndNode.nextSibling);
diff(oldStartNode, newEndNode, replayer, rrnodeMirror);
oldStartNode = oldChildren[++oldStartIndex];
newEndNode = newChildren[--newEndIndex];
} else if (
replayer.mirror.getId(oldEndNode) === rrnodeMirror.getId(newStartNode)
) {
parentNode.insertBefore(oldEndNode, oldStartNode);
diff(oldEndNode, newStartNode, replayer, rrnodeMirror);
oldEndNode = oldChildren[--oldEndIndex];
newStartNode = newChildren[++newStartIndex];
} else {
if (!oldIdToIndex) {
oldIdToIndex = {};
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
const oldChild = oldChildren[i];
if (oldChild && replayer.mirror.hasNode(oldChild))
oldIdToIndex[replayer.mirror.getId(oldChild)] = i;
}
}
indexInOld = oldIdToIndex[rrnodeMirror.getId(newStartNode)];
if (indexInOld) {
const nodeToMove = oldChildren[indexInOld]!;
parentNode.insertBefore(nodeToMove, oldStartNode);
diff(nodeToMove, newStartNode, replayer, rrnodeMirror);
oldChildren[indexInOld] = undefined;
} else {
const newNode = createOrGetNode(
newStartNode,
replayer.mirror,
rrnodeMirror,
);
/**
* A mounted iframe element has an automatically created HTML element.
* We should delete it before insert a serialized one. Otherwise, an error 'Only one element on document allowed' will be thrown.
*/
if (
replayer.mirror.getMeta(parentNode)?.type === RRNodeType.Document &&
replayer.mirror.getMeta(newNode)?.type === RRNodeType.Element &&
((parentNode as Node) as Document).documentElement
) {
parentNode.removeChild(
((parentNode as Node) as Document).documentElement,
);
oldChildren[oldStartIndex] = undefined;
oldStartNode = undefined;
}
parentNode.insertBefore(newNode, oldStartNode || null);
diff(newNode, newStartNode, replayer, rrnodeMirror);
}
newStartNode = newChildren[++newStartIndex];
}
}
if (oldStartIndex > oldEndIndex) {
const referenceRRNode = newChildren[newEndIndex + 1];
let referenceNode = null;
if (referenceRRNode)
parentNode.childNodes.forEach((child) => {
if (
replayer.mirror.getId(child) === rrnodeMirror.getId(referenceRRNode)
)
referenceNode = child;
});
for (; newStartIndex <= newEndIndex; ++newStartIndex) {
const newNode = createOrGetNode(
newChildren[newStartIndex],
replayer.mirror,
rrnodeMirror,
);
parentNode.insertBefore(newNode, referenceNode);
diff(newNode, newChildren[newStartIndex], replayer, rrnodeMirror);
}
} else if (newStartIndex > newEndIndex) {
for (; oldStartIndex <= oldEndIndex; oldStartIndex++) {
const node = oldChildren[oldStartIndex];
if (node) {
parentNode.removeChild(node);
replayer.mirror.removeNodeFromMap(node);
}
}
}
}
export function createOrGetNode(
rrNode: IRRNode,
domMirror: NodeMirror,
rrnodeMirror: Mirror,
): Node {
let node = domMirror.getNode(rrnodeMirror.getId(rrNode));
const sn = rrnodeMirror.getMeta(rrNode);
if (node !== null) return node;
switch (rrNode.RRNodeType) {
case RRNodeType.Document:
node = new Document();
break;
case RRNodeType.DocumentType:
node = document.implementation.createDocumentType(
(rrNode as IRRDocumentType).name,
(rrNode as IRRDocumentType).publicId,
(rrNode as IRRDocumentType).systemId,
);
break;
case RRNodeType.Element:
let tagName = (rrNode as IRRElement).tagName.toLowerCase();
tagName = SVGTagMap[tagName] || tagName;
if (sn && 'isSVG' in sn && sn?.isSVG) {
node = document.createElementNS(
NAMESPACES['svg'],
(rrNode as IRRElement).tagName.toLowerCase(),
);
} else node = document.createElement((rrNode as IRRElement).tagName);
break;
case RRNodeType.Text:
node = document.createTextNode((rrNode as IRRText).data);
break;
case RRNodeType.Comment:
node = document.createComment((rrNode as IRRComment).data);
break;
case RRNodeType.CDATA:
node = document.createCDATASection((rrNode as IRRCDATASection).data);
break;
}
if (sn) domMirror.add(node, { ...sn });
return node;
}
export function getNestedRule(
rules: CSSRuleList,
position: number[],
): CSSGroupingRule {
const rule = rules[position[0]] as CSSGroupingRule;
if (position.length === 1) {
return rule;
} else {
return getNestedRule(
((rule as CSSGroupingRule).cssRules[position[1]] as CSSGroupingRule)
.cssRules,
position.slice(2),
);
}
}
export enum StyleRuleType {
Insert,
Remove,
Snapshot,
SetProperty,
RemoveProperty,
}
type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number | number[];
};
type RemoveRule = {
type: StyleRuleType.Remove;
index: number | number[];
};
type SetPropertyRule = {
type: StyleRuleType.SetProperty;
index: number[];
property: string;
value: string | null;
priority: string | undefined;
};
type RemovePropertyRule = {
type: StyleRuleType.RemoveProperty;
index: number[];
property: string;
};
export type VirtualStyleRules = Array<
InsertRule | RemoveRule | SetPropertyRule | RemovePropertyRule
>;
export function getPositionsAndIndex(nestedIndex: number[]) {
const positions = [...nestedIndex];
const index = positions.pop();
return { positions, index };
}
export function applyVirtualStyleRulesToNode(
styleNode: HTMLStyleElement,
virtualStyleRules: VirtualStyleRules,
) {
const sheet = styleNode.sheet!;
virtualStyleRules.forEach((rule) => {
if (rule.type === StyleRuleType.Insert) {
try {
if (Array.isArray(rule.index)) {
const { positions, index } = getPositionsAndIndex(rule.index);
const nestedRule = getNestedRule(sheet.cssRules, positions);
nestedRule.insertRule(rule.cssText, index);
} else {
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 {
if (Array.isArray(rule.index)) {
const { positions, index } = getPositionsAndIndex(rule.index);
const nestedRule = getNestedRule(sheet.cssRules, positions);
nestedRule.deleteRule(index || 0);
} else {
sheet.deleteRule(rule.index);
}
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
} else if (rule.type === StyleRuleType.SetProperty) {
const nativeRule = (getNestedRule(
sheet.cssRules,
rule.index,
) as unknown) as CSSStyleRule;
nativeRule.style.setProperty(rule.property, rule.value, rule.priority);
} else if (rule.type === StyleRuleType.RemoveProperty) {
const nativeRule = (getNestedRule(
sheet.cssRules,
rule.index,
) as unknown) as CSSStyleRule;
nativeRule.style.removeProperty(rule.property);
}
});
}

View File

@@ -1,68 +1,24 @@
import { INode, NodeType, serializedNodeWithId } from 'rrweb-snapshot';
import { NWSAPI } from 'nwsapi';
import { parseCSSText, camelize, toCSSText } from './style';
import { NodeType as RRNodeType } from 'rrweb-snapshot';
import type { NWSAPI } from 'nwsapi';
import type { CSSStyleDeclaration as CSSStyleDeclarationType } from 'cssstyle';
import {
BaseRRCDATASectionImpl,
BaseRRCommentImpl,
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRNode,
BaseRRTextImpl,
ClassList,
IRRDocument,
CSSStyleDeclaration,
} from './document';
const nwsapi = require('nwsapi');
const cssom = require('cssom');
const cssstyle = require('cssstyle');
export abstract class RRNode {
__sn: serializedNodeWithId | undefined;
children: Array<RRNode> = [];
parentElement: RRElement | null = null;
parentNode: RRNode | null = null;
ownerDocument: RRDocument | null = null;
ELEMENT_NODE = 1;
TEXT_NODE = 3;
get firstChild() {
return this.children[0];
}
get nodeType() {
if (this instanceof RRDocument) return NodeType.Document;
if (this instanceof RRDocumentType) return NodeType.DocumentType;
if (this instanceof RRElement) return NodeType.Element;
if (this instanceof RRText) return NodeType.Text;
if (this instanceof RRCDATASection) return NodeType.CDATA;
if (this instanceof RRComment) return NodeType.Comment;
}
get childNodes() {
return this.children;
}
appendChild(newChild: RRNode): RRNode {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': This RRNode type does not support this method.`,
);
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
throw new Error(
`RRDomException: Failed to execute 'insertBefore' on 'RRNode': This RRNode type does not support this method.`,
);
}
contains(node: RRNode) {
if (node === this) return true;
for (const child of this.children) {
if (child.contains(node)) return true;
}
return false;
}
removeChild(node: RRNode) {
const indexOfChild = this.children.indexOf(node);
if (indexOfChild !== -1) {
this.children.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
}
}
toString(nodeName?: string) {
return `${JSON.stringify(this.__sn?.id) || ''} ${nodeName}`;
}
}
export class RRNode extends BaseRRNode {}
export class RRWindow {
scrollLeft = 0;
@@ -74,8 +30,10 @@ export class RRWindow {
}
}
export class RRDocument extends RRNode {
private mirror: Map<number, RRNode> = new Map();
export class RRDocument
extends BaseRRDocumentImpl(RRNode)
implements IRRDocument {
readonly nodeName: '#document' = '#document';
private _nwsapi: NWSAPI;
get nwsapi() {
if (!this._nwsapi) {
@@ -95,66 +53,32 @@ export class RRDocument extends RRNode {
return this._nwsapi;
}
get documentElement(): RRElement {
return this.children.find(
(node) => node instanceof RRElement && node.tagName === 'HTML',
) as RRElement;
get documentElement(): RRElement | null {
return super.documentElement as RRElement | null;
}
get body() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'BODY',
) || null
);
get body(): RRElement | null {
return super.body as RRElement | null;
}
get head() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'HEAD',
) || null
);
return super.head as RRElement | null;
}
get implementation() {
get implementation(): RRDocument {
return this;
}
get firstElementChild() {
return this.documentElement;
get firstElementChild(): RRElement | null {
return this.documentElement as RRElement | null;
}
appendChild(childNode: RRNode) {
const nodeType = childNode.nodeType;
if (nodeType === NodeType.Element || nodeType === NodeType.DocumentType) {
if (this.children.some((s) => s.nodeType === nodeType)) {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': Only one ${
nodeType === NodeType.Element ? 'RRElement' : 'RRDoctype'
} on RRDocument allowed.`,
);
}
}
childNode.parentElement = null;
childNode.parentNode = this;
childNode.ownerDocument = this;
this.children.push(childNode);
return childNode;
return super.appendChild(childNode);
}
insertBefore(newChild: RRNode, refChild: RRNode | null) {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.children.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.children.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
newChild.ownerDocument = this;
return newChild;
return super.insertBefore(newChild, refChild);
}
querySelectorAll(selectors: string): RRNode[] {
@@ -216,16 +140,16 @@ export class RRDocument extends RRNode {
element = new RRMediaElement(upperTagName);
break;
case 'IFRAME':
element = new RRIframeElement(upperTagName);
element = new RRIFrameElement(upperTagName);
break;
case 'IMG':
element = new RRImageElement('IMG');
element = new RRImageElement(upperTagName);
break;
case 'CANVAS':
element = new RRCanvasElement('CANVAS');
element = new RRCanvasElement(upperTagName);
break;
case 'STYLE':
element = new RRStyleElement('STYLE');
element = new RRStyleElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
@@ -235,10 +159,7 @@ export class RRDocument extends RRNode {
return element;
}
createElementNS(
_namespaceURI: 'http://www.w3.org/2000/svg',
qualifiedName: string,
) {
createElementNS(_namespaceURI: string, qualifiedName: string) {
return this.createElement(qualifiedName as keyof HTMLElementTagNameMap);
}
@@ -259,266 +180,40 @@ export class RRDocument extends RRNode {
textNode.ownerDocument = this;
return textNode;
}
/**
* This does come with some side effects. For example:
* 1. All event listeners currently registered on the document, nodes inside the document, or the document's window are removed.
* 2. All existing nodes are removed from the document.
*/
open() {
this.children = [];
}
close() {}
buildFromDom(dom: Document) {
let notSerializedId = -1;
const NodeTypeMap: Record<number, number> = {};
NodeTypeMap[document.DOCUMENT_NODE] = NodeType.Document;
NodeTypeMap[document.DOCUMENT_TYPE_NODE] = NodeType.DocumentType;
NodeTypeMap[document.ELEMENT_NODE] = NodeType.Element;
NodeTypeMap[document.TEXT_NODE] = NodeType.Text;
NodeTypeMap[document.CDATA_SECTION_NODE] = NodeType.CDATA;
NodeTypeMap[document.COMMENT_NODE] = NodeType.Comment;
function getValidTagName(element: HTMLElement): string {
if (element instanceof HTMLFormElement) {
return 'FORM';
}
return element.tagName.toUpperCase().trim();
}
const walk = function (node: INode) {
let serializedNodeWithId = node.__sn;
let rrNode: RRNode;
if (!serializedNodeWithId) {
serializedNodeWithId = {
type: NodeTypeMap[node.nodeType],
textContent: '',
id: notSerializedId,
};
notSerializedId -= 1;
node.__sn = serializedNodeWithId;
}
if (!this.mirror.has(serializedNodeWithId.id)) {
switch (node.nodeType) {
case node.DOCUMENT_NODE:
if (
serializedNodeWithId.rootId &&
serializedNodeWithId.rootId !== serializedNodeWithId.id
)
rrNode = this.createDocument();
else rrNode = this;
break;
case node.DOCUMENT_TYPE_NODE:
const documentType = (node as unknown) as DocumentType;
rrNode = this.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case node.ELEMENT_NODE:
const elementNode = (node as unknown) as HTMLElement;
const tagName = getValidTagName(elementNode);
rrNode = this.createElement(tagName);
const rrElement = rrNode as RRElement;
for (const { name, value } of Array.from(elementNode.attributes)) {
rrElement.attributes[name] = value;
}
// form fields
if (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT'
) {
const value = (elementNode as
| HTMLInputElement
| HTMLTextAreaElement).value;
if (
['RADIO', 'CHECKBOX', 'SUBMIT', 'BUTTON'].includes(
rrElement.attributes.type as string,
) &&
value
) {
rrElement.attributes.value = value;
} else if ((elementNode as HTMLInputElement).checked) {
rrElement.attributes.checked = (elementNode as HTMLInputElement).checked;
}
}
if (tagName === 'OPTION') {
const selectValue = (elementNode as HTMLOptionElement)
.parentElement;
if (
rrElement.attributes.value ===
(selectValue as HTMLSelectElement).value
) {
rrElement.attributes.selected = (elementNode as HTMLOptionElement).selected;
}
}
// canvas image data
if (tagName === 'CANVAS') {
rrElement.attributes.rr_dataURL = (elementNode as HTMLCanvasElement).toDataURL();
}
// media elements
if (tagName === 'AUDIO' || tagName === 'VIDEO') {
const rrMediaElement = rrElement as RRMediaElement;
rrMediaElement.paused = (elementNode as HTMLMediaElement).paused;
rrMediaElement.currentTime = (elementNode as HTMLMediaElement).currentTime;
}
// scroll
if (elementNode.scrollLeft) {
rrElement.scrollLeft = elementNode.scrollLeft;
}
if (elementNode.scrollTop) {
rrElement.scrollTop = elementNode.scrollTop;
}
break;
case node.TEXT_NODE:
rrNode = this.createTextNode(
((node as unknown) as Text).textContent,
);
break;
case node.CDATA_SECTION_NODE:
rrNode = this.createCDATASection();
break;
case node.COMMENT_NODE:
rrNode = this.createComment(
((node as unknown) as Comment).textContent || '',
);
break;
default:
return;
}
rrNode.__sn = serializedNodeWithId;
this.mirror.set(serializedNodeWithId.id, rrNode);
} else {
rrNode = this.mirror.get(serializedNodeWithId.id);
rrNode.parentElement = null;
rrNode.parentNode = null;
rrNode.children = [];
}
const parentNode = node.parentElement || node.parentNode;
if (parentNode) {
const parentSN = ((parentNode as unknown) as INode).__sn;
const parentRRNode = this.mirror.get(parentSN.id);
parentRRNode.appendChild(rrNode);
rrNode.parentNode = parentRRNode;
rrNode.parentElement =
parentRRNode instanceof RRElement ? parentRRNode : null;
}
if (
serializedNodeWithId.type === NodeType.Document ||
serializedNodeWithId.type === NodeType.Element
) {
node.childNodes.forEach((node) => walk((node as unknown) as INode));
}
}.bind(this);
if (dom) {
this.destroyTree();
walk((dom as unknown) as INode);
}
}
destroyTree() {
this.children = [];
this.mirror.clear();
}
toString() {
return super.toString('RRDocument');
}
}
export class RRDocumentType extends RRNode {
readonly name: string;
readonly publicId: string;
readonly systemId: string;
constructor(qualifiedName: string, publicId: string, systemId: string) {
super();
this.name = qualifiedName;
this.publicId = publicId;
this.systemId = systemId;
}
toString() {
return super.toString('RRDocumentType');
}
}
export class RRElement extends RRNode {
tagName: string;
attributes: Record<string, string | number | boolean> = {};
scrollLeft: number = 0;
scrollTop: number = 0;
shadowRoot: RRElement | null = null;
export class RRDocumentType extends BaseRRDocumentTypeImpl(RRNode) {}
export class RRElement extends BaseRRElementImpl(RRNode) {
private _style: CSSStyleDeclarationType;
constructor(tagName: string) {
super();
this.tagName = tagName;
}
get classList() {
return new ClassList(
this.attributes.class as string | undefined,
(newClassName) => {
this.attributes.class = newClassName;
super(tagName);
this._style = new cssstyle.CSSStyleDeclaration();
const style = this._style;
Object.defineProperty(this.attributes, 'style', {
get() {
return style.cssText;
},
);
set(cssText: string) {
style.cssText = cssText;
},
});
}
get id() {
return this.attributes.id;
}
get className() {
return this.attributes.class || '';
}
get textContent() {
return '';
}
set textContent(newText: string) {}
get style() {
const style = (this.attributes.style
? parseCSSText(this.attributes.style as string)
: {}) as Record<string, string> & {
setProperty: (
name: string,
value: string | null,
priority?: string | null,
) => void;
};
style.setProperty = (name: string, value: string | null) => {
const normalizedName = camelize(name);
if (!value) delete style[normalizedName];
else style[normalizedName] = value;
this.attributes.style = toCSSText(style);
};
// This is used to bypass the smoothscroll polyfill in rrweb player.
style.scrollBehavior = '';
return style;
return (this._style as unknown) as CSSStyleDeclaration;
}
get firstElementChild(): RRElement | null {
for (let child of this.children)
if (child instanceof RRElement) return child;
return null;
attachShadow(_init: ShadowRootInit): RRElement {
return super.attachShadow(_init) as RRElement;
}
get nextElementSibling(): RRElement | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.children;
let index = siblings.indexOf(this);
for (let i = index + 1; i < siblings.length; i++)
if (siblings[i] instanceof RRElement) return siblings[i] as RRElement;
return null;
appendChild(newChild: RRNode): RRNode {
return super.appendChild(newChild) as RRNode;
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
return super.insertBefore(newChild, refChild) as RRNode;
}
getAttribute(name: string) {
@@ -531,57 +226,44 @@ export class RRElement extends RRNode {
this.attributes[name.toLowerCase()] = attribute;
}
hasAttribute(name: string) {
return (name && name.toLowerCase()) in this.attributes;
}
setAttributeNS(
_namespace: string | null,
qualifiedName: string,
value: string,
): void {
this.setAttribute(qualifiedName, value);
}
removeAttribute(name: string) {
delete this.attributes[name];
delete this.attributes[name.toLowerCase()];
}
appendChild(newChild: RRNode): RRNode {
this.children.push(newChild);
newChild.parentNode = this;
newChild.parentElement = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
get firstElementChild(): RRElement | null {
for (let child of this.childNodes)
if (child.RRNodeType === RRNodeType.Element) return child as RRElement;
return null;
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.children.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.children.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
get nextElementSibling(): RRElement | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.childNodes;
let index = siblings.indexOf(this);
for (let i = index + 1; i < siblings.length; i++)
if (siblings[i] instanceof RRElement) return siblings[i] as RRElement;
return null;
}
querySelectorAll(selectors: string): RRNode[] {
const result: RRElement[] = [];
if (this.ownerDocument !== null) {
return (this.ownerDocument.nwsapi.select(
((this.ownerDocument as RRDocument).nwsapi.select(
selectors,
(this as unknown) as Element,
(element) => {
if (((element as unknown) as RRElement) !== this)
result.push((element as unknown) as RRElement);
},
) as unknown) as RRNode[];
}
return [];
return result;
}
getElementById(elementId: string): RRElement | null {
if (this instanceof RRElement && this.id === elementId) return this;
for (const child of this.children) {
if (this.id === elementId) return this;
for (const child of this.childNodes) {
if (child instanceof RRElement) {
const result = child.getElementById(elementId);
if (result !== null) return result;
@@ -596,12 +278,12 @@ export class RRElement extends RRNode {
// Make sure this element has all queried class names.
if (
this instanceof RRElement &&
queryClassList.filter((queriedClassName) =>
this.classList.some((name) => name === queriedClassName),
).length == queryClassList.length
queryClassList.classes.filter((queriedClassName) =>
this.classList.classes.some((name) => name === queriedClassName),
).length == queryClassList.classes.length
)
elements.push(this);
for (const child of this.children) {
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByClassName(className));
}
@@ -613,32 +295,12 @@ export class RRElement extends RRNode {
const normalizedTagName = tagName.toUpperCase();
if (this instanceof RRElement && this.tagName === normalizedTagName)
elements.push(this);
for (const child of this.children) {
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByTagName(tagName));
}
return elements;
}
dispatchEvent(_event: Event) {
return true;
}
/**
* Creates a shadow root for element and returns it.
*/
attachShadow(init: ShadowRootInit): RRElement {
this.shadowRoot = init.mode === 'open' ? this : null;
return this;
}
toString() {
let attributeString = '';
for (let attribute in this.attributes) {
attributeString += `${attribute}="${this.attributes[attribute]}" `;
}
return `${super.toString(this.tagName)} ${attributeString}`;
}
}
export class RRImageElement extends RRElement {
@@ -648,16 +310,7 @@ export class RRImageElement extends RRElement {
onload: ((this: GlobalEventHandlers, ev: Event) => any) | null;
}
export class RRMediaElement extends RRElement {
currentTime: number = 0;
paused: boolean = true;
async play() {
this.paused = false;
}
async pause() {
this.paused = true;
}
}
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
export class RRCanvasElement extends RRElement {
/**
@@ -675,7 +328,7 @@ export class RRStyleElement extends RRElement {
if (!this._sheet) {
let result = '';
for (let child of this.childNodes)
if (child.nodeType === NodeType.Text)
if (child.RRNodeType === RRNodeType.Text)
result += (child as RRText).textContent;
this._sheet = cssom.parse(result);
}
@@ -683,7 +336,7 @@ export class RRStyleElement extends RRElement {
}
}
export class RRIframeElement extends RRElement {
export class RRIFrameElement extends RRElement {
width: string = '';
height: string = '';
src: string = '';
@@ -699,89 +352,27 @@ export class RRIframeElement extends RRElement {
}
}
export class RRText extends RRNode {
textContent: string;
constructor(data: string) {
super();
this.textContent = data;
}
toString() {
return `${super.toString('RRText')} text=${JSON.stringify(
this.textContent,
)}`;
}
export class RRText extends BaseRRTextImpl(RRNode) {
readonly nodeName: '#text' = '#text';
}
export class RRComment extends RRNode {
data: string;
constructor(data: string) {
super();
this.data = data;
}
toString() {
return `${super.toString('RRComment')} data=${JSON.stringify(this.data)}`;
}
export class RRComment extends BaseRRCommentImpl(RRNode) {
readonly nodeName: '#comment' = '#comment';
}
export class RRCDATASection extends RRNode {
data: string;
constructor(data: string) {
super();
this.data = data;
}
toString() {
return `${super.toString('RRCDATASection')} data=${JSON.stringify(
this.data,
)}`;
}
export class RRCDATASection extends BaseRRCDATASectionImpl(RRNode) {
readonly nodeName: '#cdata-section' = '#cdata-section';
}
interface RRElementTagNameMap {
img: RRImageElement;
audio: RRMediaElement;
canvas: RRCanvasElement;
iframe: RRIFrameElement;
img: RRImageElement;
style: RRStyleElement;
video: RRMediaElement;
}
type RRElementType<
K extends keyof HTMLElementTagNameMap
> = K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
class ClassList extends Array {
private onChange: ((newClassText: string) => void) | undefined;
constructor(
classText?: string,
onChange?: ((newClassText: string) => void) | undefined,
) {
super();
if (classText) {
const classes = classText.trim().split(/\s+/);
super.push(...classes);
}
this.onChange = onChange;
}
add = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
if (super.indexOf(className) >= 0) continue;
super.push(className);
}
this.onChange && this.onChange(super.join(' '));
};
remove = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
const index = super.indexOf(className);
if (index < 0) continue;
super.splice(index, 1);
}
this.onChange && this.onChange(super.join(' '));
};
}

View File

@@ -0,0 +1,723 @@
import { NodeType as RRNodeType } from 'rrweb-snapshot';
import { parseCSSText, camelize, toCSSText } from './style';
export interface IRRNode {
parentElement: IRRNode | null;
parentNode: IRRNode | null;
childNodes: IRRNode[];
ownerDocument: IRRDocument;
readonly ELEMENT_NODE: number;
readonly TEXT_NODE: number;
// corresponding nodeType value of standard HTML Node
readonly nodeType: number;
readonly nodeName: string; // https://dom.spec.whatwg.org/#dom-node-nodename
readonly RRNodeType: RRNodeType;
firstChild: IRRNode | null;
lastChild: IRRNode | null;
nextSibling: IRRNode | null;
textContent: string | null;
contains(node: IRRNode): boolean;
appendChild(newChild: IRRNode): IRRNode;
insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode;
removeChild(node: IRRNode): IRRNode;
toString(): string;
}
export interface IRRDocument extends IRRNode {
documentElement: IRRElement | null;
body: IRRElement | null;
head: IRRElement | null;
implementation: IRRDocument;
firstElementChild: IRRElement | null;
readonly nodeName: '#document';
compatMode: 'BackCompat' | 'CSS1Compat';
createDocument(
_namespace: string | null,
_qualifiedName: string | null,
_doctype?: DocumentType | null,
): IRRDocument;
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
): IRRDocumentType;
createElement(tagName: string): IRRElement;
createElementNS(_namespaceURI: string, qualifiedName: string): IRRElement;
createTextNode(data: string): IRRText;
createComment(data: string): IRRComment;
createCDATASection(data: string): IRRCDATASection;
open(): void;
close(): void;
write(content: string): void;
}
export interface IRRElement extends IRRNode {
tagName: string;
attributes: Record<string, string>;
shadowRoot: IRRElement | null;
scrollLeft?: number;
scrollTop?: number;
id: string;
className: string;
classList: ClassList;
style: CSSStyleDeclaration;
attachShadow(init: ShadowRootInit): IRRElement;
getAttribute(name: string): string | null;
setAttribute(name: string, attribute: string): void;
setAttributeNS(
namespace: string | null,
qualifiedName: string,
value: string,
): void;
removeAttribute(name: string): void;
dispatchEvent(event: Event): boolean;
}
export interface IRRDocumentType extends IRRNode {
readonly name: string;
readonly publicId: string;
readonly systemId: string;
}
export interface IRRText extends IRRNode {
readonly nodeName: '#text';
data: string;
}
export interface IRRComment extends IRRNode {
readonly nodeName: '#comment';
data: string;
}
export interface IRRCDATASection extends IRRNode {
readonly nodeName: '#cdata-section';
data: string;
}
type ConstrainedConstructor<T = {}> = new (...args: any[]) => T;
/**
* This is designed as an abstract class so it should never be instantiated.
*/
export class BaseRRNode implements IRRNode {
public childNodes: IRRNode[] = [];
public parentElement: IRRNode | null = null;
public parentNode: IRRNode | null = null;
public textContent: string | null;
public ownerDocument: IRRDocument;
public readonly ELEMENT_NODE: number = NodeType.ELEMENT_NODE;
public readonly TEXT_NODE: number = NodeType.TEXT_NODE;
// corresponding nodeType value of standard HTML Node
public readonly nodeType: number;
public readonly nodeName: string;
public readonly RRNodeType: RRNodeType;
constructor(...args: any[]) {}
public get firstChild(): IRRNode | null {
return this.childNodes[0] || null;
}
public get lastChild(): IRRNode | null {
return this.childNodes[this.childNodes.length - 1] || null;
}
public get nextSibling(): IRRNode | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.childNodes;
let index = siblings.indexOf(this);
return siblings[index + 1] || null;
}
public contains(node: IRRNode) {
if (node === this) return true;
for (const child of this.childNodes) {
if (child.contains(node)) return true;
}
return false;
}
public appendChild(_newChild: IRRNode): IRRNode {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': This RRNode type does not support this method.`,
);
}
public insertBefore(_newChild: IRRNode, _refChild: IRRNode | null): IRRNode {
throw new Error(
`RRDomException: Failed to execute 'insertBefore' on 'RRNode': This RRNode type does not support this method.`,
);
}
public removeChild(node: IRRNode): IRRNode {
throw new Error(
`RRDomException: Failed to execute 'removeChild' on 'RRNode': This RRNode type does not support this method.`,
);
}
public toString(): string {
return 'RRNode';
}
}
export function BaseRRDocumentImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
return class BaseRRDocument extends RRNodeClass implements IRRDocument {
public readonly nodeType: number = NodeType.DOCUMENT_NODE;
public readonly nodeName: '#document' = '#document';
public readonly compatMode: 'BackCompat' | 'CSS1Compat' = 'CSS1Compat';
public readonly RRNodeType = RRNodeType.Document;
public textContent: string | null = null;
public get documentElement(): IRRElement | null {
return (
(this.childNodes.find(
(node) =>
node.RRNodeType === RRNodeType.Element &&
(node as IRRElement).tagName === 'HTML',
) as IRRElement) || null
);
}
public get body(): IRRElement | null {
return (
(this.documentElement?.childNodes.find(
(node) =>
node.RRNodeType === RRNodeType.Element &&
(node as IRRElement).tagName === 'BODY',
) as IRRElement) || null
);
}
public get head(): IRRElement | null {
return (
(this.documentElement?.childNodes.find(
(node) =>
node.RRNodeType === RRNodeType.Element &&
(node as IRRElement).tagName === 'HEAD',
) as IRRElement) || null
);
}
public get implementation(): IRRDocument {
return this;
}
public get firstElementChild(): IRRElement | null {
return this.documentElement;
}
public appendChild(childNode: IRRNode): IRRNode {
const nodeType = childNode.RRNodeType;
if (
nodeType === RRNodeType.Element ||
nodeType === RRNodeType.DocumentType
) {
if (this.childNodes.some((s) => s.RRNodeType === nodeType)) {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': Only one ${
nodeType === RRNodeType.Element ? 'RRElement' : 'RRDoctype'
} on RRDocument allowed.`,
);
}
}
childNode.parentElement = null;
childNode.parentNode = this;
this.childNodes.push(childNode);
return childNode;
}
public insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode {
const nodeType = newChild.RRNodeType;
if (
nodeType === RRNodeType.Element ||
nodeType === RRNodeType.DocumentType
) {
if (this.childNodes.some((s) => s.RRNodeType === nodeType)) {
throw new Error(
`RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one ${
nodeType === RRNodeType.Element ? 'RRElement' : 'RRDoctype'
} on RRDocument allowed.`,
);
}
}
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.childNodes.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.childNodes.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
return newChild;
}
public removeChild(node: IRRNode) {
const indexOfChild = this.childNodes.indexOf(node);
if (indexOfChild === -1)
throw new Error(
"Failed to execute 'removeChild' on 'RRDocument': The RRNode to be removed is not a child of this RRNode.",
);
this.childNodes.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
return node;
}
public open() {
this.childNodes = [];
}
public close() {}
/**
* Adhoc implementation for setting xhtml namespace in rebuilt.ts (rrweb-snapshot).
* There are two lines used this function:
* 1. doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "">')
* 2. doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">')
*/
public write(content: string) {
let publicId;
if (
content ===
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "">'
)
publicId = '-//W3C//DTD XHTML 1.0 Transitional//EN';
else if (
content ===
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">'
)
publicId = '-//W3C//DTD HTML 4.0 Transitional//EN';
if (publicId) {
const doctype = this.createDocumentType('html', publicId, '');
this.open();
this.appendChild(doctype);
}
}
createDocument(
_namespace: string | null,
_qualifiedName: string | null,
_doctype?: DocumentType | null,
): IRRDocument {
return new BaseRRDocument();
}
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
): IRRDocumentType {
const doctype = new (BaseRRDocumentTypeImpl(BaseRRNode))(
qualifiedName,
publicId,
systemId,
);
doctype.ownerDocument = this;
return doctype;
}
createElement(tagName: string): IRRElement {
const element = new (BaseRRElementImpl(BaseRRNode))(tagName);
element.ownerDocument = this;
return element;
}
createElementNS(_namespaceURI: string, qualifiedName: string): IRRElement {
return this.createElement(qualifiedName);
}
createTextNode(data: string): IRRText {
const text = new (BaseRRTextImpl(BaseRRNode))(data);
text.ownerDocument = this;
return text;
}
createComment(data: string): IRRComment {
const comment = new (BaseRRCommentImpl(BaseRRNode))(data);
comment.ownerDocument = this;
return comment;
}
createCDATASection(data: string): IRRCDATASection {
const CDATASection = new (BaseRRCDATASectionImpl(BaseRRNode))(data);
CDATASection.ownerDocument = this;
return CDATASection;
}
toString() {
return 'RRDocument';
}
};
}
export function BaseRRDocumentTypeImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRDocumentType
extends RRNodeClass
implements IRRDocumentType {
public readonly nodeType: number = NodeType.DOCUMENT_TYPE_NODE;
public readonly RRNodeType = RRNodeType.DocumentType;
public readonly nodeName: string;
public readonly name: string;
public readonly publicId: string;
public readonly systemId: string;
public textContent: string | null = null;
constructor(qualifiedName: string, publicId: string, systemId: string) {
super();
this.name = qualifiedName;
this.publicId = publicId;
this.systemId = systemId;
this.nodeName = qualifiedName;
}
toString() {
return 'RRDocumentType';
}
};
}
export function BaseRRElementImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRElement extends RRNodeClass implements IRRElement {
public readonly nodeType: number = NodeType.ELEMENT_NODE;
public readonly RRNodeType = RRNodeType.Element;
public readonly nodeName: string;
public tagName: string;
public attributes: Record<string, string> = {};
public shadowRoot: IRRElement | null = null;
public scrollLeft?: number;
public scrollTop?: number;
constructor(tagName: string) {
super();
this.tagName = tagName.toUpperCase();
this.nodeName = tagName.toUpperCase();
}
public get textContent(): string {
let result = '';
this.childNodes.forEach((node) => (result += node.textContent));
return result;
}
public set textContent(textContent: string) {
this.childNodes = [this.ownerDocument.createTextNode(textContent)];
}
public get classList(): ClassList {
return new ClassList(
this.attributes.class as string | undefined,
(newClassName) => {
this.attributes.class = newClassName;
},
);
}
public get id() {
return this.attributes.id || '';
}
public get className() {
return this.attributes.class || '';
}
public get style() {
const style = (this.attributes.style
? parseCSSText(this.attributes.style as string)
: {}) as CSSStyleDeclaration;
const hyphenateRE = /\B([A-Z])/g;
style.setProperty = (
name: string,
value: string | null,
priority?: string,
) => {
if (hyphenateRE.test(name)) return;
const normalizedName = camelize(name);
if (!value) delete style[normalizedName];
else style[normalizedName] = value;
if (priority === 'important') style[normalizedName] += ' !important';
this.attributes.style = toCSSText(style);
};
style.removeProperty = (name: string) => {
if (hyphenateRE.test(name)) return '';
const normalizedName = camelize(name);
const value = style[normalizedName] || '';
delete style[normalizedName];
this.attributes.style = toCSSText(style);
return value;
};
return style;
}
public getAttribute(name: string) {
return this.attributes[name] || null;
}
public setAttribute(name: string, attribute: string) {
this.attributes[name] = attribute;
}
public setAttributeNS(
_namespace: string | null,
qualifiedName: string,
value: string,
): void {
this.setAttribute(qualifiedName, value);
}
public removeAttribute(name: string) {
delete this.attributes[name];
}
public appendChild(newChild: IRRNode): IRRNode {
this.childNodes.push(newChild);
newChild.parentNode = this;
newChild.parentElement = this;
return newChild;
}
public insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.childNodes.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.childNodes.splice(childIndex, 0, newChild);
newChild.parentElement = this;
newChild.parentNode = this;
return newChild;
}
public removeChild(node: IRRNode): IRRNode {
const indexOfChild = this.childNodes.indexOf(node);
if (indexOfChild === -1)
throw new Error(
"Failed to execute 'removeChild' on 'RRElement': The RRNode to be removed is not a child of this RRNode.",
);
this.childNodes.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
return node;
}
public attachShadow(_init: ShadowRootInit): IRRElement {
const shadowRoot = this.ownerDocument.createElement('SHADOWROOT');
this.shadowRoot = shadowRoot;
return shadowRoot;
}
public dispatchEvent(_event: Event) {
return true;
}
toString() {
let attributeString = '';
for (let attribute in this.attributes) {
attributeString += `${attribute}="${this.attributes[attribute]}" `;
}
return `${this.tagName} ${attributeString}`;
}
};
}
export function BaseRRMediaElementImpl<
RRElement extends ConstrainedConstructor<IRRElement>
>(RRElementClass: RRElement) {
return class BaseRRMediaElement extends RRElementClass {
public currentTime?: number;
public volume?: number;
public paused?: boolean;
public muted?: boolean;
attachShadow(_init: ShadowRootInit): IRRElement {
throw new Error(
`RRDomException: Failed to execute 'attachShadow' on 'RRElement': This RRElement does not support attachShadow`,
);
}
public play() {
this.paused = false;
}
public pause() {
this.paused = true;
}
};
}
export function BaseRRTextImpl<RRNode extends ConstrainedConstructor<IRRNode>>(
RRNodeClass: RRNode,
) {
// @ts-ignore
return class BaseRRText extends RRNodeClass implements IRRText {
public readonly nodeType: number = NodeType.TEXT_NODE;
public readonly nodeName: '#text' = '#text';
public readonly RRNodeType = RRNodeType.Text;
public data: string;
constructor(data: string) {
super();
this.data = data;
}
public get textContent(): string {
return this.data;
}
public set textContent(textContent: string) {
this.data = textContent;
}
toString() {
return `RRText text=${JSON.stringify(this.data)}`;
}
};
}
export function BaseRRCommentImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRComment extends RRNodeClass implements IRRComment {
public readonly nodeType: number = NodeType.COMMENT_NODE;
public readonly nodeName: '#comment' = '#comment';
public readonly RRNodeType = RRNodeType.Comment;
public data: string;
constructor(data: string) {
super();
this.data = data;
}
public get textContent(): string {
return this.data;
}
public set textContent(textContent: string) {
this.data = textContent;
}
toString() {
return `RRComment text=${JSON.stringify(this.data)}`;
}
};
}
export function BaseRRCDATASectionImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRCDATASection
extends RRNodeClass
implements IRRCDATASection {
public readonly nodeName: '#cdata-section' = '#cdata-section';
public readonly nodeType: number = NodeType.CDATA_SECTION_NODE;
public readonly RRNodeType = RRNodeType.CDATA;
public data: string;
constructor(data: string) {
super();
this.data = data;
}
public get textContent(): string {
return this.data;
}
public set textContent(textContent: string) {
this.data = textContent;
}
toString() {
return `RRCDATASection data=${JSON.stringify(this.data)}`;
}
};
}
export class ClassList {
private onChange: ((newClassText: string) => void) | undefined;
classes: string[] = [];
constructor(
classText?: string,
onChange?: ((newClassText: string) => void) | undefined,
) {
if (classText) {
const classes = classText.trim().split(/\s+/);
this.classes.push(...classes);
}
this.onChange = onChange;
}
add = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
if (this.classes.indexOf(className) >= 0) continue;
this.classes.push(className);
}
this.onChange && this.onChange(this.classes.join(' '));
};
remove = (...classNames: string[]) => {
this.classes = this.classes.filter(
(item) => classNames.indexOf(item) === -1,
);
this.onChange && this.onChange(this.classes.join(' '));
};
}
export type CSSStyleDeclaration = Record<string, string> & {
setProperty: (
name: string,
value: string | null,
priority?: string | null,
) => void;
removeProperty: (name: string) => string;
};
// Enumerate nodeType value of standard HTML Node.
export enum NodeType {
PLACEHOLDER, // This isn't a node type. Enum type value starts from zero but NodeType value starts from 1.
ELEMENT_NODE,
ATTRIBUTE_NODE,
TEXT_NODE,
CDATA_SECTION_NODE,
ENTITY_REFERENCE_NODE,
ENTITY_NODE,
PROCESSING_INSTRUCTION_NODE,
COMMENT_NODE,
DOCUMENT_NODE,
DOCUMENT_TYPE_NODE,
DOCUMENT_FRAGMENT_NODE,
}

View File

@@ -2,6 +2,8 @@ import { RRDocument, RRNode } from './document-nodejs';
/**
* Polyfill the performance for nodejs.
* Note: The performance api is available through the global object from nodejs v16.0.0.
* https://github.com/nodejs/node/pull/37970
*/
export function polyfillPerformance() {
if (typeof window !== 'undefined' || 'performance' in global) return;
@@ -80,8 +82,8 @@ export function polyfillDocument() {
const rrdom = new RRDocument();
(() => {
rrdom.appendChild(rrdom.createElement('html'));
rrdom.documentElement.appendChild(rrdom.createElement('head'));
rrdom.documentElement.appendChild(rrdom.createElement('body'));
rrdom.documentElement!.appendChild(rrdom.createElement('head'));
rrdom.documentElement!.appendChild(rrdom.createElement('body'));
})();
global.document = (rrdom as unknown) as Document;
}

View File

@@ -1,40 +1,45 @@
export function parseCSSText(cssText: string): Record<string, string> {
const res: Record<string, string> = {};
const listDelimiter = /;(?![^(]*\))/g;
const propertyDelimiter = /:(.+)/;
cssText.split(listDelimiter).forEach(function (item) {
if (item) {
const tmp = item.split(propertyDelimiter);
tmp.length > 1 && (res[camelize(tmp[0].trim())] = tmp[1].trim());
}
});
return res;
const res: Record<string, string> = {};
const listDelimiter = /;(?![^(]*\))/g;
const propertyDelimiter = /:(.+)/;
const comment = /\/\*.*?\*\//g;
cssText
.replace(comment, '')
.split(listDelimiter)
.forEach(function (item) {
if (item) {
const tmp = item.split(propertyDelimiter);
tmp.length > 1 && (res[camelize(tmp[0].trim())] = tmp[1].trim());
}
});
return res;
}
export function toCSSText(style: Record<string, string>): string {
const properties = [];
for (let name in style) {
const value = style[name];
if (typeof value !== 'string') continue;
const normalizedName = hyphenate(name);
properties.push(`${normalizedName}: ${value};`);
}
export function toCSSText(style: Record<string, string>): string {
const properties = [];
for (let name in style) {
const value = style[name];
if (typeof value !== 'string') continue;
const normalizedName = hyphenate(name);
properties.push(`${normalizedName}:${value};`);
}
return properties.join(' ');
}
/**
* Camelize a hyphen-delimited string.
*/
const camelizeRE = /-(\w)/g;
export const camelize = (str: string): string => {
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''));
};
/**
* Hyphenate a camelCase string.
*/
const hyphenateRE = /\B([A-Z])/g;
export const hyphenate = (str: string): string => {
return str.replace(hyphenateRE, '-$1').toLowerCase();
};
return properties.join(' ');
}
/**
* Camelize a hyphen-delimited string.
*/
const camelizeRE = /-([a-z])/g;
const CUSTOM_PROPERTY_REGEX = /^--[a-zA-Z0-9-]+$/;
export const camelize = (str: string): string => {
if (CUSTOM_PROPERTY_REGEX.test(str)) return str;
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''));
};
/**
* Hyphenate a camelCase string.
*/
const hyphenateRE = /\B([A-Z])/g;
export const hyphenate = (str: string): string => {
return str.replace(hyphenateRE, '-$1').toLowerCase();
};

View File

@@ -0,0 +1,450 @@
import {
NodeType as RRNodeType,
createMirror as createNodeMirror,
} from 'rrweb-snapshot';
import type {
Mirror as NodeMirror,
IMirror,
serializedNodeWithId,
} from 'rrweb-snapshot';
import type {
canvasMutationData,
canvasEventWithTime,
inputData,
scrollData,
} from 'rrweb/src/types';
import {
BaseRRNode as RRNode,
BaseRRCDATASectionImpl,
BaseRRCommentImpl,
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRTextImpl,
IRRDocument,
IRRElement,
IRRNode,
NodeType,
IRRDocumentType,
IRRText,
IRRComment,
} from './document';
import type { VirtualStyleRules } from './diff';
export class RRDocument extends BaseRRDocumentImpl(RRNode) {
// In the rrweb replayer, there are some unserialized nodes like the element that stores the injected style rules.
// These unserialized nodes may interfere the execution of the diff algorithm.
// The id of serialized node is larger than 0. So this value less than 0 is used as id for these unserialized nodes.
private _unserializedId = -1;
/**
* Every time the id is used, it will minus 1 automatically to avoid collisions.
*/
public get unserializedId(): number {
return this._unserializedId--;
}
public mirror: Mirror = createMirror();
public scrollData: scrollData | null = null;
constructor(mirror?: Mirror) {
super();
if (mirror) {
this.mirror = mirror;
}
}
createDocument(
_namespace: string | null,
_qualifiedName: string | null,
_doctype?: DocumentType | null,
) {
return new RRDocument();
}
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
) {
const documentTypeNode = new RRDocumentType(
qualifiedName,
publicId,
systemId,
);
documentTypeNode.ownerDocument = this;
return documentTypeNode;
}
createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
): RRElementType<K>;
createElement(tagName: string): RRElement;
createElement(tagName: string) {
const upperTagName = tagName.toUpperCase();
let element;
switch (upperTagName) {
case 'AUDIO':
case 'VIDEO':
element = new RRMediaElement(upperTagName);
break;
case 'IFRAME':
element = new RRIFrameElement(upperTagName, this.mirror);
break;
case 'CANVAS':
element = new RRCanvasElement(upperTagName);
break;
case 'STYLE':
element = new RRStyleElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
break;
}
element.ownerDocument = this;
return element;
}
createComment(data: string) {
const commentNode = new RRComment(data);
commentNode.ownerDocument = this;
return commentNode;
}
createCDATASection(data: string) {
const sectionNode = new RRCDATASection(data);
sectionNode.ownerDocument = this;
return sectionNode;
}
createTextNode(data: string) {
const textNode = new RRText(data);
textNode.ownerDocument = this;
return textNode;
}
destroyTree() {
this.childNodes = [];
this.mirror.reset();
}
open() {
super.open();
this._unserializedId = -1;
}
}
export const RRDocumentType = BaseRRDocumentTypeImpl(RRNode);
export class RRElement extends BaseRRElementImpl(RRNode) {
inputData: inputData | null = null;
scrollData: scrollData | null = null;
}
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
export class RRCanvasElement extends RRElement implements IRRElement {
public canvasMutations: {
event: canvasEventWithTime;
mutation: canvasMutationData;
}[] = [];
/**
* This is a dummy implementation to distinguish RRCanvasElement from real HTMLCanvasElement.
*/
getContext(): RenderingContext | null {
return null;
}
}
export class RRStyleElement extends RRElement {
public rules: VirtualStyleRules = [];
}
export class RRIFrameElement extends RRElement {
contentDocument: RRDocument = new RRDocument();
constructor(upperTagName: string, mirror: Mirror) {
super(upperTagName);
this.contentDocument.mirror = mirror;
}
}
export const RRText = BaseRRTextImpl(RRNode);
export type RRText = typeof RRText;
export const RRComment = BaseRRCommentImpl(RRNode);
export type RRComment = typeof RRComment;
export const RRCDATASection = BaseRRCDATASectionImpl(RRNode);
export type RRCDATASection = typeof RRCDATASection;
interface RRElementTagNameMap {
audio: RRMediaElement;
canvas: RRCanvasElement;
iframe: RRIFrameElement;
style: RRStyleElement;
video: RRMediaElement;
}
type RRElementType<
K extends keyof HTMLElementTagNameMap
> = K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
function getValidTagName(element: HTMLElement): string {
// https://github.com/rrweb-io/rrweb-snapshot/issues/56
if (element instanceof HTMLFormElement) {
return 'FORM';
}
return element.tagName.toUpperCase();
}
/**
* Build a RRNode from a real Node.
* @param node the real Node
* @param rrdom the RRDocument
* @param domMirror the NodeMirror that records the real document tree
* @returns the built RRNode
*/
export function buildFromNode(
node: Node,
rrdom: IRRDocument,
domMirror: NodeMirror,
parentRRNode?: IRRNode | null,
): IRRNode | null {
let rrNode: IRRNode;
switch (node.nodeType) {
case NodeType.DOCUMENT_NODE:
if (parentRRNode && parentRRNode.nodeName === 'IFRAME')
rrNode = (parentRRNode as RRIFrameElement).contentDocument;
else {
rrNode = rrdom;
(rrNode as IRRDocument).compatMode = (node as Document).compatMode as
| 'BackCompat'
| 'CSS1Compat';
}
break;
case NodeType.DOCUMENT_TYPE_NODE:
const documentType = (node as Node) as DocumentType;
rrNode = rrdom.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case NodeType.ELEMENT_NODE:
const elementNode = (node as Node) as HTMLElement;
const tagName = getValidTagName(elementNode);
rrNode = rrdom.createElement(tagName);
const rrElement = rrNode as IRRElement;
for (const { name, value } of Array.from(elementNode.attributes)) {
rrElement.attributes[name] = value;
}
elementNode.scrollLeft && (rrElement.scrollLeft = elementNode.scrollLeft);
elementNode.scrollTop && (rrElement.scrollTop = elementNode.scrollTop);
/**
* We don't have to record special values of input elements at the beginning.
* Because if these values are changed later, the mutation will be applied through the batched input events on its RRElement after the diff algorithm is executed.
*/
break;
case NodeType.TEXT_NODE:
rrNode = rrdom.createTextNode(((node as Node) as Text).textContent || '');
break;
case NodeType.CDATA_SECTION_NODE:
rrNode = rrdom.createCDATASection(((node as Node) as CDATASection).data);
break;
case NodeType.COMMENT_NODE:
rrNode = rrdom.createComment(
((node as Node) as Comment).textContent || '',
);
break;
// if node is a shadow root
case NodeType.DOCUMENT_FRAGMENT_NODE:
rrNode = (parentRRNode as IRRElement).attachShadow({ mode: 'open' });
break;
default:
return null;
}
let sn: serializedNodeWithId | null = domMirror.getMeta(node);
if (rrdom instanceof RRDocument) {
if (!sn) {
sn = getDefaultSN(rrNode, rrdom.unserializedId);
domMirror.add(node, sn);
}
rrdom.mirror.add(rrNode, { ...sn });
}
return rrNode;
}
/**
* Build a RRDocument from a real document tree.
* @param dom the real document tree
* @param domMirror the NodeMirror that records the real document tree
* @param rrdom the rrdom object to be constructed
* @returns the build rrdom
*/
export function buildFromDom(
dom: Document,
domMirror: NodeMirror = createNodeMirror(),
rrdom: IRRDocument = new RRDocument(),
) {
function walk(node: Node, parentRRNode: IRRNode | null) {
const rrNode = buildFromNode(node, rrdom, domMirror, parentRRNode);
if (rrNode === null) return;
if (
// if the parentRRNode isn't a RRIFrameElement
parentRRNode?.nodeName !== 'IFRAME' &&
// if node isn't a shadow root
node.nodeType !== NodeType.DOCUMENT_FRAGMENT_NODE
) {
parentRRNode?.appendChild(rrNode);
rrNode.parentNode = parentRRNode;
rrNode.parentElement = parentRRNode as RRElement;
}
if (node.nodeName === 'IFRAME') {
walk((node as HTMLIFrameElement).contentDocument!, rrNode);
} else if (
node.nodeType === NodeType.DOCUMENT_NODE ||
node.nodeType === NodeType.ELEMENT_NODE ||
node.nodeType === NodeType.DOCUMENT_FRAGMENT_NODE
) {
// if the node is a shadow dom
if (
node.nodeType === NodeType.ELEMENT_NODE &&
((node as Node) as HTMLElement).shadowRoot
)
walk(((node as Node) as HTMLElement).shadowRoot!, rrNode);
node.childNodes.forEach((childNode) => walk(childNode, rrNode));
}
}
walk(dom, null);
return rrdom;
}
export function createMirror(): Mirror {
return new Mirror();
}
// based on Mirror from rrweb-snapshots
export class Mirror implements IMirror<RRNode> {
private idNodeMap: Map<number, RRNode> = new Map();
private nodeMetaMap: WeakMap<RRNode, serializedNodeWithId> = new WeakMap();
getId(n: RRNode | undefined | null): number {
if (!n) return -1;
const id = this.getMeta(n)?.id;
// if n is not a serialized Node, use -1 as its id.
return id ?? -1;
}
getNode(id: number): RRNode | null {
return this.idNodeMap.get(id) || null;
}
getIds(): number[] {
return Array.from(this.idNodeMap.keys());
}
getMeta(n: RRNode): serializedNodeWithId | null {
return this.nodeMetaMap.get(n) || null;
}
// removes the node from idNodeMap
// doesn't remove the node from nodeMetaMap
removeNodeFromMap(n: RRNode) {
const id = this.getId(n);
this.idNodeMap.delete(id);
if (n.childNodes) {
n.childNodes.forEach((childNode) => this.removeNodeFromMap(childNode));
}
}
has(id: number): boolean {
return this.idNodeMap.has(id);
}
hasNode(node: RRNode): boolean {
return this.nodeMetaMap.has(node);
}
add(n: RRNode, meta: serializedNodeWithId) {
const id = meta.id;
this.idNodeMap.set(id, n);
this.nodeMetaMap.set(n, meta);
}
replace(id: number, n: RRNode) {
this.idNodeMap.set(id, n);
}
reset() {
this.idNodeMap = new Map();
this.nodeMetaMap = new WeakMap();
}
}
/**
* Get a default serializedNodeWithId value for a RRNode.
* @param id the serialized id to assign
*/
export function getDefaultSN(node: IRRNode, id: number): serializedNodeWithId {
switch (node.RRNodeType) {
case RRNodeType.Document:
return {
id,
type: node.RRNodeType,
childNodes: [],
};
case RRNodeType.DocumentType:
const doctype = node as IRRDocumentType;
return {
id,
type: node.RRNodeType,
name: doctype.name,
publicId: doctype.publicId,
systemId: doctype.systemId,
};
case RRNodeType.Element:
return {
id,
type: node.RRNodeType,
tagName: (node as IRRElement).tagName.toLowerCase(), // In rrweb data, all tagNames are lowercase.
attributes: {},
childNodes: [],
};
case RRNodeType.Text:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRText).textContent || '',
};
case RRNodeType.Comment:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRComment).textContent || '',
};
case RRNodeType.CDATA:
return {
id,
type: node.RRNodeType,
textContent: '',
};
}
}
export { RRNode };
export {
diff,
createOrGetNode,
StyleRuleType,
VirtualStyleRules,
ReplayerHandler,
} from './diff';

View File

@@ -1,48 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RRDocument for nodejs environment buildFromDom should create an RRDocument from a html document 1`] = `
"-1 RRDocument
-2 RRDocumentType
-3 HTML lang=\\"en\\"
-4 HEAD
-5 RRText text=\\"\\\\n \\"
-6 META charset=\\"UTF-8\\"
-7 RRText text=\\"\\\\n \\"
-8 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-9 RRText text=\\"\\\\n \\"
-10 TITLE
-11 RRText text=\\"Main\\"
-12 RRText text=\\"\\\\n \\"
-13 LINK rel=\\"stylesheet\\" href=\\"somelink\\"
-14 RRText text=\\"\\\\n \\"
-15 STYLE
-16 RRText text=\\"\\\\n h1 {\\\\n color: 'black';\\\\n }\\\\n .blocks {\\\\n padding: 0;\\\\n }\\\\n .blocks1 {\\\\n margin: 0;\\\\n }\\\\n #block1 {\\\\n width: 100px;\\\\n height: 200px;\\\\n }\\\\n @import url(\\\\\\"main.css\\\\\\");\\\\n \\"
-17 RRText text=\\"\\\\n \\"
-18 RRText text=\\"\\\\n \\"
-19 BODY
-20 RRText text=\\"\\\\n \\"
-21 H1
-22 RRText text=\\"This is a h1 heading\\"
-23 RRText text=\\"\\\\n \\"
-24 H1 style=\\"font-size: 16px\\"
-25 RRText text=\\"This is a h1 heading with styles\\"
-26 RRText text=\\"\\\\n \\"
-27 DIV id=\\"block1\\" class=\\"blocks blocks1\\"
-28 RRText text=\\"\\\\n \\"
-29 DIV id=\\"block2\\" class=\\"blocks blocks1 :hover\\"
-30 RRText text=\\"\\\\n Text 1\\\\n \\"
-31 DIV id=\\"block3\\"
-32 RRText text=\\"\\\\n \\"
-33 P
-34 RRText text=\\"This is a paragraph\\"
-35 RRText text=\\"\\\\n \\"
-36 BUTTON
-37 RRText text=\\"button1\\"
-38 RRText text=\\"\\\\n \\"
-39 RRText text=\\"\\\\n Text 2\\\\n \\"
-40 RRText text=\\"\\\\n \\"
-41 IMG src=\\"somelink\\" alt=\\"This is an image\\"
-42 RRText text=\\"\\\\n \\"
-43 RRText text=\\"\\\\n \\\\n\\\\n\\"
"
`;

View File

@@ -0,0 +1,160 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RRDocument for browser environment create a RRDocument from a html document can build from a common html 1`] = `
"-1 RRDocument
-2 RRDocumentType
-3 HTML lang=\\"en\\"
-4 HEAD
-5 RRText text=\\"\\\\n \\"
-6 META charset=\\"UTF-8\\"
-7 RRText text=\\"\\\\n \\"
-8 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-9 RRText text=\\"\\\\n \\"
-10 TITLE
-11 RRText text=\\"Main\\"
-12 RRText text=\\"\\\\n \\"
-13 LINK rel=\\"stylesheet\\" href=\\"somelink\\"
-14 RRText text=\\"\\\\n \\"
-15 STYLE
-16 RRText text=\\"\\\\n h1 {\\\\n color: 'black';\\\\n }\\\\n .blocks {\\\\n padding: 0;\\\\n }\\\\n .blocks1 {\\\\n margin: 0;\\\\n }\\\\n #block1 {\\\\n width: 100px;\\\\n height: 200px;\\\\n }\\\\n @import url('main.css');\\\\n \\"
-17 RRText text=\\"\\\\n \\"
-18 RRText text=\\"\\\\n \\"
-19 BODY
-20 RRText text=\\"\\\\n \\"
-21 H1
-22 RRText text=\\"This is a h1 heading\\"
-23 RRText text=\\"\\\\n \\"
-24 H1 style=\\"font-size: 16px\\"
-25 RRText text=\\"This is a h1 heading with styles\\"
-26 RRText text=\\"\\\\n \\"
-27 DIV id=\\"block1\\" class=\\"blocks blocks1\\"
-28 RRText text=\\"\\\\n \\"
-29 DIV id=\\"block2\\" class=\\"blocks blocks1 :hover\\"
-30 RRText text=\\"\\\\n Text 1\\\\n \\"
-31 DIV id=\\"block3\\"
-32 RRText text=\\"\\\\n \\"
-33 P
-34 RRText text=\\"This is a paragraph\\"
-35 RRText text=\\"\\\\n \\"
-36 BUTTON
-37 RRText text=\\"button1\\"
-38 RRText text=\\"\\\\n \\"
-39 RRText text=\\"\\\\n Text 2\\\\n \\"
-40 RRText text=\\"\\\\n \\"
-41 IMG src=\\"somelink\\" alt=\\"This is an image\\"
-42 RRText text=\\"\\\\n \\"
-43 RRComment text=\\" This is a line of comment \\"
-44 RRText text=\\"\\\\n \\"
-45 FORM
-46 RRText text=\\"\\\\n \\"
-47 INPUT type=\\"text\\" id=\\"input1\\"
-48 RRText text=\\"\\\\n \\"
-49 RRText text=\\"\\\\n \\"
-50 RRText text=\\"\\\\n \\\\n\\\\n\\"
"
`;
exports[`RRDocument for browser environment create a RRDocument from a html document can build from a html containing nested shadow doms 1`] = `
"-1 RRDocument
-2 RRDocumentType
-3 HTML lang=\\"en\\"
-4 HEAD
-5 RRText text=\\"\\\\n \\"
-6 META charset=\\"UTF-8\\"
-7 RRText text=\\"\\\\n \\"
-8 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-9 RRText text=\\"\\\\n \\"
-10 TITLE
-11 RRText text=\\"shadow dom\\"
-12 RRText text=\\"\\\\n \\"
-13 RRText text=\\"\\\\n \\"
-14 BODY
-15 RRText text=\\"\\\\n \\"
-16 DIV
-17 SHADOWROOT
-18 RRText text=\\"\\\\n \\"
-19 SPAN
-20 RRText text=\\" shadow dom one \\"
-21 RRText text=\\"\\\\n \\"
-22 DIV
-23 SHADOWROOT
-24 RRText text=\\"\\\\n \\"
-25 SPAN
-26 RRText text=\\" shadow dom two \\"
-27 RRText text=\\"\\\\n \\"
-28 RRText text=\\"\\\\n \\\\n \\"
-29 RRText text=\\"\\\\n \\"
-30 RRText text=\\"\\\\n \\\\n \\"
-31 RRText text=\\"\\\\n \\\\n\\\\n\\"
"
`;
exports[`RRDocument for browser environment create a RRDocument from a html document can build from a xml page 1`] = `
"-1 RRDocument
-2 XML
-3 RRCDATASection data=\\"Some <CDATA> data & then some\\"
"
`;
exports[`RRDocument for browser environment create a RRDocument from a html document can build from an iframe html 1`] = `
"-1 RRDocument
-2 RRDocumentType
-3 HTML lang=\\"en\\"
-4 HEAD
-5 RRText text=\\"\\\\n \\"
-6 META charset=\\"UTF-8\\"
-7 RRText text=\\"\\\\n \\"
-8 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-9 RRText text=\\"\\\\n \\"
-10 TITLE
-11 RRText text=\\"Iframe\\"
-12 RRText text=\\"\\\\n \\"
-13 RRText text=\\"\\\\n \\"
-14 BODY
-15 RRText text=\\"\\\\n \\"
-16 IFRAME id=\\"iframe1\\" srcdoc=\\"
<html>
<head>
<meta charset='UTF-8' />
<meta
name='viewport'
content='width=device-width, initial-scale=1.0'
/>
</head>
<body>
<div>This is a block inside the iframe1.</div>
<iframe id='iframe3' srcdoc='<div>This is a block inside the iframe3.</div>'>
</body>
</html>\\"
-17 RRDocument
-18 HTML
-19 HEAD
-20 RRText text=\\"\\\\n \\"
-21 META charset=\\"UTF-8\\"
-22 RRText text=\\"\\\\n \\"
-23 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-24 RRText text=\\"\\\\n \\"
-25 RRText text=\\"\\\\n \\"
-26 BODY
-27 RRText text=\\"\\\\n \\"
-28 DIV
-29 RRText text=\\"This is a block inside the iframe1.\\"
-30 RRText text=\\"\\\\n \\"
-31 IFRAME id=\\"iframe3\\" srcdoc=\\"<div>This is a block inside the iframe3.</div>\\"
-32 RRDocument
-33 HTML
-34 HEAD
-35 BODY
-36 DIV
-37 RRText text=\\"This is a block inside the iframe3.\\"
-38 RRText text=\\"\\\\n \\"
-39 IFRAME id=\\"iframe2\\" srcdoc=\\"<div>This is a block inside the iframe2.</div>\\"
-40 RRDocument
-41 HTML
-42 HEAD
-43 BODY
-44 DIV
-45 RRText text=\\"This is a block inside the iframe2.\\"
-46 RRText text=\\"\\\\n \\\\n\\\\n\\"
"
`;

File diff suppressed because it is too large Load Diff

View File

@@ -3,67 +3,105 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { RRDocument, RRElement, RRStyleElement } from '../src/document-nodejs';
import { printRRDom } from './util';
import { NodeType as RRNodeType } from 'rrweb-snapshot';
import {
RRCanvasElement,
RRCDATASection,
RRComment,
RRDocument,
RRElement,
RRIFrameElement,
RRImageElement,
RRMediaElement,
RRStyleElement,
RRText,
} from '../src/document-nodejs';
import { buildFromDom } from '../src/virtual-dom';
describe('RRDocument for nodejs environment', () => {
describe('buildFromDom', () => {
it('should create an RRDocument from a html document', () => {
// setup document
document.write(getHtml('main.html'));
// create RRDocument from document
const rrdoc = new RRDocument();
rrdoc.buildFromDom(document);
expect(printRRDom(rrdoc)).toMatchSnapshot();
});
});
describe('RRDocument API', () => {
let rrdom: RRDocument;
beforeAll(() => {
// initialize rrdom
document.write(getHtml('main.html'));
rrdom = new RRDocument();
rrdom.buildFromDom(document);
buildFromDom(document, undefined, rrdom);
});
it('get className', () => {
expect(rrdom.getElementsByTagName('DIV')[0].className).toEqual(
'blocks blocks1',
);
expect(rrdom.getElementsByTagName('DIV')[1].className).toEqual(
'blocks blocks1 :hover',
it('can create different type of RRNodes', () => {
const document = rrdom.createDocument('', '');
expect(document).toBeInstanceOf(RRDocument);
const audio = rrdom.createElement('audio');
expect(audio).toBeInstanceOf(RRMediaElement);
const video = rrdom.createElement('video');
expect(video).toBeInstanceOf(RRMediaElement);
const iframe = rrdom.createElement('iframe');
expect(iframe).toBeInstanceOf(RRIFrameElement);
const image = rrdom.createElement('img');
expect(image).toBeInstanceOf(RRImageElement);
const canvas = rrdom.createElement('canvas');
expect(canvas).toBeInstanceOf(RRCanvasElement);
const style = rrdom.createElement('style');
expect(style).toBeInstanceOf(RRStyleElement);
const elementNS = rrdom.createElementNS(
'http://www.w3.org/2000/svg',
'div',
);
expect(elementNS).toBeInstanceOf(RRElement);
expect(elementNS.tagName).toEqual('DIV');
const text = rrdom.createTextNode('text');
expect(text).toBeInstanceOf(RRText);
expect(text.textContent).toEqual('text');
const comment = rrdom.createComment('comment');
expect(comment).toBeInstanceOf(RRComment);
expect(comment.textContent).toEqual('comment');
const CDATA = rrdom.createCDATASection('data');
expect(CDATA).toBeInstanceOf(RRCDATASection);
expect(CDATA.data).toEqual('data');
});
it('get id', () => {
expect(rrdom.getElementsByTagName('DIV')[0].id).toEqual('block1');
expect(rrdom.getElementsByTagName('DIV')[1].id).toEqual('block2');
expect(rrdom.getElementsByTagName('DIV')[2].id).toEqual('block3');
it('can get head element', () => {
expect(rrdom.head).toBeDefined();
expect(rrdom.head!.tagName).toBe('HEAD');
expect(rrdom.head!.parentElement).toBe(rrdom.documentElement);
});
it('get attribute name', () => {
expect(
rrdom.getElementsByTagName('DIV')[0].getAttribute('class'),
).toEqual('blocks blocks1');
expect(
rrdom.getElementsByTagName('dIv')[0].getAttribute('cLaSs'),
).toEqual('blocks blocks1');
expect(rrdom.getElementsByTagName('DIV')[0].getAttribute('id')).toEqual(
'block1',
it('can get body element', () => {
expect(rrdom.body).toBeDefined();
expect(rrdom.body!.tagName).toBe('BODY');
expect(rrdom.body!.parentElement).toBe(rrdom.documentElement);
});
it('can get implementation', () => {
expect(rrdom.implementation).toBeDefined();
expect(rrdom.implementation).toBe(rrdom);
});
it('can insert elements', () => {
expect(() =>
rrdom.insertBefore(rrdom.createDocumentType('', '', ''), null),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one RRDoctype on RRDocument allowed."`,
);
expect(rrdom.getElementsByTagName('div')[0].getAttribute('iD')).toEqual(
'block1',
expect(() =>
rrdom.insertBefore(rrdom.createElement('div'), null),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one RRElement on RRDocument allowed."`,
);
expect(
rrdom.getElementsByTagName('p')[0].getAttribute('class'),
).toBeNull();
const node = new RRDocument();
const doctype = rrdom.createDocumentType('', '', '');
const documentElement = node.createElement('html');
node.insertBefore(documentElement, null);
node.insertBefore(doctype, documentElement);
expect(node.childNodes.length).toEqual(2);
expect(node.childNodes[0]).toBe(doctype);
expect(node.childNodes[1]).toBe(documentElement);
expect(node.documentElement).toBe(documentElement);
});
it('get firstElementChild', () => {
expect(rrdom.firstElementChild).toBeDefined();
expect(rrdom.firstElementChild.tagName).toEqual('HTML');
expect(rrdom.firstElementChild!.tagName).toEqual('HTML');
const div1 = rrdom.getElementById('block1');
expect(div1).toBeDefined();
@@ -73,31 +111,6 @@ describe('RRDocument for nodejs environment', () => {
expect(div2!.firstElementChild!.id).toEqual('block3');
});
it('get nextElementSibling', () => {
expect(rrdom.documentElement.firstElementChild).not.toBeNull();
expect(rrdom.documentElement.firstElementChild!.tagName).toEqual('HEAD');
expect(
rrdom.documentElement.firstElementChild!.nextElementSibling,
).not.toBeNull();
expect(
rrdom.documentElement.firstElementChild!.nextElementSibling!.tagName,
).toEqual('BODY');
expect(
rrdom.documentElement.firstElementChild!.nextElementSibling!
.nextElementSibling,
).toBeNull();
expect(rrdom.getElementsByTagName('h1').length).toEqual(2);
const element1 = rrdom.getElementsByTagName('h1')[0];
const element2 = rrdom.getElementsByTagName('h1')[1];
expect(element1.tagName).toEqual('H1');
expect(element2.tagName).toEqual('H1');
expect(element1.nextElementSibling).toEqual(element2);
expect(element2.nextElementSibling).not.toBeNull();
expect(element2.nextElementSibling!.id).toEqual('block1');
expect(element2.nextElementSibling!.nextElementSibling).toBeNull();
});
it('getElementsByTagName', () => {
for (let tagname of [
'HTML',
@@ -114,6 +127,8 @@ describe('RRDocument for nodejs environment', () => {
'BUTTON',
'IMG',
'CANVAS',
'FORM',
'INPUT',
]) {
const expectedResult = document.getElementsByTagName(tagname).length;
expect(rrdom.getElementsByTagName(tagname).length).toEqual(
@@ -126,6 +141,8 @@ describe('RRDocument for nodejs environment', () => {
expect(node.tagName).toEqual(tagname);
}
}
const node = new RRDocument();
expect(node.getElementsByTagName('h2').length).toEqual(0);
});
it('getElementsByClassName', () => {
@@ -148,6 +165,8 @@ describe('RRDocument for nodejs environment', () => {
result: document.getElementsByClassName(className).length,
});
}
const node = new RRDocument();
expect(node.getElementsByClassName('block').length).toEqual(0);
});
it('getElementById', () => {
@@ -157,6 +176,8 @@ describe('RRDocument for nodejs environment', () => {
}
for (let elementId of ['block', 'blocks', 'blocks1'])
expect(rrdom.getElementById(elementId)).toBeNull();
const node = new RRDocument();
expect(node.getElementById('id')).toBeNull();
});
it('querySelectorAll querying tag name', () => {
@@ -193,7 +214,7 @@ describe('RRDocument for nodejs environment', () => {
}
for (let element of rrdom.querySelectorAll('.\\:hover')) {
expect(element).toBeInstanceOf(RRElement);
expect((element as RRElement).classList).toContain(':hover');
expect((element as RRElement).classList.classes).toContain(':hover');
}
});
@@ -220,6 +241,243 @@ describe('RRDocument for nodejs environment', () => {
expect(rrdom.querySelectorAll('.blocks#block1').length).toEqual(1);
expect(rrdom.querySelectorAll('.blocks#block3').length).toEqual(0);
});
});
describe('RRElement API', () => {
let rrdom: RRDocument;
beforeAll(() => {
// initialize rrdom
document.write(getHtml('main.html'));
rrdom = new RRDocument();
buildFromDom(document, undefined, rrdom);
});
it('can get attribute', () => {
expect(
rrdom.getElementsByTagName('DIV')[0].getAttribute('class'),
).toEqual('blocks blocks1');
expect(
rrdom.getElementsByTagName('dIv')[0].getAttribute('cLaSs'),
).toEqual('blocks blocks1');
expect(rrdom.getElementsByTagName('DIV')[0].getAttribute('id')).toEqual(
'block1',
);
expect(rrdom.getElementsByTagName('div')[0].getAttribute('iD')).toEqual(
'block1',
);
expect(
rrdom.getElementsByTagName('p')[0].getAttribute('class'),
).toBeNull();
});
it('can set attribute', () => {
const node = rrdom.createElement('div');
expect(node.getAttribute('class')).toEqual(null);
node.setAttribute('class', 'className');
expect(node.getAttribute('cLass')).toEqual('className');
expect(node.getAttribute('iD')).toEqual(null);
node.setAttribute('iD', 'id');
expect(node.getAttribute('id')).toEqual('id');
});
it('can remove attribute', () => {
const node = rrdom.createElement('div');
node.setAttribute('Class', 'className');
expect(node.getAttribute('class')).toEqual('className');
node.removeAttribute('clAss');
expect(node.getAttribute('class')).toEqual(null);
node.removeAttribute('Id');
expect(node.getAttribute('id')).toEqual(null);
});
it('get nextElementSibling', () => {
expect(rrdom.documentElement!.firstElementChild).not.toBeNull();
expect(rrdom.documentElement!.firstElementChild!.tagName).toEqual('HEAD');
expect(
rrdom.documentElement!.firstElementChild!.nextElementSibling,
).not.toBeNull();
expect(
rrdom.documentElement!.firstElementChild!.nextElementSibling!.tagName,
).toEqual('BODY');
expect(
rrdom.documentElement!.firstElementChild!.nextElementSibling!
.nextElementSibling,
).toBeNull();
expect(rrdom.getElementsByTagName('h1').length).toEqual(2);
const element1 = rrdom.getElementsByTagName('h1')[0];
const element2 = rrdom.getElementsByTagName('h1')[1];
expect(element1.tagName).toEqual('H1');
expect(element2.tagName).toEqual('H1');
expect(element1.nextElementSibling).toEqual(element2);
expect(element2.nextElementSibling).not.toBeNull();
expect(element2.nextElementSibling!.id).toEqual('block1');
expect(element2.nextElementSibling!.nextElementSibling).toBeNull();
const node = rrdom.createElement('div');
expect(node.nextElementSibling).toBeNull();
});
it('can get CSS style declaration', () => {
const node = rrdom.createElement('div');
const style = node.style;
expect(style).toBeDefined();
expect(style.setProperty).toBeDefined();
expect(style.removeProperty).toBeDefined();
node.attributes.style =
'color: blue; background-color: red; width: 78%; height: 50vh !important;';
expect(node.style.color).toBe('blue');
expect(node.style.backgroundColor).toBe('red');
expect(node.style.width).toBe('78%');
expect(node.style.height).toBe('50vh');
});
it('can set CSS property', () => {
const node = rrdom.createElement('div');
const style = node.style;
style.setProperty('color', 'red');
expect(node.attributes.style).toEqual('color: red;');
// camelCase style is unacceptable
style.setProperty('backgroundColor', 'blue');
expect(node.attributes.style).toEqual('color: red;');
style.setProperty('height', '50vh', 'important');
expect(node.attributes.style).toEqual(
'color: red; height: 50vh !important;',
);
// kebab-case
style.setProperty('background-color', 'red');
expect(node.attributes.style).toEqual(
'color: red; height: 50vh !important; background-color: red;',
);
// remove the property
style.setProperty('background-color', null);
expect(node.attributes.style).toEqual(
'color: red; height: 50vh !important;',
);
});
it('can remove CSS property', () => {
const node = rrdom.createElement('div');
node.attributes.style =
'color: blue; background-color: red; width: 78%; height: 50vh;';
const style = node.style;
expect(style.removeProperty('color')).toEqual('blue');
expect(node.attributes.style).toEqual(
'background-color: red; width: 78%; height: 50vh;',
);
expect(style.removeProperty('height')).toEqual('50vh');
expect(node.attributes.style).toEqual(
'background-color: red; width: 78%;',
);
// kebab-case
expect(style.removeProperty('background-color')).toEqual('red');
expect(node.attributes.style).toEqual('width: 78%;');
style.setProperty('background-color', 'red');
expect(node.attributes.style).toEqual(
'width: 78%; background-color: red;',
);
expect(style.removeProperty('backgroundColor')).toEqual('');
expect(node.attributes.style).toEqual(
'width: 78%; background-color: red;',
);
// remove a non-exist property
expect(style.removeProperty('margin')).toEqual('');
});
it('can parse more inline styles correctly', () => {
const node = rrdom.createElement('div');
// general
node.attributes.style =
'display: inline-block; margin: 0 auto; border: 5px solid #BADA55; font-size: .75em; position:absolute;width: 33.3%; z-index:1337; font-family: "Goudy Bookletter 1911", Gill Sans Extrabold, sans-serif;';
const style = node.style;
expect(style.display).toEqual('inline-block');
expect(style.margin).toEqual('0px auto');
expect(style.border).toEqual('5px solid #bada55');
expect(style.fontSize).toEqual('.75em');
expect(style.position).toEqual('absolute');
expect(style.width).toEqual('33.3%');
expect(style.zIndex).toEqual('1337');
expect(style.fontFamily).toEqual(
'"Goudy Bookletter 1911", Gill Sans Extrabold, sans-serif',
);
// multiple of same property
node.attributes.style = 'color:rgba(0,0,0,1);color:white';
expect(style.color).toEqual('white');
// url
node.attributes.style =
'background-image: url("http://example.com/img.png")';
expect(node.style.backgroundImage).toEqual(
'url(http://example.com/img.png)',
);
// comment
node.attributes.style =
'top: 0; /* comment1 */ bottom: /* comment2 */42rem;';
expect(node.style.top).toEqual('0px');
expect(node.style.bottom).toEqual('42rem');
// empty comment
node.attributes.style = 'top: /**/0;';
expect(node.style.top).toEqual('0px');
// incomplete
node.attributes.style = 'overflow:';
expect(node.style.overflow).toEqual('');
});
it('querySelectorAll', () => {
const element = rrdom.getElementById('block2')!;
expect(element).toBeDefined();
expect(element.id).toEqual('block2');
const result = element.querySelectorAll('div');
expect(result.length).toBe(1);
expect((result[0]! as RRElement).tagName).toEqual('DIV');
expect(element.querySelectorAll('.blocks').length).toEqual(0);
const element2 = rrdom.getElementById('block1')!;
expect(element2).toBeDefined();
expect(element2.id).toEqual('block1');
expect(element2.querySelectorAll('div').length).toEqual(2);
expect(element2.querySelectorAll('.blocks').length).toEqual(1);
});
it('can attach shadow dom', () => {
const node = rrdom.createElement('div');
expect(node.shadowRoot).toBeNull();
node.attachShadow({ mode: 'open' });
expect(node.shadowRoot).not.toBeNull();
expect(node.shadowRoot!.RRNodeType).toBe(RRNodeType.Element);
expect(node.shadowRoot!.tagName).toBe('SHADOWROOT');
expect(node.parentNode).toBeNull();
});
it('can insert new child before an existing child', () => {
const node = rrdom.createElement('div');
const child1 = rrdom.createElement('h1');
const child2 = rrdom.createElement('h2');
expect(() =>
node.insertBefore(node, child1),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode."`,
);
expect(node.insertBefore(child1, null)).toBe(child1);
expect(node.childNodes[0]).toBe(child1);
expect(child1.parentNode).toBe(node);
expect(child1.parentElement).toBe(node);
expect(node.insertBefore(child2, child1)).toBe(child2);
expect(node.childNodes.length).toBe(2);
expect(node.childNodes[0]).toBe(child2);
expect(node.childNodes[1]).toBe(child1);
expect(child2.parentNode).toBe(node);
expect(child2.parentElement).toBe(node);
});
it('style element', () => {
expect(rrdom.getElementsByTagName('style').length).not.toEqual(0);
@@ -250,6 +508,36 @@ describe('RRDocument for nodejs environment', () => {
expect(rules[5]).toBeUndefined();
expect(rules[4].cssText).toEqual(`@import url(main.css);`);
});
it('can create an RRIframeElement', () => {
const iframe = rrdom.createElement('iframe');
expect(iframe.tagName).toEqual('IFRAME');
expect(iframe.width).toEqual('');
expect(iframe.height).toEqual('');
expect(iframe.contentDocument).toBeDefined();
expect(iframe.contentDocument!.childNodes.length).toBe(1);
expect(iframe.contentDocument!.documentElement).toBeDefined();
expect(iframe.contentDocument!.head).toBeDefined();
expect(iframe.contentDocument!.body).toBeDefined();
expect(iframe.contentWindow).toBeDefined();
expect(iframe.contentWindow!.scrollTop).toEqual(0);
expect(iframe.contentWindow!.scrollLeft).toEqual(0);
expect(iframe.contentWindow!.scrollTo).toBeDefined();
// empty parameter and did nothing
iframe.contentWindow!.scrollTo();
expect(iframe.contentWindow!.scrollTop).toEqual(0);
expect(iframe.contentWindow!.scrollLeft).toEqual(0);
iframe.contentWindow!.scrollTo({ top: 10, left: 20 });
expect(iframe.contentWindow!.scrollTop).toEqual(10);
expect(iframe.contentWindow!.scrollLeft).toEqual(20);
});
it('should have a RRCanvasElement', () => {
const canvas = rrdom.createElement('canvas');
expect(canvas.getContext()).toBeNull();
});
});
});

View File

@@ -0,0 +1,922 @@
/**
* @jest-environment jsdom
*/
import { NodeType as RRNodeType } from 'rrweb-snapshot';
import {
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRNode,
IRRDocumentType,
} from '../src/document';
describe('Basic RRDocument implementation', () => {
const RRNode = BaseRRNode;
const RRDocument = BaseRRDocumentImpl(RRNode);
const RRDocumentType = BaseRRDocumentTypeImpl(RRNode);
const RRElement = BaseRRElementImpl(RRNode);
class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
describe('Basic RRNode implementation', () => {
it('should have basic properties', () => {
const node = new RRNode();
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBeUndefined();
expect(node.textContent).toBeUndefined();
expect(node.RRNodeType).toBeUndefined();
expect(node.nodeType).toBeUndefined();
expect(node.nodeName).toBeUndefined();
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.toString()).toEqual('RRNode');
});
it('can get first child node', () => {
const parentNode = new RRNode();
const childNode1 = new RRNode();
const childNode2 = new RRNode();
expect(parentNode.firstChild).toBeNull();
parentNode.childNodes = [childNode1];
expect(parentNode.firstChild).toBe(childNode1);
parentNode.childNodes = [childNode1, childNode2];
expect(parentNode.firstChild).toBe(childNode1);
parentNode.childNodes = [childNode2, childNode1];
expect(parentNode.firstChild).toBe(childNode2);
});
it('can get last child node', () => {
const parentNode = new RRNode();
const childNode1 = new RRNode();
const childNode2 = new RRNode();
expect(parentNode.lastChild).toBeNull();
parentNode.childNodes = [childNode1];
expect(parentNode.lastChild).toBe(childNode1);
parentNode.childNodes = [childNode1, childNode2];
expect(parentNode.lastChild).toBe(childNode2);
parentNode.childNodes = [childNode2, childNode1];
expect(parentNode.lastChild).toBe(childNode1);
});
it('can get nextSibling', () => {
const parentNode = new RRNode();
const childNode1 = new RRNode();
const childNode2 = new RRNode();
expect(parentNode.nextSibling).toBeNull();
expect(childNode1.nextSibling).toBeNull();
childNode1.parentNode = parentNode;
parentNode.childNodes = [childNode1];
expect(childNode1.nextSibling).toBeNull();
childNode2.parentNode = parentNode;
parentNode.childNodes = [childNode1, childNode2];
expect(childNode1.nextSibling).toBe(childNode2);
expect(childNode2.nextSibling).toBeNull();
});
it('should return whether the node contains another node', () => {
const parentNode = new RRNode();
const childNode1 = new RRNode();
const childNode2 = new RRNode();
parentNode.childNodes = [childNode1];
expect(parentNode.contains(childNode1)).toBeTruthy();
expect(parentNode.contains(childNode2)).toBeFalsy();
childNode1.childNodes = [childNode2];
expect(parentNode.contains(childNode2)).toBeTruthy();
});
it('should not implement appendChild', () => {
const parentNode = new RRNode();
const childNode = new RRNode();
expect(() =>
parentNode.appendChild(childNode),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'appendChild' on 'RRNode': This RRNode type does not support this method."`,
);
});
it('should not implement insertBefore', () => {
const parentNode = new RRNode();
const childNode = new RRNode();
expect(() =>
parentNode.insertBefore(childNode, null),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': This RRNode type does not support this method."`,
);
});
it('should not implement removeChild', () => {
const parentNode = new RRNode();
const childNode = new RRNode();
expect(() =>
parentNode.removeChild(childNode),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'removeChild' on 'RRNode': This RRNode type does not support this method."`,
);
});
});
describe('Basic RRDocument implementation', () => {
it('should have basic properties', () => {
const node = new RRDocument();
expect(node.toString()).toEqual('RRDocument');
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBeUndefined();
expect(node.textContent).toBeNull();
expect(node.RRNodeType).toBe(RRNodeType.Document);
expect(node.nodeType).toBe(document.nodeType);
expect(node.nodeName).toBe('#document');
expect(node.compatMode).toBe('CSS1Compat');
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.documentElement).toBeNull();
expect(node.body).toBeNull();
expect(node.head).toBeNull();
expect(node.implementation).toBe(node);
expect(node.firstElementChild).toBeNull();
expect(node.createDocument).toBeDefined();
expect(node.createDocumentType).toBeDefined();
expect(node.createElement).toBeDefined();
expect(node.createElementNS).toBeDefined();
expect(node.createTextNode).toBeDefined();
expect(node.createComment).toBeDefined();
expect(node.createCDATASection).toBeDefined();
expect(node.open).toBeDefined();
expect(node.close).toBeDefined();
expect(node.write).toBeDefined();
expect(node.toString()).toEqual('RRDocument');
});
it('can get documentElement', () => {
const node = new RRDocument();
expect(node.documentElement).toBeNull();
const element = node.createElement('html');
node.appendChild(element);
expect(node.documentElement).toBe(element);
});
it('can get head', () => {
const node = new RRDocument();
expect(node.head).toBeNull();
const element = node.createElement('html');
node.appendChild(element);
expect(node.head).toBeNull();
const head = node.createElement('head');
element.appendChild(head);
expect(node.head).toBe(head);
});
it('can get body', () => {
const node = new RRDocument();
expect(node.body).toBeNull();
const element = node.createElement('html');
node.appendChild(element);
expect(node.body).toBeNull();
const body = node.createElement('body');
element.appendChild(body);
expect(node.body).toBe(body);
const head = node.createElement('head');
element.appendChild(head);
expect(node.body).toBe(body);
});
it('can get firstElementChild', () => {
const node = new RRDocument();
expect(node.firstElementChild).toBeNull();
const element = node.createElement('html');
node.appendChild(element);
expect(node.firstElementChild).toBe(element);
});
it('can append child', () => {
const node = new RRDocument();
expect(node.firstElementChild).toBeNull();
const documentType = node.createDocumentType('html', '', '');
expect(node.appendChild(documentType)).toBe(documentType);
expect(node.childNodes[0]).toEqual(documentType);
expect(documentType.parentElement).toBeNull();
expect(documentType.parentNode).toBe(node);
expect(() =>
node.appendChild(documentType),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'appendChild' on 'RRNode': Only one RRDoctype on RRDocument allowed."`,
);
const element = node.createElement('html');
expect(node.appendChild(element)).toBe(element);
expect(node.childNodes[1]).toEqual(element);
expect(element.parentElement).toBeNull();
expect(element.parentNode).toBe(node);
const div = node.createElement('div');
expect(() => node.appendChild(div)).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'appendChild' on 'RRNode': Only one RRElement on RRDocument allowed."`,
);
});
it('can insert new child before an existing child', () => {
const node = new RRDocument();
const docType = node.createDocumentType('', '', '');
expect(() =>
node.insertBefore(node, docType),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode."`,
);
expect(node.insertBefore(docType, null)).toBe(docType);
expect(() =>
node.insertBefore(docType, null),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one RRDoctype on RRDocument allowed."`,
);
node.removeChild(docType);
const documentElement = node.createElement('html');
expect(() =>
node.insertBefore(documentElement, docType),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode."`,
);
expect(node.insertBefore(documentElement, null)).toBe(documentElement);
expect(() =>
node.insertBefore(documentElement, null),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one RRElement on RRDocument allowed."`,
);
expect(node.insertBefore(docType, documentElement)).toBe(docType);
expect(node.childNodes[0]).toBe(docType);
expect(node.childNodes[1]).toBe(documentElement);
expect(docType.parentElement).toBeNull();
expect(documentElement.parentElement).toBeNull();
expect(docType.parentNode).toBe(node);
expect(documentElement.parentNode).toBe(node);
});
it('can remove an existing child', () => {
const node = new RRDocument();
const documentType = node.createDocumentType('html', '', '');
const documentElement = node.createElement('html');
node.appendChild(documentType);
node.appendChild(documentElement);
expect(documentType.parentNode).toBe(node);
expect(documentElement.parentNode).toBe(node);
expect(() =>
node.removeChild(node.createElement('div')),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'removeChild' on 'RRDocument': The RRNode to be removed is not a child of this RRNode."`,
);
expect(node.removeChild(documentType)).toBe(documentType);
expect(documentType.parentNode).toBeNull();
expect(node.removeChild(documentElement)).toBe(documentElement);
expect(documentElement.parentNode).toBeNull();
});
it('should implement create node functions', () => {
const node = new RRDocument();
expect(node.createDocument(null, '', null).RRNodeType).toEqual(
RRNodeType.Document,
);
expect(node.createDocumentType('', '', '').RRNodeType).toEqual(
RRNodeType.DocumentType,
);
expect(node.createElement('html').RRNodeType).toEqual(RRNodeType.Element);
expect(node.createElementNS('', 'html').RRNodeType).toEqual(
RRNodeType.Element,
);
expect(node.createTextNode('text').RRNodeType).toEqual(RRNodeType.Text);
expect(node.createComment('comment').RRNodeType).toEqual(
RRNodeType.Comment,
);
expect(node.createCDATASection('data').RRNodeType).toEqual(
RRNodeType.CDATA,
);
});
it('can close and open a RRDocument', () => {
const node = new RRDocument();
const documentType = node.createDocumentType('html', '', '');
node.appendChild(documentType);
expect(node.childNodes[0]).toBe(documentType);
expect(node.close());
expect(node.open());
expect(node.childNodes.length).toEqual(0);
});
it('can cover the usage of write() in rrweb-snapshot', () => {
const node = new RRDocument();
node.write(
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "">',
);
expect(node.childNodes.length).toBe(1);
let doctype = node.childNodes[0] as IRRDocumentType;
expect(doctype.RRNodeType).toEqual(RRNodeType.DocumentType);
expect(doctype.parentNode).toEqual(node);
expect(doctype.name).toEqual('html');
expect(doctype.publicId).toEqual(
'-//W3C//DTD XHTML 1.0 Transitional//EN',
);
expect(doctype.systemId).toEqual('');
node.write(
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">',
);
expect(node.childNodes.length).toBe(1);
doctype = node.childNodes[0] as IRRDocumentType;
expect(doctype.RRNodeType).toEqual(RRNodeType.DocumentType);
expect(doctype.parentNode).toEqual(node);
expect(doctype.name).toEqual('html');
expect(doctype.publicId).toEqual('-//W3C//DTD HTML 4.0 Transitional//EN');
expect(doctype.systemId).toEqual('');
});
});
describe('Basic RRDocumentType implementation', () => {
it('should have basic properties', () => {
const name = 'name',
publicId = 'publicId',
systemId = 'systemId';
const node = new RRDocumentType(name, publicId, systemId);
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBeUndefined();
expect(node.textContent).toBeNull();
expect(node.RRNodeType).toBe(RRNodeType.DocumentType);
expect(node.nodeType).toBe(document.DOCUMENT_TYPE_NODE);
expect(node.nodeName).toBe(name);
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.name).toBe(name);
expect(node.publicId).toBe(publicId);
expect(node.systemId).toBe(systemId);
expect(node.toString()).toEqual('RRDocumentType');
});
});
describe('Basic RRElement implementation', () => {
const document = new RRDocument();
it('should have basic properties', () => {
const node = document.createElement('div');
node.scrollLeft = 100;
node.scrollTop = 200;
node.attributes.id = 'id';
node.attributes.class = 'className';
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBe(document);
expect(node.textContent).toEqual('');
expect(node.RRNodeType).toBe(RRNodeType.Element);
expect(node.nodeType).toBe(document.ELEMENT_NODE);
expect(node.nodeName).toBe('DIV');
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.tagName).toEqual('DIV');
expect(node.attributes).toEqual({ id: 'id', class: 'className' });
expect(node.shadowRoot).toBeNull();
expect(node.scrollLeft).toEqual(100);
expect(node.scrollTop).toEqual(200);
expect(node.id).toEqual('id');
expect(node.className).toEqual('className');
expect(node.classList).toBeDefined();
expect(node.style).toBeDefined();
expect(node.getAttribute).toBeDefined();
expect(node.setAttribute).toBeDefined();
expect(node.setAttributeNS).toBeDefined();
expect(node.removeAttribute).toBeDefined();
expect(node.attachShadow).toBeDefined();
expect(node.dispatchEvent).toBeDefined();
expect(node.dispatchEvent((null as unknown) as Event)).toBeTruthy();
expect(node.toString()).toEqual('DIV id="id" class="className" ');
});
it('can get textContent', () => {
const node = document.createElement('div');
node.appendChild(document.createTextNode('text1 '));
node.appendChild(document.createTextNode('text2'));
expect(node.textContent).toEqual('text1 text2');
});
it('can set textContent', () => {
const node = document.createElement('div');
node.appendChild(document.createTextNode('text1 '));
node.appendChild(document.createTextNode('text2'));
expect(node.textContent).toEqual('text1 text2');
node.textContent = 'new text';
expect(node.textContent).toEqual('new text');
});
it('can get id', () => {
const node = document.createElement('div');
expect(node.id).toEqual('');
node.attributes.id = 'idName';
expect(node.id).toEqual('idName');
});
it('can get className', () => {
const node = document.createElement('div');
expect(node.className).toEqual('');
node.attributes.class = 'className';
expect(node.className).toEqual('className');
});
it('can get classList', () => {
const node = document.createElement('div');
const classList = node.classList;
expect(classList.add).toBeDefined();
expect(classList.remove).toBeDefined();
});
it('classList can add class name', () => {
const node = document.createElement('div');
expect(node.className).toEqual('');
const classList = node.classList;
classList.add('c1');
expect(node.className).toEqual('c1');
classList.add('c2');
expect(node.className).toEqual('c1 c2');
classList.add('c2');
expect(node.className).toEqual('c1 c2');
});
it('classList can remove class name', () => {
const node = document.createElement('div');
expect(node.className).toEqual('');
const classList = node.classList;
classList.add('c1', 'c2', 'c3');
expect(node.className).toEqual('c1 c2 c3');
classList.remove('c2');
expect(node.className).toEqual('c1 c3');
classList.remove('c3');
expect(node.className).toEqual('c1');
classList.remove('c1');
expect(node.className).toEqual('');
classList.remove('c1');
expect(node.className).toEqual('');
});
it('classList can remove duplicate class names', () => {
const node = document.createElement('div');
expect(node.className).toEqual('');
node.setAttribute('class', 'c1 c1 c1');
expect(node.className).toEqual('c1 c1 c1');
const classList = node.classList;
classList.remove('c1');
expect(node.className).toEqual('');
});
it('can get CSS style declaration', () => {
const node = document.createElement('div');
const style = node.style;
expect(style).toBeDefined();
expect(style.setProperty).toBeDefined();
expect(style.removeProperty).toBeDefined();
node.attributes.style =
'color: blue; background-color: red; width: 78%; height: 50vh !important;';
expect(node.style.color).toBe('blue');
expect(node.style.backgroundColor).toBe('red');
expect(node.style.width).toBe('78%');
expect(node.style.height).toBe('50vh !important');
});
it('can set CSS property', () => {
const node = document.createElement('div');
const style = node.style;
style.setProperty('color', 'red');
expect(node.attributes.style).toEqual('color: red;');
// camelCase style is unacceptable
style.setProperty('backgroundColor', 'blue');
expect(node.attributes.style).toEqual('color: red;');
style.setProperty('height', '50vh', 'important');
expect(node.attributes.style).toEqual(
'color: red; height: 50vh !important;',
);
// kebab-case
style.setProperty('background-color', 'red');
expect(node.attributes.style).toEqual(
'color: red; height: 50vh !important; background-color: red;',
);
// remove the property
style.setProperty('background-color', null);
expect(node.attributes.style).toEqual(
'color: red; height: 50vh !important;',
);
});
it('can remove CSS property', () => {
const node = document.createElement('div');
node.attributes.style =
'color: blue; background-color: red; width: 78%; height: 50vh !important;';
const style = node.style;
expect(style.removeProperty('color')).toEqual('blue');
expect(node.attributes.style).toEqual(
'background-color: red; width: 78%; height: 50vh !important;',
);
expect(style.removeProperty('height')).toEqual('50vh !important');
expect(node.attributes.style).toEqual(
'background-color: red; width: 78%;',
);
// kebab-case
expect(style.removeProperty('background-color')).toEqual('red');
expect(node.attributes.style).toEqual('width: 78%;');
style.setProperty('background-color', 'red');
expect(node.attributes.style).toEqual(
'width: 78%; background-color: red;',
);
expect(style.removeProperty('backgroundColor')).toEqual('');
expect(node.attributes.style).toEqual(
'width: 78%; background-color: red;',
);
// remove a non-exist property
expect(style.removeProperty('margin')).toEqual('');
});
it('can parse more inline styles correctly', () => {
const node = document.createElement('div');
// general
node.attributes.style =
'display: inline-block; margin: 0 auto; border: 5px solid #BADA55; font-size: .75em; position:absolute;width: 33.3%; z-index:1337; font-family: "Goudy Bookletter 1911", Gill Sans Extrabold, sans-serif;';
let style = node.style;
expect(style.display).toEqual('inline-block');
expect(style.margin).toEqual('0 auto');
expect(style.border).toEqual('5px solid #BADA55');
expect(style.fontSize).toEqual('.75em');
expect(style.position).toEqual('absolute');
expect(style.width).toEqual('33.3%');
expect(style.zIndex).toEqual('1337');
expect(style.fontFamily).toEqual(
'"Goudy Bookletter 1911", Gill Sans Extrabold, sans-serif',
);
// multiple of same property
node.attributes.style = 'color: rgba(0,0,0,1);color:white';
style = node.style;
expect(style.color).toEqual('white');
// url
node.attributes.style =
'background-image: url("http://example.com/img.png")';
expect(node.style.backgroundImage).toEqual(
'url("http://example.com/img.png")',
);
// vendor prefixes
node.attributes.style = `
-moz-border-radius: 10px 5px;
-webkit-border-top-left-radius: 10px;
-webkit-border-bottom-left-radius: 5px;
border-radius: 10px 5px;
`;
style = node.style;
expect(style.MozBorderRadius).toEqual('10px 5px');
expect(style.WebkitBorderTopLeftRadius).toEqual('10px');
expect(style.WebkitBorderBottomLeftRadius).toEqual('5px');
expect(style.borderRadius).toEqual('10px 5px');
// comment
node.attributes.style =
'top: 0; /* comment1 */ bottom: /* comment2 */42rem;';
expect(node.style.top).toEqual('0');
expect(node.style.bottom).toEqual('42rem');
// empty comment
node.attributes.style = 'top: /**/0;';
expect(node.style.top).toEqual('0');
// custom property (variable)
node.attributes.style = '--custom-property: value';
expect(node.style['--custom-property']).toEqual('value');
// incomplete
node.attributes.style = 'overflow:';
expect(node.style.overflow).toBeUndefined();
});
it('can get attribute', () => {
const node = document.createElement('div');
node.attributes.class = 'className';
expect(node.getAttribute('class')).toEqual('className');
expect(node.getAttribute('id')).toEqual(null);
node.attributes.id = 'id';
expect(node.getAttribute('id')).toEqual('id');
});
it('can set attribute', () => {
const node = document.createElement('div');
expect(node.getAttribute('class')).toEqual(null);
node.setAttribute('class', 'className');
expect(node.getAttribute('class')).toEqual('className');
expect(node.getAttribute('id')).toEqual(null);
node.setAttribute('id', 'id');
expect(node.getAttribute('id')).toEqual('id');
});
it('can setAttributeNS', () => {
const node = document.createElement('div');
expect(node.getAttribute('class')).toEqual(null);
node.setAttributeNS('namespace', 'class', 'className');
expect(node.getAttribute('class')).toEqual('className');
expect(node.getAttribute('id')).toEqual(null);
node.setAttributeNS('namespace', 'id', 'id');
expect(node.getAttribute('id')).toEqual('id');
});
it('can remove attribute', () => {
const node = document.createElement('div');
node.setAttribute('class', 'className');
expect(node.getAttribute('class')).toEqual('className');
node.removeAttribute('class');
expect(node.getAttribute('class')).toEqual(null);
node.removeAttribute('id');
expect(node.getAttribute('id')).toEqual(null);
});
it('can attach shadow dom', () => {
const node = document.createElement('div');
expect(node.shadowRoot).toBeNull();
node.attachShadow({ mode: 'open' });
expect(node.shadowRoot).not.toBeNull();
expect(node.shadowRoot!.RRNodeType).toBe(RRNodeType.Element);
expect(node.shadowRoot!.tagName).toBe('SHADOWROOT');
expect(node.parentNode).toBeNull();
});
it('can append child', () => {
const node = document.createElement('div');
expect(node.childNodes.length).toBe(0);
const child1 = document.createComment('span');
expect(node.appendChild(child1)).toBe(child1);
expect(node.childNodes[0]).toEqual(child1);
expect(child1.parentElement).toBe(node);
expect(child1.parentNode).toBe(node);
const child2 = document.createElement('p');
expect(node.appendChild(child2)).toBe(child2);
expect(node.childNodes[1]).toEqual(child2);
expect(child2.parentElement).toBe(node);
expect(child2.parentNode).toBe(node);
});
it('can insert new child before an existing child', () => {
const node = document.createElement('div');
const child1 = document.createElement('h1');
const child2 = document.createElement('h2');
expect(() =>
node.insertBefore(node, child1),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode."`,
);
expect(node.insertBefore(child1, null)).toBe(child1);
expect(node.childNodes[0]).toBe(child1);
expect(child1.parentNode).toBe(node);
expect(child1.parentElement).toBe(node);
expect(node.insertBefore(child2, child1)).toBe(child2);
expect(node.childNodes.length).toBe(2);
expect(node.childNodes[0]).toBe(child2);
expect(node.childNodes[1]).toBe(child1);
expect(child2.parentNode).toBe(node);
expect(child2.parentElement).toBe(node);
});
it('can remove an existing child', () => {
const node = document.createElement('div');
const child1 = document.createElement('h1');
const child2 = document.createElement('h2');
node.appendChild(child1);
node.appendChild(child2);
expect(node.childNodes.length).toBe(2);
expect(child1.parentNode).toBe(node);
expect(child2.parentNode).toBe(node);
expect(child1.parentElement).toBe(node);
expect(child2.parentElement).toBe(node);
expect(() =>
node.removeChild(document.createElement('div')),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to execute 'removeChild' on 'RRElement': The RRNode to be removed is not a child of this RRNode."`,
);
expect(node.removeChild(child1)).toBe(child1);
expect(child1.parentNode).toBeNull();
expect(child1.parentElement).toBeNull();
expect(node.childNodes.length).toBe(1);
expect(node.removeChild(child2)).toBe(child2);
expect(node.childNodes.length).toBe(0);
expect(child2.parentNode).toBeNull();
expect(child2.parentElement).toBeNull();
});
});
describe('Basic RRText implementation', () => {
const dom = new RRDocument();
it('should have basic properties', () => {
const node = dom.createTextNode('text');
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBe(dom);
expect(node.textContent).toEqual('text');
expect(node.RRNodeType).toBe(RRNodeType.Text);
expect(node.nodeType).toBe(document.TEXT_NODE);
expect(node.nodeName).toBe('#text');
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.toString()).toEqual('RRText text="text"');
});
it('can set textContent', () => {
const node = dom.createTextNode('text');
expect(node.textContent).toEqual('text');
node.textContent = 'new text';
expect(node.textContent).toEqual('new text');
});
});
describe('Basic RRComment implementation', () => {
const dom = new RRDocument();
it('should have basic properties', () => {
const node = dom.createComment('comment');
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBe(dom);
expect(node.textContent).toEqual('comment');
expect(node.RRNodeType).toBe(RRNodeType.Comment);
expect(node.nodeType).toBe(document.COMMENT_NODE);
expect(node.nodeName).toBe('#comment');
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.toString()).toEqual('RRComment text="comment"');
});
it('can set textContent', () => {
const node = dom.createComment('comment');
expect(node.textContent).toEqual('comment');
node.textContent = 'new comment';
expect(node.textContent).toEqual('new comment');
});
});
describe('Basic RRCDATASection implementation', () => {
const dom = new RRDocument();
it('should have basic properties', () => {
const node = dom.createCDATASection('data');
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBe(dom);
expect(node.textContent).toEqual('data');
expect(node.RRNodeType).toBe(RRNodeType.CDATA);
expect(node.nodeType).toBe(document.CDATA_SECTION_NODE);
expect(node.nodeName).toBe('#cdata-section');
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.lastChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.toString()).toEqual('RRCDATASection data="data"');
});
it('can set textContent', () => {
const node = dom.createCDATASection('data');
expect(node.textContent).toEqual('data');
node.textContent = 'new data';
expect(node.textContent).toEqual('new data');
});
});
describe('Basic RRMediaElement implementation', () => {
it('should have basic properties', () => {
const node = new RRMediaElement('video');
node.scrollLeft = 100;
node.scrollTop = 200;
expect(node.parentNode).toEqual(null);
expect(node.parentElement).toEqual(null);
expect(node.childNodes).toBeInstanceOf(Array);
expect(node.childNodes.length).toBe(0);
expect(node.ownerDocument).toBeUndefined();
expect(node.textContent).toEqual('');
expect(node.RRNodeType).toBe(RRNodeType.Element);
expect(node.nodeType).toBe(document.ELEMENT_NODE);
expect(node.ELEMENT_NODE).toBe(document.ELEMENT_NODE);
expect(node.TEXT_NODE).toBe(document.TEXT_NODE);
expect(node.firstChild).toBeNull();
expect(node.nextSibling).toBeNull();
expect(node.contains).toBeDefined();
expect(node.appendChild).toBeDefined();
expect(node.insertBefore).toBeDefined();
expect(node.removeChild).toBeDefined();
expect(node.tagName).toEqual('VIDEO');
expect(node.attributes).toEqual({});
expect(node.shadowRoot).toBeNull();
expect(node.scrollLeft).toEqual(100);
expect(node.scrollTop).toEqual(200);
expect(node.id).toEqual('');
expect(node.className).toEqual('');
expect(node.classList).toBeDefined();
expect(node.style).toBeDefined();
expect(node.getAttribute).toBeDefined();
expect(node.setAttribute).toBeDefined();
expect(node.setAttributeNS).toBeDefined();
expect(node.removeAttribute).toBeDefined();
expect(node.attachShadow).toBeDefined();
expect(node.dispatchEvent).toBeDefined();
expect(node.currentTime).toBeUndefined();
expect(node.volume).toBeUndefined();
expect(node.paused).toBeUndefined();
expect(node.muted).toBeUndefined();
expect(node.play).toBeDefined();
expect(node.pause).toBeDefined();
expect(node.toString()).toEqual('VIDEO ');
});
it('can play and pause the media', () => {
const node = new RRMediaElement('video');
expect(node.paused).toBeUndefined();
node.play();
expect(node.paused).toBeFalsy();
node.pause();
expect(node.paused).toBeTruthy();
node.play();
expect(node.paused).toBeFalsy();
});
it('should not support attachShadow function', () => {
const node = new RRMediaElement('video');
expect(() =>
node.attachShadow({ mode: 'open' }),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'attachShadow' on 'RRElement': This RRElement does not support attachShadow"`,
);
});
});
});

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Iframe</title>
</head>
<body>
<iframe
id="iframe1"
srcdoc="
<html>
<head>
<meta charset='UTF-8' />
<meta
name='viewport'
content='width=device-width, initial-scale=1.0'
/>
</head>
<body>
<div>This is a block inside the iframe1.</div>
<iframe id='iframe3' srcdoc='<div>This is a block inside the iframe3.</div>'>
</body>
</html>"
></iframe>
<iframe
id="iframe2"
srcdoc="<div>This is a block inside the iframe2.</div>"
></iframe>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Main</title>
<link rel="stylesheet" href="somelink">
<link rel="stylesheet" href="somelink" />
<style>
h1 {
color: 'black';
@@ -19,7 +19,7 @@
width: 100px;
height: 200px;
}
@import url("main.css");
@import url('main.css');
</style>
</head>
<body>
@@ -35,6 +35,10 @@
Text 2
</div>
<img src="somelink" alt="This is an image" />
<!-- This is a line of comment -->
<form>
<input type="text" id="input1" />
</form>
</div>
</body>
</html>

View 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" />
<title>shadow dom</title>
</head>
<body>
<div>
<template shadowroot="open">
<span> shadow dom one </span>
<div>
<template shadowroot="open">
<span> shadow dom two </span>
</template>
</div>
</template>
</div>
</body>
</html>

View File

@@ -1,3 +1,4 @@
import { compare } from 'compare-versions';
import { RRDocument, RRNode } from '../src/document-nodejs';
import {
polyfillPerformance,
@@ -9,7 +10,8 @@ import {
describe('polyfill for nodejs', () => {
it('should polyfill performance api', () => {
expect(global.performance).toBeUndefined();
if (compare(process.version, 'v16.0.0', '<'))
expect(global.performance).toBeUndefined();
polyfillPerformance();
expect(global.performance).toBeDefined();
expect(performance).toBeDefined();
@@ -20,6 +22,18 @@ describe('polyfill for nodejs', () => {
);
});
it('should not polyfill performance if it already exists', () => {
if (compare(process.version, 'v16.0.0', '>=')) {
const originalPerformance = global.performance;
polyfillPerformance();
expect(global.performance).toBe(originalPerformance);
}
const fakePerformance = (jest.fn() as unknown) as Performance;
global.performance = fakePerformance;
polyfillPerformance();
expect(global.performance).toEqual(fakePerformance);
});
it('should polyfill requestAnimationFrame', () => {
expect(global.requestAnimationFrame).toBeUndefined();
expect(global.cancelAnimationFrame).toBeUndefined();
@@ -59,12 +73,32 @@ describe('polyfill for nodejs', () => {
jest.useRealTimers();
});
it('should not polyfill requestAnimationFrame if it already exists', () => {
const fakeRequestAnimationFrame = (jest.fn() as unknown) as typeof global.requestAnimationFrame;
global.requestAnimationFrame = fakeRequestAnimationFrame;
const fakeCancelAnimationFrame = (jest.fn() as unknown) as typeof global.cancelAnimationFrame;
global.cancelAnimationFrame = fakeCancelAnimationFrame;
polyfillRAF();
expect(global.requestAnimationFrame).toBe(fakeRequestAnimationFrame);
expect(global.cancelAnimationFrame).toBe(fakeCancelAnimationFrame);
});
it('should polyfill Event type', () => {
// if the second version is greater
if (compare(process.version, 'v15.0.0', '<'))
expect(global.Event).toBeUndefined();
polyfillEvent();
expect(global.Event).toBeDefined();
expect(Event).toBeDefined();
});
it('should not polyfill Event type if it already exists', () => {
const fakeEvent = (jest.fn() as unknown) as typeof global.Event;
global.Event = fakeEvent;
polyfillEvent();
expect(global.Event).toBe(fakeEvent);
});
it('should polyfill Node type', () => {
expect(global.Node).toBeUndefined();
polyfillNode();
@@ -73,6 +107,13 @@ describe('polyfill for nodejs', () => {
expect(Node).toEqual(RRNode);
});
it('should not polyfill Node type if it already exists', () => {
const fakeNode = (jest.fn() as unknown) as typeof global.Node;
global.Node = fakeNode;
polyfillNode();
expect(global.Node).toBe(fakeNode);
});
it('should polyfill document object', () => {
expect(global.document).toBeUndefined();
polyfillDocument();
@@ -80,4 +121,11 @@ describe('polyfill for nodejs', () => {
expect(document).toBeDefined();
expect(document).toBeInstanceOf(RRDocument);
});
it('should not polyfill document object if it already exists', () => {
const fakeDocument = (jest.fn() as unknown) as typeof global.document;
global.document = fakeDocument;
polyfillDocument();
expect(global.document).toBe(fakeDocument);
});
});

View File

@@ -1,19 +0,0 @@
import { RRIframeElement, RRNode } from '../src/document-nodejs';
/**
* Print the RRDom as a string.
* @param rootNode the root node of the RRDom tree
* @returns printed string
*/
export function printRRDom(rootNode: RRNode) {
return walk(rootNode, '');
}
function walk(node: RRNode, blankSpace: string) {
let printText = `${blankSpace}${node.toString()}\n`;
for (const child of node.childNodes)
printText += walk(child, blankSpace + ' ');
if (node instanceof RRIframeElement)
printText += walk(node.contentDocument, blankSpace + ' ');
return printText;
}

View File

@@ -0,0 +1,550 @@
/**
* @jest-environment jsdom
*/
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import * as rollup from 'rollup';
import resolve from '@rollup/plugin-node-resolve';
import * as typescript from 'rollup-plugin-typescript2';
import { JSDOM } from 'jsdom';
import {
cdataNode,
commentNode,
documentNode,
documentTypeNode,
elementNode,
Mirror,
NodeType,
NodeType as RRNodeType,
textNode,
} from 'rrweb-snapshot';
import {
buildFromDom,
buildFromNode,
createMirror,
getDefaultSN,
RRCanvasElement,
RRDocument,
RRElement,
RRNode,
} from '../src/virtual-dom';
const _typescript = (typescript as unknown) as typeof typescript.default;
const printRRDomCode = `
/**
* Print the RRDom as a string.
* @param rootNode the root node of the RRDom tree
* @returns printed string
*/
function printRRDom(rootNode, mirror) {
return walk(rootNode, mirror, '');
}
function walk(node, mirror, blankSpace) {
let printText = \`\${blankSpace}\${mirror.getId(node)} \${node.toString()}\n\`;
if(node instanceof rrdom.RRElement && node.shadowRoot)
printText += walk(node.shadowRoot, mirror, blankSpace + ' ');
for (const child of node.childNodes)
printText += walk(child, mirror, blankSpace + ' ');
if (node instanceof rrdom.RRIFrameElement)
printText += walk(node.contentDocument, mirror, blankSpace + ' ');
return printText;
}
`;
describe('RRDocument for browser environment', () => {
let mirror: Mirror;
beforeEach(() => {
mirror = new Mirror();
});
describe('create a RRNode from a real Node', () => {
it('should support quicksmode documents', () => {
// seperate jsdom document as changes to the document would otherwise bleed into other tests
const dom = new JSDOM();
const document = dom.window.document;
expect(document.doctype).toBeNull(); // confirm compatMode is 'BackCompat' in JSDOM
const rrdom = new RRDocument();
let rrNode = buildFromNode(document, rrdom, mirror)!;
expect((rrNode as RRDocument).compatMode).toBe('BackCompat');
});
it('can patch serialized ID for an unserialized node', () => {
// build from document
expect(mirror.getMeta(document)).toBeNull();
const rrdom = new RRDocument();
let rrNode = buildFromNode(document, rrdom, mirror)!;
expect(mirror.getMeta(document)).toBeDefined();
expect(mirror.getId(document)).toEqual(-1);
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Document);
expect(rrdom.mirror.getId(rrNode)).toEqual(-1);
expect(rrNode).toBe(rrdom);
// build from document type
expect(mirror.getMeta(document.doctype!)).toBeNull();
rrNode = buildFromNode(document.doctype!, rrdom, mirror)!;
expect(mirror.getMeta(document.doctype!)).toBeDefined();
expect(mirror.getId(document.doctype)).toEqual(-2);
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(
RRNodeType.DocumentType,
);
expect(rrdom.mirror.getId(rrNode)).toEqual(-2);
// build from element
expect(mirror.getMeta(document.documentElement)).toBeNull();
rrNode = buildFromNode(
(document.documentElement as unknown) as Node,
rrdom,
mirror,
)!;
expect(mirror.getMeta(document.documentElement)).toBeDefined();
expect(mirror.getId(document.documentElement)).toEqual(-3);
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Element);
expect(rrdom.mirror.getId(rrNode)).toEqual(-3);
// build from text
const text = document.createTextNode('text');
expect(mirror.getMeta(text)).toBeNull();
rrNode = buildFromNode(text, rrdom, mirror)!;
expect(mirror.getMeta(text)).toBeDefined();
expect(mirror.getId(text)).toEqual(-4);
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Text);
expect(rrdom.mirror.getId(rrNode)).toEqual(-4);
// build from comment
const comment = document.createComment('comment');
expect(mirror.getMeta(comment)).toBeNull();
rrNode = buildFromNode(comment, rrdom, mirror)!;
expect(mirror.getMeta(comment)).toBeDefined();
expect(mirror.getId(comment)).toEqual(-5);
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Comment);
expect(rrdom.mirror.getId(rrNode)).toEqual(-5);
// build from CDATASection
const xmlDoc = new DOMParser().parseFromString(
'<xml></xml>',
'application/xml',
);
const cdata = 'Some <CDATA> data & then some';
var cdataSection = xmlDoc.createCDATASection(cdata);
expect(mirror.getMeta(cdataSection)).toBeNull();
expect(mirror.getMeta(cdataSection)).toBeNull();
rrNode = buildFromNode(cdataSection, rrdom, mirror)!;
expect(mirror.getMeta(cdataSection)).toBeDefined();
expect(mirror.getId(cdataSection)).toEqual(-6);
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.CDATA);
expect(rrdom.mirror.getId(rrNode)).toEqual(-6);
expect(rrNode.textContent).toEqual(cdata);
});
it('can record scroll position from HTMLElements', () => {
expect(document.body.scrollLeft).toEqual(0);
expect(document.body.scrollTop).toEqual(0);
const rrdom = new RRDocument();
let rrNode = buildFromNode(document.body, rrdom, mirror)!;
expect((rrNode as RRElement).scrollLeft).toBeUndefined();
expect((rrNode as RRElement).scrollTop).toBeUndefined();
document.body.scrollLeft = 100;
document.body.scrollTop = 200;
expect(document.body.scrollLeft).toEqual(100);
expect(document.body.scrollTop).toEqual(200);
rrNode = buildFromNode(document.body, rrdom, mirror)!;
expect((rrNode as RRElement).scrollLeft).toEqual(100);
expect((rrNode as RRElement).scrollTop).toEqual(200);
});
it('can build contentDocument from an iframe element', () => {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
expect(iframe.contentDocument).not.toBeNull();
const rrdom = new RRDocument();
const RRIFrame = rrdom.createElement('iframe');
const rrNode = buildFromNode(
iframe.contentDocument!,
rrdom,
mirror,
RRIFrame,
)!;
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Document);
expect(rrdom.mirror.getId(rrNode)).toEqual(-1);
expect(mirror.getId(iframe.contentDocument)).toEqual(-1);
expect(rrNode).toBe(RRIFrame.contentDocument);
});
it('can build from a shadow dom', () => {
const div = document.createElement('div');
div.attachShadow({ mode: 'open' });
expect(div.shadowRoot).toBeDefined();
const rrdom = new RRDocument();
const parentRRNode = rrdom.createElement('div');
const rrNode = buildFromNode(
div.shadowRoot!,
rrdom,
mirror,
parentRRNode,
)!;
expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getId(rrNode)).toEqual(-1);
expect(mirror.getId(div.shadowRoot)).toEqual(-1);
expect(rrNode.RRNodeType).toEqual(RRNodeType.Element);
expect((rrNode as RRElement).tagName).toEqual('SHADOWROOT');
expect(rrNode).toBe(parentRRNode.shadowRoot);
});
});
describe('create a RRDocument from a html document', () => {
let browser: puppeteer.Browser;
let code: string;
let page: puppeteer.Page;
beforeAll(async () => {
browser = await puppeteer.launch();
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/virtual-dom.ts'),
plugins: [
resolve(),
(_typescript({
tsconfigOverride: { compilerOptions: { module: 'ESNext' } },
}) as unknown) as rollup.Plugin,
],
});
const {
output: [{ code: _code }],
} = await bundle.generate({
name: 'rrdom',
format: 'iife',
});
code = _code;
});
afterAll(async () => {
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(code + printRRDomCode);
});
afterEach(async () => {
await page.close();
});
it('can build from a common html', async () => {
await page.setContent(getHtml('main.html'));
const result = await page.evaluate(`
const doc = new rrdom.RRDocument();
rrdom.buildFromDom(document, undefined, doc);
printRRDom(doc, doc.mirror);
`);
expect(result).toMatchSnapshot();
});
it('can build from an iframe html ', async () => {
await page.setContent(getHtml('iframe.html'));
const result = await page.evaluate(`
const doc = new rrdom.RRDocument();
rrdom.buildFromDom(document, undefined, doc);
printRRDom(doc, doc.mirror);
`);
expect(result).toMatchSnapshot();
});
it('can build from a html containing nested shadow doms', async () => {
await page.setContent(getHtml('shadow-dom.html'));
const result = await page.evaluate(`
const doc = new rrdom.RRDocument();
rrdom.buildFromDom(document, undefined, doc);
printRRDom(doc, doc.mirror);
`);
expect(result).toMatchSnapshot();
});
it('can build from a xml page', async () => {
const result = await page.evaluate(`
var docu = new DOMParser().parseFromString('<xml></xml>', 'application/xml');
var cdata = docu.createCDATASection('Some <CDATA> data & then some');
docu.getElementsByTagName('xml')[0].appendChild(cdata);
// Displays: <xml><![CDATA[Some <CDATA> data & then some]]></xml>
const doc = new rrdom.RRDocument();
rrdom.buildFromDom(docu, undefined, doc);
printRRDom(doc, doc.mirror);
`);
expect(result).toMatchSnapshot();
});
});
describe('RRDocument build for virtual dom', () => {
it('can access a unique, decremented unserializedId every time', () => {
const node = new RRDocument();
for (let i = 1; i <= 100; i++) expect(node.unserializedId).toBe(-i);
});
it('can create a new RRDocument', () => {
const dom = new RRDocument();
const newDom = dom.createDocument('', '');
expect(newDom).toBeInstanceOf(RRDocument);
});
it('can create a new RRDocument receiving a mirror parameter', () => {
const mirror = createMirror();
const dom = new RRDocument(mirror);
const newDom = dom.createDocument('', '');
expect(newDom).toBeInstanceOf(RRDocument);
expect(dom.mirror).toBe(mirror);
});
it('can build a RRDocument from a real Dom', () => {
const result = buildFromDom(document, mirror);
expect(result.childNodes.length).toBe(2);
expect(result.documentElement).toBeDefined();
expect(result.head).toBeDefined();
expect(result.head!.tagName).toBe('HEAD');
expect(result.body).toBeDefined();
expect(result.body!.tagName).toBe('BODY');
});
it('can destroy a RRDocument tree', () => {
const dom = new RRDocument();
const node1 = dom.createDocumentType('', '', '');
dom.appendChild(node1);
dom.mirror.add(node1, {
id: 0,
type: NodeType.DocumentType,
name: '',
publicId: '',
systemId: '',
});
const node2 = dom.createElement('html');
dom.appendChild(node2);
dom.mirror.add(node1, {
id: 1,
type: NodeType.Document,
childNodes: [],
});
expect(dom.childNodes.length).toEqual(2);
expect(dom.mirror.has(0)).toBeTruthy();
expect(dom.mirror.has(1)).toBeTruthy();
dom.destroyTree();
expect(dom.childNodes.length).toEqual(0);
expect(dom.mirror.has(0)).toBeFalsy();
expect(dom.mirror.has(1)).toBeFalsy();
});
it('can close and open a RRDocument', () => {
const dom = new RRDocument();
const documentType = dom.createDocumentType('html', '', '');
dom.appendChild(documentType);
expect(dom.childNodes[0]).toBe(documentType);
expect(dom.unserializedId).toBe(-1);
expect(dom.unserializedId).toBe(-2);
expect(dom.close());
expect(dom.open());
expect(dom.childNodes.length).toEqual(0);
expect(dom.unserializedId).toBe(-1);
});
it('can execute a dummy getContext function in RRCanvasElement', () => {
const canvas = new RRCanvasElement('CANVAS');
expect(canvas.getContext).toBeDefined();
expect(canvas.getContext()).toBeNull();
});
describe('Mirror in the RRDocument', () => {
it('should have a mirror to store id and node', () => {
const dom = new RRDocument();
expect(dom.mirror).toBeDefined();
const node1 = dom.createElement('div');
dom.mirror.add(node1, getDefaultSN(node1, 0));
const node2 = dom.createTextNode('text');
dom.mirror.add(node2, getDefaultSN(node2, 1));
expect(dom.mirror.getNode(0)).toBe(node1);
expect(dom.mirror.getNode(1)).toBe(node2);
expect(dom.mirror.getNode(2)).toBeNull();
expect(dom.mirror.getNode(-1)).toBeNull();
});
it('can get node id', () => {
const dom = new RRDocument();
const node1 = dom.createElement('div');
dom.mirror.add(node1, getDefaultSN(node1, 0));
expect(dom.mirror.getId(node1)).toEqual(0);
const node2 = dom.createTextNode('text');
expect(dom.mirror.getId(node2)).toEqual(-1);
expect(dom.mirror.getId((null as unknown) as RRNode)).toEqual(-1);
});
it('has() should return whether the mirror has an ID', () => {
const dom = new RRDocument();
const node1 = dom.createElement('div');
dom.mirror.add(node1, getDefaultSN(node1, 0));
const node2 = dom.createTextNode('text');
dom.mirror.add(node2, getDefaultSN(node2, 1));
expect(dom.mirror.has(0)).toBeTruthy();
expect(dom.mirror.has(1)).toBeTruthy();
expect(dom.mirror.has(2)).toBeFalsy();
expect(dom.mirror.has(-1)).toBeFalsy();
});
it('can remove node from the mirror', () => {
const dom = new RRDocument();
const node1 = dom.createElement('div');
dom.mirror.add(node1, getDefaultSN(node1, 0));
const node2 = dom.createTextNode('text');
dom.mirror.add(node2, getDefaultSN(node2, 1));
node1.appendChild(node2);
expect(dom.mirror.has(0)).toBeTruthy();
expect(dom.mirror.has(1)).toBeTruthy();
dom.mirror.removeNodeFromMap(node2);
expect(dom.mirror.has(0)).toBeTruthy();
expect(dom.mirror.has(1)).toBeFalsy();
dom.mirror.add(node2, getDefaultSN(node2, 1));
expect(dom.mirror.has(1)).toBeTruthy();
// To remove node1 and its child node2 from the mirror.
dom.mirror.removeNodeFromMap(node1);
expect(dom.mirror.has(0)).toBeFalsy();
expect(dom.mirror.has(1)).toBeFalsy();
});
it('can reset the mirror', () => {
const dom = new RRDocument();
const node1 = dom.createElement('div');
dom.mirror.add(node1, getDefaultSN(node1, 0));
const node2 = dom.createTextNode('text');
dom.mirror.add(node2, getDefaultSN(node2, 1));
expect(dom.mirror.has(0)).toBeTruthy();
expect(dom.mirror.has(1)).toBeTruthy();
dom.mirror.reset();
expect(dom.mirror.has(0)).toBeFalsy();
expect(dom.mirror.has(1)).toBeFalsy();
});
it('hasNode() should return whether the mirror has a node', () => {
const dom = new RRDocument();
const node1 = dom.createElement('div');
const node2 = dom.createTextNode('text');
expect(dom.mirror.hasNode(node1)).toBeFalsy();
dom.mirror.add(node1, getDefaultSN(node1, 0));
expect(dom.mirror.hasNode(node1)).toBeTruthy();
expect(dom.mirror.hasNode(node2)).toBeFalsy();
dom.mirror.add(node2, getDefaultSN(node2, 1));
expect(dom.mirror.hasNode(node2)).toBeTruthy();
});
it('can get all IDs from the mirror', () => {
const dom = new RRDocument();
expect(dom.mirror.getIds().length).toBe(0);
const node1 = dom.createElement('div');
dom.mirror.add(node1, getDefaultSN(node1, 0));
const node2 = dom.createTextNode('text');
dom.mirror.add(node2, getDefaultSN(node2, 1));
expect(dom.mirror.getIds().length).toBe(2);
expect(dom.mirror.getIds()).toStrictEqual([0, 1]);
});
it('can replace nodes', () => {
const dom = new RRDocument();
expect(dom.mirror.getIds().length).toBe(0);
const node1 = dom.createElement('div');
dom.mirror.add(node1, getDefaultSN(node1, 0));
expect(dom.mirror.getNode(0)).toBe(node1);
const node2 = dom.createTextNode('text');
dom.mirror.replace(0, node2);
expect(dom.mirror.getNode(0)).toBe(node2);
});
});
});
describe('can get default SN value from a RRNode', () => {
const rrdom = new RRDocument();
it('can get from RRDocument', () => {
const node = rrdom;
const sn = getDefaultSN(node, 1);
expect(sn).toBeDefined();
expect(sn.type).toEqual(RRNodeType.Document);
expect((sn as documentNode).childNodes).toBeInstanceOf(Array);
});
it('can get from RRDocumentType', () => {
const name = 'name',
publicId = 'publicId',
systemId = 'systemId';
const node = rrdom.createDocumentType(name, publicId, systemId);
const sn = getDefaultSN(node, 1);
expect(sn).toBeDefined();
expect(sn.type).toEqual(RRNodeType.DocumentType);
expect((sn as documentTypeNode).name).toEqual(name);
expect((sn as documentTypeNode).publicId).toEqual(publicId);
expect((sn as documentTypeNode).systemId).toEqual(systemId);
});
it('can get from RRElement', () => {
const node = rrdom.createElement('div');
const sn = getDefaultSN(node, 1);
expect(sn).toBeDefined();
expect(sn.type).toEqual(RRNodeType.Element);
expect((sn as elementNode).tagName).toEqual('div');
expect((sn as elementNode).attributes).toBeDefined();
expect((sn as elementNode).childNodes).toBeInstanceOf(Array);
});
it('can get from RRText', () => {
const node = rrdom.createTextNode('text');
const sn = getDefaultSN(node, 1);
expect(sn).toBeDefined();
expect(sn.type).toEqual(RRNodeType.Text);
expect((sn as textNode).textContent).toEqual('text');
});
it('can get from RRComment', () => {
const node = rrdom.createComment('comment');
const sn = getDefaultSN(node, 1);
expect(sn).toBeDefined();
expect(sn.type).toEqual(RRNodeType.Comment);
expect((sn as commentNode).textContent).toEqual('comment');
});
it('can get from RRCDATASection', () => {
const node = rrdom.createCDATASection('data');
const sn = getDefaultSN(node, 1);
expect(sn).toBeDefined();
expect(sn.type).toEqual(RRNodeType.CDATA);
expect((sn as cdataNode).textContent).toEqual('');
});
});
});
function getHtml(fileName: string) {
const filePath = path.resolve(__dirname, `./html/${fileName}`);
return fs.readFileSync(filePath, 'utf8');
}

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES6",
"module": "commonjs",
"noImplicitAny": true,
"strictNullChecks": true,
@@ -11,9 +11,10 @@
"outDir": "build",
"lib": ["es6", "dom"],
"skipLibCheck": true,
"declaration": true
"declaration": true,
"importsNotUsedAsValues": "error"
},
"compileOnSave": true,
"exclude": ["test"],
"include": ["src", "test.d.ts"]
"include": ["src", "test.d.ts", "../rrweb/src/record/workers/workers.d.ts"]
}

View File

@@ -2,26 +2,28 @@
"name": "rrweb-player",
"version": "0.7.14",
"devDependencies": {
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^7.0.0",
"@rollup/plugin-typescript": "^4.0.0",
"@rollup/plugin-commonjs": "^22.0.0",
"@rollup/plugin-node-resolve": "^13.2.1",
"@rollup/plugin-typescript": "^8.3.2",
"@types/offscreencanvas": "^2019.6.4",
"@typescript-eslint/eslint-plugin": "^3.7.0",
"@typescript-eslint/parser": "^3.7.0",
"eslint": "^7.5.0",
"eslint-config-google": "^0.11.0",
"eslint-plugin-svelte3": "^2.7.3",
"postcss-easy-import": "^3.0.0",
"rollup": "^2.45.2",
"rollup": "^2.71.1",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-web-worker-loader": "^1.6.1",
"sirv-cli": "^0.4.4",
"svelte": "^3.2.0",
"svelte-check": "^1.4.0",
"svelte-preprocess": "^4.0.0",
"tslib": "^2.0.0",
"typescript": "^3.9.7"
"typescript": "^4.6.4"
},
"dependencies": {
"@tsconfig/svelte": "^1.0.0",

View File

@@ -4,6 +4,7 @@ import commonjs from '@rollup/plugin-commonjs';
import livereload from 'rollup-plugin-livereload';
import { terser } from 'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import webWorkerLoader from 'rollup-plugin-web-worker-loader';
import typescript from '@rollup/plugin-typescript';
import pkg from './package.json';
import css from 'rollup-plugin-css-only';
@@ -64,8 +65,12 @@ export default entries.map((output) => ({
browser: true,
dedupe: ['svelte'],
}),
commonjs(),
// supports bundling `web-worker:..filename` from rrweb
webWorkerLoader(),
typescript(),
css({

View File

@@ -1,5 +1,10 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"],
}
"exclude": [
"node_modules/*",
"__sapper__/*",
"public/*",
"../rrweb/src/record/workers/workers.d.ts"
]
}

View File

@@ -330,7 +330,7 @@ export function buildNodeWithSN(
if (n.rootId) {
console.assert(
(mirror.getNode(n.rootId) as Document) === doc,
'Target document should has the same root id.',
'Target document should have the same root id.',
);
}
// use target document as root document

View File

@@ -76,6 +76,28 @@ export interface ICanvas extends HTMLCanvasElement {
__context: string;
}
export interface IMirror<TNode> {
getId(n: TNode | undefined | null): number;
getNode(id: number): TNode | null;
getIds(): number[];
getMeta(n: TNode): serializedNodeWithId | null;
removeNodeFromMap(n: TNode): void;
has(id: number): boolean;
hasNode(node: TNode): boolean;
add(n: TNode, meta: serializedNodeWithId): void;
replace(id: number, n: TNode): void;
reset(): void;
}
export type idNodeMap = Map<number, Node>;
export type nodeMetaMap = WeakMap<Node, serializedNodeWithId>;

View File

@@ -3,6 +3,7 @@ import {
MaskInputFn,
MaskInputOptions,
nodeMetaMap,
IMirror,
serializedNodeWithId,
} from './types';
@@ -15,7 +16,7 @@ export function isShadowRoot(n: Node): n is ShadowRoot {
return Boolean(host && host.shadowRoot && host.shadowRoot === n);
}
export class Mirror {
export class Mirror implements IMirror<Node> {
private idNodeMap: idNodeMap = new Map();
private nodeMetaMap: nodeMetaMap = new WeakMap();
@@ -47,7 +48,9 @@ export class Mirror {
this.idNodeMap.delete(id);
if (n.childNodes) {
n.childNodes.forEach((childNode) => this.removeNodeFromMap(childNode));
n.childNodes.forEach((childNode) =>
this.removeNodeFromMap((childNode as unknown) as Node),
);
}
}
has(id: number): boolean {

View File

@@ -58,6 +58,18 @@ export interface INode extends Node {
export interface ICanvas extends HTMLCanvasElement {
__context: string;
}
export interface IMirror<TNode> {
getId(n: TNode | undefined | null): number;
getNode(id: number): TNode | null;
getIds(): number[];
getMeta(n: TNode): serializedNodeWithId | null;
removeNodeFromMap(n: TNode): void;
has(id: number): boolean;
hasNode(node: TNode): boolean;
add(n: TNode, meta: serializedNodeWithId): void;
replace(id: number, n: TNode): void;
reset(): void;
}
export declare type idNodeMap = Map<number, Node>;
export declare type nodeMetaMap = WeakMap<Node, serializedNodeWithId>;
export declare type MaskInputOptions = Partial<{

View File

@@ -1,7 +1,7 @@
import { MaskInputFn, MaskInputOptions, serializedNodeWithId } from './types';
import { MaskInputFn, MaskInputOptions, IMirror, serializedNodeWithId } from './types';
export declare function isElement(n: Node): n is Element;
export declare function isShadowRoot(n: Node): n is ShadowRoot;
export declare class Mirror {
export declare class Mirror implements IMirror<Node> {
private idNodeMap;
private nodeMetaMap;
getId(n: Node | undefined | null): number;

View File

@@ -5,5 +5,6 @@ module.exports = {
testMatch: ['**/**.test.ts'],
moduleNameMapper: {
'\\.css$': 'identity-obj-proxy',
'rrdom/es/(.*)': 'rrdom/lib/$1',
},
};

View File

@@ -46,12 +46,12 @@
"@types/inquirer": "0.0.43",
"@types/jest": "^27.4.1",
"@types/jest-image-snapshot": "^4.3.1",
"@types/jsdom": "^16.2.14",
"@types/node": "^17.0.21",
"@types/offscreencanvas": "^2019.6.4",
"@types/prettier": "^2.3.2",
"@types/puppeteer": "^5.4.4",
"cross-env": "^5.2.0",
"esbuild": "^0.14.38",
"fast-mhtml": "^1.1.9",
"identity-obj-proxy": "^3.0.0",
"ignore-styles": "^5.0.1",
@@ -59,14 +59,12 @@
"jest": "^27.5.1",
"jest-image-snapshot": "^4.5.1",
"jest-snapshot": "^23.6.0",
"jsdom": "^17.0.0",
"jsdom-global": "^3.0.2",
"prettier": "2.2.1",
"puppeteer": "^9.1.1",
"rollup": "^2.68.0",
"rollup-plugin-esbuild": "^4.9.1",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-rename-node-modules": "^1.3.1",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.31.2",
"rollup-plugin-web-worker-loader": "^1.6.1",
"ts-jest": "^27.1.3",
@@ -81,6 +79,7 @@
"base64-arraybuffer": "^1.0.1",
"fflate": "^0.4.4",
"mitt": "^1.1.3",
"rrdom": "^0.1.2",
"rrweb-snapshot": "^1.1.14"
}
}

View File

@@ -1,6 +1,6 @@
import typescript from 'rollup-plugin-typescript2';
import esbuild from 'rollup-plugin-esbuild';
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 webWorkerLoader from 'rollup-plugin-web-worker-loader';
@@ -108,10 +108,34 @@ const baseConfigs = [
let configs = [];
function getPlugins(options = {}) {
const { minify = false, sourceMap = false } = options;
return [
resolve({ browser: true }),
webWorkerLoader({
targetPlatform: 'browser',
inline: true,
sourceMap,
}),
esbuild({
minify,
}),
postcss({
extract: false,
inject: false,
minimize: minify,
sourceMap,
}),
];
}
for (const c of baseConfigs) {
const basePlugins = [
resolve({ browser: true }),
// supports bundling `web-worker:..filename`
webWorkerLoader(),
typescript(),
];
const plugins = basePlugins.concat(
@@ -123,7 +147,7 @@ for (const c of baseConfigs) {
// browser
configs.push({
input: c.input,
plugins,
plugins: getPlugins(),
output: [
{
name: c.name,
@@ -135,14 +159,7 @@ for (const c of baseConfigs) {
// browser + minify
configs.push({
input: c.input,
plugins: basePlugins.concat(
postcss({
extract: true,
minimize: true,
sourceMap: true,
}),
terser(),
),
plugins: getPlugins({ minify: true, sourceMap: true }),
output: [
{
name: c.name,
@@ -197,23 +214,9 @@ if (process.env.BROWSER_ONLY) {
configs = [];
for (const c of browserOnlyBaseConfigs) {
const plugins = [
resolve({ browser: true }),
webWorkerLoader(),
typescript({
outDir: null,
}),
postcss({
extract: false,
inject: false,
sourceMap: true,
}),
terser(),
];
configs.push({
input: c.input,
plugins,
plugins: getPlugins({ sourceMap: true, minify: true }),
output: [
{
name: c.name,

View File

@@ -1,4 +1,4 @@
import { eventWithTime } from '../types';
import type { eventWithTime } from '../types';
export type PackFn = (event: eventWithTime) => string;
export type UnpackFn = (raw: string) => eventWithTime;

View File

@@ -1,6 +1,6 @@
import { strFromU8, strToU8, unzlibSync } from 'fflate';
import { UnpackFn, eventWithTimeAndPacker, MARK } from './base';
import { eventWithTime } from '../types';
import type { eventWithTime } from '../types';
export const unpack: UnpackFn = (raw: string) => {
if (typeof raw !== 'string') {
@@ -16,7 +16,7 @@ export const unpack: UnpackFn = (raw: string) => {
}
try {
const e: eventWithTimeAndPacker = JSON.parse(
strFromU8(unzlibSync(strToU8(raw, true)))
strFromU8(unzlibSync(strToU8(raw, true))),
);
if (e.v === MARK) {
return e;

View File

@@ -1,4 +1,4 @@
import { listenerHandler, RecordPlugin, IWindow } from '../../../types';
import type { listenerHandler, RecordPlugin, IWindow } from '../../../types';
import { patch } from '../../../utils';
import { ErrorStackParser, StackFrame } from './error-stack-parser';
import { stringify } from './stringify';

View File

@@ -4,7 +4,7 @@
*
*/
import { StringifyOptions } from './index';
import type { StringifyOptions } from './index';
/**
* transfer the node path in Event to string

View File

@@ -1,4 +1,4 @@
import { RecordPlugin } from '../../../types';
import type { RecordPlugin } from '../../../types';
export type SequentialIdOptions = {
key: string;

View File

@@ -1,5 +1,5 @@
import type { SequentialIdOptions } from '../record';
import { ReplayPlugin, eventWithTime } from '../../../types';
import type { ReplayPlugin, eventWithTime } from '../../../types';
type Options = SequentialIdOptions & {
warnOnMissingId: boolean;

View File

@@ -1,5 +1,5 @@
import { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
import { mutationCallBack } from '../types';
import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
import type { mutationCallBack } from '../types';
export class IframeManager {
private iframes: WeakMap<HTMLIFrameElement, true> = new WeakMap();

View File

@@ -7,7 +7,7 @@ import {
maskInputValue,
Mirror,
} from 'rrweb-snapshot';
import {
import type {
mutationRecord,
textCursor,
attributeCursor,
@@ -298,7 +298,7 @@ export default class MutationBuffer {
inlineImages: this.inlineImages,
onSerialize: (currentN) => {
if (isSerializedIframe(currentN, this.mirror)) {
this.iframeManager.addIframe(currentN);
this.iframeManager.addIframe(currentN as HTMLIFrameElement);
}
if (hasShadowRoot(n)) {
this.shadowDomManager.addShadowRoot(n.shadowRoot, document);
@@ -322,7 +322,7 @@ export default class MutationBuffer {
this.mirror.removeNodeFromMap(this.mapRemoves.shift()!);
}
for (const n of this.movedSet) {
for (const n of Array.from(this.movedSet.values())) {
if (
isParentRemoved(this.removes, n, this.mirror) &&
!this.movedSet.has(n.parentNode!)
@@ -332,7 +332,7 @@ export default class MutationBuffer {
pushAdd(n);
}
for (const n of this.addedSet) {
for (const n of Array.from(this.addedSet.values())) {
if (
!isAncestorInSet(this.droppedSet, n) &&
!isParentRemoved(this.removes, n, this.mirror)

View File

@@ -1,5 +1,5 @@
import { MaskInputOptions, maskInputValue } from 'rrweb-snapshot';
import { FontFaceSet } from 'css-font-loading-module';
import type { FontFaceSet } from 'css-font-loading-module';
import {
throttle,
on,
@@ -108,9 +108,9 @@ export function initMutationObserver(
typeof MutationObserver
>)[angularZoneSymbol];
}
const observer = new mutationObserverCtor(
mutationBuffer.processMutations.bind(mutationBuffer),
);
const observer = new (mutationObserverCtor as new (
callback: MutationCallback,
) => MutationObserver)(mutationBuffer.processMutations.bind(mutationBuffer));
observer.observe(rootEl, {
attributes: true,
attributeOldValue: true,

View File

@@ -1,4 +1,4 @@
import { Mirror } from 'rrweb-snapshot';
import type { Mirror } from 'rrweb-snapshot';
import {
blockClass,
CanvasContext,

View File

@@ -1,7 +1,6 @@
import { ICanvas, Mirror } from 'rrweb-snapshot';
import {
import type { ICanvas, Mirror } from 'rrweb-snapshot';
import type {
blockClass,
CanvasContext,
canvasManagerMutationCallback,
canvasMutationCallback,
canvasMutationCommand,
@@ -10,11 +9,12 @@ import {
listenerHandler,
CanvasArg,
} from '../../../types';
import { CanvasContext } from '../../../types';
import initCanvas2DMutationObserver from './2d';
import initCanvasContextObserver from './canvas';
import initCanvasWebGLMutationObserver from './webgl';
import ImageBitmapDataURLWorker from 'web-worker:../../workers/image-bitmap-data-url-worker.ts';
import { ImageBitmapDataURLRequestWorker } from '../../workers/image-bitmap-data-url-worker';
import type { ImageBitmapDataURLRequestWorker } from '../../workers/image-bitmap-data-url-worker';
export type RafStamps = { latestId: number; invokeId: number | null };

View File

@@ -1,5 +1,5 @@
import { ICanvas } from 'rrweb-snapshot';
import { blockClass, IWindow, listenerHandler } from '../../../types';
import type { ICanvas } from 'rrweb-snapshot';
import type { blockClass, IWindow, listenerHandler } from '../../../types';
import { isBlocked, patch } from '../../../utils';
export default function initCanvasContextObserver(

View File

@@ -1,5 +1,5 @@
import { encode } from 'base64-arraybuffer';
import { IWindow, CanvasArg } from '../../../types';
import type { IWindow, CanvasArg } from '../../../types';
// TODO: unify with `replay/webgl.ts`
type CanvasVarMap = Map<string, any[]>;

View File

@@ -1,4 +1,4 @@
import { Mirror } from 'rrweb-snapshot';
import type { Mirror } from 'rrweb-snapshot';
import {
blockClass,
CanvasContext,
@@ -31,8 +31,8 @@ function patchGLPrototype(
return function (this: typeof prototype, ...args: Array<unknown>) {
const result = original.apply(this, args);
saveWebGLVar(result, win, prototype);
if (!isBlocked(this.canvas, blockClass)) {
const id = mirror.getId(this.canvas);
if (!isBlocked(this.canvas as HTMLCanvasElement, blockClass)) {
const id = mirror.getId(this.canvas as HTMLCanvasElement);
const recordArgs = serializeArgs([...args], win, prototype);
const mutation: canvasMutationWithType = {

View File

@@ -1,4 +1,4 @@
import {
import type {
mutationCallBack,
scrollCallback,
MutationBufferParam,
@@ -6,7 +6,7 @@ import {
} from '../types';
import { initMutationObserver, initScrollObserver } from './observer';
import { patch } from '../utils';
import { Mirror } from 'rrweb-snapshot';
import type { Mirror } from 'rrweb-snapshot';
type BypassOptions = Omit<
MutationBufferParam,

View File

@@ -1,5 +1,5 @@
import { encode } from 'base64-arraybuffer';
import {
import type {
ImageBitmapDataURLWorkerParams,
ImageBitmapDataURLWorkerResponse,
} from '../../types';

View File

@@ -1,5 +1,5 @@
import { Replayer } from '../';
import { canvasMutationCommand } from '../../types';
import type { Replayer } from '../';
import type { canvasMutationCommand } from '../../types';
import { deserializeArg } from './deserialize-args';
export default async function canvasMutation({

View File

@@ -1,6 +1,6 @@
import { decode } from 'base64-arraybuffer';
import type { Replayer } from '../';
import { CanvasArg, SerializedCanvasArg } from '../../types';
import type { CanvasArg, SerializedCanvasArg } from '../../types';
// TODO: add ability to wipe this list
type GLVarMap = Map<string, any[]>;

View File

@@ -1,4 +1,4 @@
import { Replayer } from '..';
import type { Replayer } from '..';
import {
CanvasContext,
canvasMutationCommand,

View File

@@ -11,9 +11,8 @@ function getContext(
// you might have to do `ctx.flush()` before every webgl canvas event
try {
if (type === CanvasContext.WebGL) {
return (
target.getContext('webgl')! || target.getContext('experimental-webgl')
);
return (target.getContext('webgl')! ||
target.getContext('experimental-webgl')) as WebGLRenderingContext;
}
return target.getContext('webgl2')!;
} catch (e) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,186 +0,0 @@
export enum StyleRuleType {
Insert,
Remove,
Snapshot,
SetProperty,
RemoveProperty,
}
type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number | number[];
};
type RemoveRule = {
type: StyleRuleType.Remove;
index: number | number[];
};
type SnapshotRule = {
type: StyleRuleType.Snapshot;
cssTexts: string[];
};
type SetPropertyRule = {
type: StyleRuleType.SetProperty;
index: number[];
property: string;
value: string | null;
priority: string | undefined;
};
type RemovePropertyRule = {
type: StyleRuleType.RemoveProperty;
index: number[];
property: string;
};
export type VirtualStyleRules = Array<
InsertRule | RemoveRule | SnapshotRule | SetPropertyRule | RemovePropertyRule
>;
export type VirtualStyleRulesMap = Map<Node, VirtualStyleRules>;
export function getNestedRule(
rules: CSSRuleList,
position: number[],
): CSSGroupingRule {
const rule = rules[position[0]] as CSSGroupingRule;
if (position.length === 1) {
return rule;
} else {
return getNestedRule(
((rule as CSSGroupingRule).cssRules[position[1]] as CSSGroupingRule)
.cssRules,
position.slice(2),
);
}
}
export function getPositionsAndIndex(nestedIndex: number[]) {
const positions = [...nestedIndex];
const index = positions.pop();
return { positions, index };
}
export function applyVirtualStyleRulesToNode(
storedRules: VirtualStyleRules,
styleNode: HTMLStyleElement,
) {
const { sheet } = styleNode;
if (!sheet) {
// styleNode without sheet means the DOM has been removed
// so the rules no longer need to be applied
return;
}
storedRules.forEach((rule) => {
if (rule.type === StyleRuleType.Insert) {
try {
if (Array.isArray(rule.index)) {
const { positions, index } = getPositionsAndIndex(rule.index);
const nestedRule = getNestedRule(sheet.cssRules, positions);
nestedRule.insertRule(rule.cssText, index);
} else {
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 {
if (Array.isArray(rule.index)) {
const { positions, index } = getPositionsAndIndex(rule.index);
const nestedRule = getNestedRule(sheet.cssRules, positions);
nestedRule.deleteRule(index || 0);
} else {
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);
} else if (rule.type === StyleRuleType.SetProperty) {
const nativeRule = (getNestedRule(
sheet.cssRules,
rule.index,
) as unknown) as CSSStyleRule;
nativeRule.style.setProperty(rule.property, rule.value, rule.priority);
} else if (rule.type === StyleRuleType.RemoveProperty) {
const nativeRule = (getNestedRule(
sheet.cssRules,
rule.index,
) as unknown) as CSSStyleRule;
nativeRule.style.removeProperty(rule.property);
}
});
}
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, [
{
type: StyleRuleType.Snapshot,
cssTexts,
},
]);
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
}

View File

@@ -1,4 +1,4 @@
import {
import type {
serializedNodeWithId,
Mirror,
INode,
@@ -7,11 +7,12 @@ import {
MaskInputFn,
MaskTextFn,
} from 'rrweb-snapshot';
import { PackFn, UnpackFn } from './packer/base';
import { IframeManager } from './record/iframe-manager';
import { ShadowDomManager } from './record/shadow-dom-manager';
import type { PackFn, UnpackFn } from './packer/base';
import type { IframeManager } from './record/iframe-manager';
import type { ShadowDomManager } from './record/shadow-dom-manager';
import type { Replayer } from './replay';
import { CanvasManager } from './record/observers/canvas/canvas-manager';
import type { RRNode } from 'rrdom/es/virtual-dom';
import type { CanvasManager } from './record/observers/canvas/canvas-manager';
export enum EventType {
DomContentLoaded,
@@ -169,6 +170,11 @@ export type eventWithTime = event & {
delay?: number;
};
export type canvasEventWithTime = eventWithTime & {
type: EventType.IncrementalSnapshot;
data: canvasMutationData;
};
export type blockClass = string | RegExp;
export type maskTextClass = string | RegExp;
@@ -653,6 +659,7 @@ export type playerConfig = {
strokeStyle?: string;
};
unpackFn?: UnpackFn;
useVirtualDom: boolean;
plugins?: ReplayPlugin[];
};
@@ -663,7 +670,7 @@ export type playerMetaData = {
};
export type missingNode = {
node: Node;
node: Node | RRNode;
mutation: addedNodeMutation;
};
export type missingNodeMap = {
@@ -706,12 +713,6 @@ export enum ReplayerEvents {
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;
declare global {

View File

@@ -1,21 +1,17 @@
import {
import type {
throttleOptions,
listenerHandler,
hookResetter,
blockClass,
IncrementalSource,
addedNodeMutation,
removedNodeMutation,
textMutation,
attributeMutation,
mutationData,
scrollData,
inputData,
DocumentDimension,
IWindow,
DeprecatedMirror,
textMutation,
} from './types';
import { Mirror, IGNORED_NODE, isShadowRoot } from 'rrweb-snapshot';
import type { IMirror, Mirror } from 'rrweb-snapshot';
import { isShadowRoot, IGNORED_NODE } from 'rrweb-snapshot';
import type { RRNode, RRIFrameElement } from 'rrdom/es/virtual-dom';
export function on(
type: string,
@@ -280,201 +276,6 @@ export function polyfill(win = window) {
}
}
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) => {
if (id === -1) return;
this.removeIdSet.add(id);
const node = mirror.getNode(id);
node?.childNodes.forEach((childNode) => {
deepRemoveFromMirror(mirror.getId(childNode));
});
};
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[];
@@ -542,13 +343,13 @@ export function iterateResolveTree(
export type AppendedIframe = {
mutationInQueue: addedNodeMutation;
builtNode: HTMLIFrameElement;
builtNode: HTMLIFrameElement | RRIFrameElement;
};
export function isSerializedIframe(
n: Node,
mirror: Mirror,
): n is HTMLIFrameElement {
export function isSerializedIframe<TNode extends Node | RRNode>(
n: TNode,
mirror: IMirror<TNode>,
): boolean {
return Boolean(n.nodeName === 'IFRAME' && mirror.getMeta(n));
}
@@ -582,12 +383,34 @@ export function getBaseDimension(
};
}
export function hasShadowRoot<T extends Node>(
export function hasShadowRoot<T extends Node | RRNode>(
n: T,
): n is T & { shadowRoot: ShadowRoot } {
return Boolean(((n as unknown) as Element)?.shadowRoot);
}
export function getNestedRule(
rules: CSSRuleList,
position: number[],
): CSSGroupingRule {
const rule = rules[position[0]] as CSSGroupingRule;
if (position.length === 1) {
return rule;
} else {
return getNestedRule(
((rule as CSSGroupingRule).cssRules[position[1]] as CSSGroupingRule)
.cssRules,
position.slice(2),
);
}
}
export function getPositionsAndIndex(nestedIndex: number[]) {
const positions = [...nestedIndex];
const index = positions.pop();
return { positions, index };
}
/**
* Returns the latest mutation in the queue for each node.
* @param {textMutation[]} mutations The text mutations to filter.

View File

@@ -8362,7 +8362,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"assert\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:2:37\\"
\\"__puppeteer_evaluation_script__:2:21\\"
],
\\"payload\\": [
\\"true\\",
@@ -8378,7 +8378,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"count\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:3:37\\"
\\"__puppeteer_evaluation_script__:3:21\\"
],
\\"payload\\": [
\\"\\\\\\"count\\\\\\"\\"
@@ -8393,7 +8393,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"countReset\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:4:37\\"
\\"__puppeteer_evaluation_script__:4:21\\"
],
\\"payload\\": [
\\"\\\\\\"count\\\\\\"\\"
@@ -8408,7 +8408,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"debug\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:5:37\\"
\\"__puppeteer_evaluation_script__:5:21\\"
],
\\"payload\\": [
\\"\\\\\\"debug\\\\\\"\\"
@@ -8423,7 +8423,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"dir\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:6:37\\"
\\"__puppeteer_evaluation_script__:6:21\\"
],
\\"payload\\": [
\\"\\\\\\"dir\\\\\\"\\"
@@ -8438,7 +8438,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"dirxml\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:7:37\\"
\\"__puppeteer_evaluation_script__:7:21\\"
],
\\"payload\\": [
\\"\\\\\\"dirxml\\\\\\"\\"
@@ -8453,7 +8453,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"group\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:8:37\\"
\\"__puppeteer_evaluation_script__:8:21\\"
],
\\"payload\\": []
}
@@ -8466,7 +8466,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"groupCollapsed\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:9:37\\"
\\"__puppeteer_evaluation_script__:9:21\\"
],
\\"payload\\": []
}
@@ -8479,7 +8479,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"info\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:10:37\\"
\\"__puppeteer_evaluation_script__:10:21\\"
],
\\"payload\\": [
\\"\\\\\\"info\\\\\\"\\"
@@ -8494,7 +8494,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"log\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:11:37\\"
\\"__puppeteer_evaluation_script__:11:21\\"
],
\\"payload\\": [
\\"\\\\\\"log\\\\\\"\\"
@@ -8509,7 +8509,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"table\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:12:37\\"
\\"__puppeteer_evaluation_script__:12:21\\"
],
\\"payload\\": [
\\"\\\\\\"table\\\\\\"\\"
@@ -8524,7 +8524,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"time\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:13:37\\"
\\"__puppeteer_evaluation_script__:13:21\\"
],
\\"payload\\": []
}
@@ -8537,7 +8537,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"timeEnd\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:14:37\\"
\\"__puppeteer_evaluation_script__:14:21\\"
],
\\"payload\\": []
}
@@ -8550,7 +8550,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"timeLog\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:15:37\\"
\\"__puppeteer_evaluation_script__:15:21\\"
],
\\"payload\\": []
}
@@ -8563,7 +8563,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"trace\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:16:37\\"
\\"__puppeteer_evaluation_script__:16:21\\"
],
\\"payload\\": [
\\"\\\\\\"trace\\\\\\"\\"
@@ -8578,7 +8578,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"warn\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:17:37\\"
\\"__puppeteer_evaluation_script__:17:21\\"
],
\\"payload\\": [
\\"\\\\\\"warn\\\\\\"\\"
@@ -8593,7 +8593,7 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"clear\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:18:37\\"
\\"__puppeteer_evaluation_script__:18:21\\"
],
\\"payload\\": []
}
@@ -8606,10 +8606,10 @@ exports[`record integration tests should record console messages 1`] = `
\\"payload\\": {
\\"level\\": \\"log\\",
\\"trace\\": [
\\"__puppeteer_evaluation_script__:19:37\\"
\\"__puppeteer_evaluation_script__:19:21\\"
],
\\"payload\\": [
\\"\\\\\\"TypeError: a message\\\\\\\\n at __puppeteer_evaluation_script__:19:41\\\\\\\\nEnd of stack for Error object\\\\\\"\\"
\\"\\\\\\"TypeError: a message\\\\\\\\n at __puppeteer_evaluation_script__:19:25\\\\\\\\nEnd of stack for Error object\\\\\\"\\"
]
}
}

View File

@@ -56,7 +56,7 @@ html.rrweb-paused *, html.rrweb-paused ::before, html.rrweb-paused ::after { ani
file-cid-1
@charset \\"utf-8\\";
.css-added-at-500 { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
.css-added-at-400 { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
file-cid-2
@@ -64,7 +64,7 @@ file-cid-2
.css-added-at-200-overwritten-at-3000 { opacity: 1; transform: translateX(0px); }
.css-added-at-400-overwritten-at-3000 { border: 1px solid blue; }
.css-added-at-500-overwritten-at-3000 { border: 1px solid blue; }
file-cid-3

View File

@@ -1,7 +1,7 @@
import * as http from 'http';
import type * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import type * as puppeteer from 'puppeteer';
import {
startServer,
launchPuppeteer,
@@ -9,12 +9,7 @@ import {
replaceLast,
waitForRAF,
} from '../utils';
import {
recordOptions,
eventWithTime,
EventType,
IncrementalSource,
} from '../../src/types';
import type { recordOptions, eventWithTime } from '../../src/types';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });

View File

@@ -486,6 +486,30 @@ const events: eventWithTime[] = [
timestamp: now + 1500,
},
// add iframe five
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 75,
nextId: null,
node: {
type: 2,
tagName: 'iframe',
attributes: { id: 'five' },
childNodes: [],
rootId: 62,
id: 80,
},
},
],
},
timestamp: now + 2000,
},
{
type: EventType.IncrementalSnapshot,
data: {
@@ -550,30 +574,6 @@ const events: eventWithTime[] = [
},
timestamp: now + 2000,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 75,
nextId: null,
node: {
type: 2,
tagName: 'iframe',
attributes: { id: 'five' },
childNodes: [],
rootId: 62,
id: 80,
},
},
],
},
timestamp: now + 2000,
},
// remove the html element of iframe four
{
type: EventType.IncrementalSnapshot,

View File

@@ -0,0 +1,215 @@
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: 5,
type: 2,
tagName: 'body',
attributes: {},
childNodes: [],
},
],
},
],
},
initialOffset: { top: 0, left: 0 },
},
type: EventType.FullSnapshot,
timestamp: now + 100,
},
// mutation that adds select elements
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 5,
nextId: null,
node: {
type: 2,
tagName: 'select',
childNodes: [],
id: 26,
},
},
{
parentId: 26,
nextId: null,
node: {
type: 2,
tagName: 'option',
attributes: { value: 'valueC' },
childNodes: [],
id: 27,
},
},
{
parentId: 27,
nextId: null,
node: { type: 3, textContent: 'C', id: 28 },
},
{
parentId: 26,
nextId: 27,
node: {
type: 2,
tagName: 'option',
attributes: { value: 'valueB', selected: true },
childNodes: [],
id: 29,
},
},
{
parentId: 26,
nextId: 29,
node: {
type: 2,
tagName: 'option',
attributes: { value: 'valueA' },
childNodes: [],
id: 30,
},
},
{
parentId: 30,
nextId: null,
node: { type: 3, textContent: 'A', id: 31 },
},
{
parentId: 29,
nextId: null,
node: { type: 3, textContent: 'B', id: 32 },
},
],
},
timestamp: now + 1000,
},
// input event
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Input,
text: 'valueA',
isChecked: false,
id: 26,
},
timestamp: now + 1500,
},
// input event
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Input,
text: 'valueC',
isChecked: false,
id: 26,
},
timestamp: now + 2000,
},
// mutation that adds an input element
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 5,
nextId: null,
node: {
type: 2,
tagName: 'input',
attributes: {},
childNodes: [],
id: 33,
},
},
],
},
timestamp: now + 2500,
},
// an input event
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Input,
text: 'test input',
isChecked: false,
id: 33,
},
timestamp: now + 3000,
},
// remove the select element
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [{ parentId: 5, id: 26 }],
adds: [],
},
timestamp: now + 3500,
},
// remove the input element
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [{ parentId: 5, id: 33 }],
adds: [],
},
timestamp: now + 4000,
},
];
export default events;

View File

@@ -0,0 +1,128 @@
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: 1200,
height: 500,
},
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: 5,
type: 2,
tagName: 'body',
attributes: {},
childNodes: [],
},
],
},
],
},
initialOffset: { top: 0, left: 0 },
},
type: EventType.FullSnapshot,
timestamp: now + 100,
},
// mutation that adds two div elements
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 5,
nextId: null,
node: {
type: 2,
tagName: 'div',
attributes: {
id: 'container',
style: 'height: 1000px; overflow: scroll;',
},
childNodes: [],
id: 6,
},
},
{
parentId: 6,
nextId: null,
node: {
type: 2,
tagName: 'div',
attributes: {
id: 'block',
style: 'height: 10000px; background-color: yellow;',
},
childNodes: [],
id: 7,
},
},
],
},
timestamp: now + 500,
},
// scroll event on the "#container" div
{
type: EventType.IncrementalSnapshot,
data: { source: IncrementalSource.Scroll, id: 6, x: 0, y: 2500 },
timestamp: now + 1000,
},
// scroll event on document
{
type: EventType.IncrementalSnapshot,
data: { source: IncrementalSource.Scroll, id: 1, x: 0, y: 250 },
timestamp: now + 1500,
},
// remove the "#container" div
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [{ parentId: 5, id: 6 }],
adds: [],
},
timestamp: now + 2000,
},
];
export default events;

View File

@@ -0,0 +1,172 @@
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: 1200,
height: 500,
},
timestamp: now + 100,
},
{
type: EventType.FullSnapshot,
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: 5,
type: 2,
tagName: 'body',
attributes: {},
childNodes: [],
},
],
},
],
},
initialOffset: { top: 0, left: 0 },
},
timestamp: now + 200,
},
// add shadow dom elements
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 5,
nextId: null,
node: {
type: 2,
tagName: 'div',
attributes: {},
childNodes: [],
id: 6,
isShadowHost: true,
},
},
],
},
timestamp: now + 500,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 6,
nextId: null,
node: {
type: 2,
tagName: 'span',
attributes: {},
childNodes: [],
id: 7,
isShadow: true,
},
},
{
parentId: 7,
nextId: null,
node: { type: 3, textContent: 'shadow dom one', id: 8 },
},
],
},
timestamp: now + 500,
},
// add nested shadow dom elements
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 6,
nextId: null,
node: {
type: 2,
tagName: 'div',
attributes: {},
childNodes: [],
id: 9,
isShadow: true,
isShadowHost: true,
},
},
],
},
timestamp: now + 1000,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 9,
nextId: null,
node: {
type: 2,
tagName: 'span',
attributes: {},
childNodes: [],
id: 10,
isShadow: true,
},
},
{
parentId: 10,
nextId: null,
node: { type: 3, textContent: 'shadow dom two', id: 11 },
},
],
},
timestamp: now + 1000,
},
];
export default events;

View File

@@ -105,22 +105,6 @@ const events: eventWithTime[] = [
type: EventType.FullSnapshot,
timestamp: now + 100,
},
// mutation that adds style rule to existing stylesheet
{
data: {
id: 101,
adds: [
{
rule:
'.css-added-at-400-overwritten-at-3000 {border: 1px solid blue;}',
index: 1,
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 400,
},
// mutation that adds stylesheet
{
data: {
@@ -142,7 +126,7 @@ const events: eventWithTime[] = [
type: 3,
isStyle: true,
textContent:
'\n.css-added-at-500 {\n padding: 1.3125rem;\n flex: none;\n width: 100%;\n}\n',
'\n.css-added-at-400 {\n padding: 1.3125rem;\n flex: none;\n width: 100%;\n}\n',
},
nextId: null,
parentId: 255,
@@ -154,6 +138,22 @@ const events: eventWithTime[] = [
attributes: [],
},
type: EventType.IncrementalSnapshot,
timestamp: now + 400,
},
// mutation that adds style rule to existing stylesheet
{
data: {
id: 101,
adds: [
{
rule:
'.css-added-at-500-overwritten-at-3000 {border: 1px solid blue;}',
index: 1,
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 500,
},
// adds StyleSheetRule

View File

@@ -0,0 +1,178 @@
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: {},
childNodes: [
{
id: 102,
type: 3,
isStyle: true,
textContent: '\n.css-added-at-100 {color: yellow;}\n',
},
],
},
],
},
{
id: 107,
type: 2,
tagName: 'body',
attributes: {},
childNodes: [],
},
],
},
],
},
initialOffset: { top: 0, left: 0 },
},
type: EventType.FullSnapshot,
timestamp: now + 100,
},
// mutation that adds an element
{
data: {
adds: [
{
node: {
id: 108,
type: 2,
tagName: 'div',
attributes: {},
childNodes: [],
},
nextId: null,
parentId: 107,
},
],
texts: [],
source: IncrementalSource.Mutation,
removes: [],
attributes: [],
},
type: EventType.IncrementalSnapshot,
timestamp: now + 500,
},
// adds a StyleSheetRule by inserting
{
data: {
id: 101,
adds: [
{
rule: '.css-added-at-1000-overwritten-at-1500 {color:red;}',
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 1000,
},
// adds a StyleSheetRule by adding a text
{
data: {
adds: [
{
node: {
type: 3,
textContent: '.css-added-at-1500-deleted-at-2500 {color: yellow;}',
id: 109,
},
nextId: null,
parentId: 101,
},
],
texts: [],
source: IncrementalSource.Mutation,
removes: [],
attributes: [],
},
type: EventType.IncrementalSnapshot,
timestamp: now + 1500,
},
// adds a StyleSheetRule by inserting
{
data: {
id: 101,
adds: [
{
rule: '.css-added-at-2000-overwritten-at-2500 {color: blue;}',
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 2000,
},
// deletes a StyleSheetRule by removing the text
{
data: {
texts: [],
attributes: [],
removes: [{ parentId: 101, id: 109 }],
adds: [],
source: IncrementalSource.Mutation,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 2500,
},
// adds a StyleSheetRule by inserting
{
data: {
id: 101,
adds: [
{
rule: '.css-added-at-3000 {color: red;}',
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 3000,
},
];
export default events;

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as puppeteer from 'puppeteer';
import type * as http from 'http';
import type * as puppeteer from 'puppeteer';
import {
assertSnapshot,
startServer,

View File

@@ -2,7 +2,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import type * as puppeteer from 'puppeteer';
import {
recordOptions,
listenerHandler,

View File

@@ -2,7 +2,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import type * as puppeteer from 'puppeteer';
import {
recordOptions,
listenerHandler,
@@ -17,7 +17,7 @@ import {
stripBase64,
waitForRAF,
} from '../utils';
import { ICanvas } from 'rrweb-snapshot';
import type { ICanvas } from 'rrweb-snapshot';
interface ISuite {
code: string;

View File

@@ -5,7 +5,6 @@ import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { Replayer } from '../../src/replay';
import {} from '../../src/types';
import {
CanvasContext,
CanvasArg,

View File

@@ -1,165 +0,0 @@
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).toEqual(1);
expect(styleEl.sheet?.cssRules[0].cssText).toEqual(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).toEqual(3);
expect(styleEl.sheet?.cssRules[0].cssText).toEqual(cssText);
});
it('should delete rule at index 0', () => {
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).toEqual(1);
expect(styleEl.sheet?.cssRules[0].cssText).toEqual('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).toEqual(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).toEqual(2);
expect(
Array.from(styleEl.sheet?.cssRules || []).map((rule) => rule.cssText),
).toEqual(cssTexts);
});
// JSDOM/CSSOM is currently broken for this test
// remove '.skip' once https://github.com/NV/CSSOM/pull/113#issue-712485075 is merged
it.skip('should insert rule at index [0,0] and keep exsisting rules', () => {
const dom = new JSDOM(`
<style>
@media {
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, 0], type: StyleRuleType.Insert },
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
console.log(
Array.from((styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules),
);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length,
).toEqual(3);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules[0].cssText,
).toEqual(cssText);
});
it('should delete rule at index [0,1]', () => {
const dom = new JSDOM(`
<style>
@media {
a {color: blue;}
div {color: black;}
}
</style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const virtualStyleRules: VirtualStyleRules = [
{ index: [0, 1], type: StyleRuleType.Remove },
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length,
).toEqual(1);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules[0].cssText,
).toEqual('a {color: blue;}');
});
});
});

View File

@@ -1,8 +1,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { assertDomSnapshot, launchPuppeteer } from '../utils';
import { launchPuppeteer } from '../utils';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import * as puppeteer from 'puppeteer';
import type * as puppeteer from 'puppeteer';
import events from '../events/webgl';
interface ISuite {

View File

@@ -2,16 +2,21 @@
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import type * as puppeteer from 'puppeteer';
import {
assertDomSnapshot,
launchPuppeteer,
sampleEvents as events,
sampleStyleSheetRemoveEvents as stylesheetRemoveEvents,
waitForRAF,
} from './utils';
import styleSheetRuleEvents from './events/style-sheet-rule-events';
import orderingEvents from './events/ordering';
import scrollEvents from './events/scroll';
import inputEvents from './events/input';
import iframeEvents from './events/iframe';
import shadowDomEvents from './events/shadow-dom';
import StyleSheetTextMutation from './events/style-sheet-text-mutation';
interface ISuite {
code: string;
@@ -247,12 +252,206 @@ describe('replayer', function () {
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-3100');
rules.some((x) => x.selectorText === '.css-added-at-3100') &&
!rules.some(
(x) => x.selectorText === '.css-added-at-500-overwritten-at-3000',
);
`);
expect(result).toEqual(true);
});
it('should overwrite all StyleSheetRules by appending a text node to stylesheet element while fast-forwarding', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(1600);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-1000-overwritten-at-1500');
`);
expect(result).toEqual(false);
});
it('should apply fast-forwarded StyleSheetRules that came after appending text node to stylesheet element', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(2100);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-2000-overwritten-at-2500');
`);
expect(result).toEqual(true);
});
it('should overwrite all StyleSheetRules by removing text node from stylesheet element while fast-forwarding', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(2600);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-2000-overwritten-at-2500');
`);
expect(result).toEqual(false);
});
it('should apply fast-forwarded StyleSheetRules that came after removing text node from stylesheet element', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(3100);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-3000');
`);
expect(result).toEqual(true);
});
it('can fast forward scroll events', async () => {
await page.evaluate(`
events = ${JSON.stringify(scrollEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(550);
`);
// add the "#container" element at 500
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(await contentDocument!.$('#container')).not.toBeNull();
expect(await contentDocument!.$('#block')).not.toBeNull();
expect(
await contentDocument!.$eval(
'#container',
(element: Element) => element.scrollTop,
),
).toEqual(0);
// restart the replayer
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1050);');
// scroll the "#container" div' at 1000
expect(
await contentDocument!.$eval(
'#container',
(element: Element) => element.scrollTop,
),
).toEqual(2500);
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1550);');
// scroll the document at 1500
expect(
await page.$eval(
'iframe',
(element: Element) =>
(element as HTMLIFrameElement)!.contentWindow!.scrollY,
),
).toEqual(250);
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2050);');
// remove the "#container" element at 2000
expect(await contentDocument!.$('#container')).toBeNull();
expect(await contentDocument!.$('#block')).toBeNull();
expect(
await page.$eval(
'iframe',
(element: Element) =>
(element as HTMLIFrameElement)!.contentWindow!.scrollY,
),
).toEqual(0);
});
it('can fast forward input events', async () => {
await page.evaluate(`
events = ${JSON.stringify(inputEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(1050);
`);
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(await contentDocument!.$('select')).not.toBeNull();
expect(
await contentDocument!.$eval(
'select',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('valueB'); // the default value
// restart the replayer
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1550);');
// the value get changed to 'valueA' at 1500
expect(
await contentDocument!.$eval(
'select',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('valueA');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2050);');
// the value get changed to 'valueC' at 2000
expect(
await contentDocument!.$eval(
'select',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('valueC');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2550);');
// add a new input element at 2500
expect(
await contentDocument!.$eval(
'input',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(3050);');
// set the value 'test input' for the input element at 3000
expect(
await contentDocument!.$eval(
'input',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('test input');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(3550);');
// remove the select element at 3500
expect(await contentDocument!.$('select')).toBeNull();
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(4050);');
// remove the input element at 4000
expect(await contentDocument!.$('input')).toBeNull();
});
it('can fast-forward mutation events containing nested iframe elements', async () => {
await page.evaluate(`
events = ${JSON.stringify(iframeEvents)};
@@ -264,13 +463,12 @@ describe('replayer', function () {
const contentDocument = await iframe!.contentFrame()!;
expect(await contentDocument!.$('iframe')).toBeNull();
const delay = 50;
// restart the replayer
await page.evaluate('replayer.play(0);');
await page.waitForTimeout(delay);
await waitForRAF(page);
await page.evaluate('replayer.pause(550);'); // add 'iframe one' at 500
expect(await contentDocument!.$('iframe')).not.toBeNull();
const iframeOneDocument = await (await contentDocument!.$(
let iframeOneDocument = await (await contentDocument!.$(
'iframe',
))!.contentFrame();
expect(iframeOneDocument).not.toBeNull();
@@ -286,14 +484,21 @@ describe('replayer', function () {
// add 'iframe two' and 'iframe three' at 1000
await page.evaluate('replayer.play(0);');
await page.waitForTimeout(delay);
await waitForRAF(page);
await page.evaluate('replayer.pause(1050);');
// check the inserted style of iframe 'one' again
iframeOneDocument = await (await contentDocument!.$(
'iframe',
))!.contentFrame();
expect((await iframeOneDocument!.$$('style')).length).toBe(1);
expect((await contentDocument!.$$('iframe')).length).toEqual(2);
let iframeTwoDocument = await (
await contentDocument!.$$('iframe')
)[1]!.contentFrame();
expect(iframeTwoDocument).not.toBeNull();
expect((await iframeTwoDocument!.$$('iframe')).length).toEqual(2);
expect((await iframeTwoDocument!.$$('style')).length).toBe(1);
let iframeThreeDocument = await (
await iframeTwoDocument!.$$('iframe')
)[0]!.contentFrame();
@@ -301,25 +506,27 @@ describe('replayer', function () {
await iframeTwoDocument!.$$('iframe')
)[1]!.contentFrame();
expect(iframeThreeDocument).not.toBeNull();
expect((await iframeThreeDocument!.$$('style')).length).toBe(1);
expect(iframeFourDocument).not.toBeNull();
// add 'iframe four' at 1500
await page.evaluate('replayer.play(0);');
await page.waitForTimeout(delay);
await waitForRAF(page);
await page.evaluate('replayer.pause(1550);');
iframeTwoDocument = await (
await contentDocument!.$$('iframe')
)[1]!.contentFrame();
expect((await iframeTwoDocument!.$$('style')).length).toBe(1);
iframeFourDocument = await (
await iframeTwoDocument!.$$('iframe')
)[1]!.contentFrame();
expect(await iframeFourDocument!.$('iframe')).toBeNull();
expect(await iframeFourDocument!.$('style')).not.toBeNull();
expect((await iframeFourDocument!.$$('style')).length).toBe(1);
expect(await iframeFourDocument!.title()).toEqual('iframe 4');
// add 'iframe five' at 2000
await page.evaluate('replayer.play(0);');
await page.waitForTimeout(delay);
await waitForRAF(page);
await page.evaluate('replayer.pause(2050);');
iframeTwoDocument = await (
await contentDocument!.$$('iframe')
@@ -327,6 +534,7 @@ describe('replayer', function () {
iframeFourDocument = await (
await iframeTwoDocument!.$$('iframe')
)[1]!.contentFrame();
expect((await iframeFourDocument!.$$('style')).length).toBe(1);
expect(await iframeFourDocument!.$('iframe')).not.toBeNull();
const iframeFiveDocument = await (await iframeFourDocument!.$(
'iframe',
@@ -343,7 +551,7 @@ describe('replayer', function () {
// remove the html element of 'iframe four' at 2500
await page.evaluate('replayer.play(0);');
await page.waitForTimeout(delay);
await waitForRAF(page);
await page.evaluate('replayer.pause(2550);');
iframeTwoDocument = await (
await contentDocument!.$$('iframe')
@@ -362,6 +570,51 @@ describe('replayer', function () {
).not.toBeNull();
});
it('can fast-forward mutation events containing nested shadow doms', async () => {
await page.evaluate(`
events = ${JSON.stringify(shadowDomEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(550);
`);
// add shadow dom 'one' at 500
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(
await contentDocument!.$eval('div', (element) => element.shadowRoot),
).not.toBeNull();
expect(
await contentDocument!.evaluate(
() =>
document
.querySelector('body > div')!
.shadowRoot!.querySelector('span')!.textContent,
),
).toEqual('shadow dom one');
// add shadow dom 'two' at 1000
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1050);');
expect(
await contentDocument!.evaluate(
() =>
document
.querySelector('body > div')!
.shadowRoot!.querySelector('div')!.shadowRoot,
),
).not.toBeNull();
expect(
await contentDocument!.evaluate(
() =>
document
.querySelector('body > div')!
.shadowRoot!.querySelector('div')!
.shadowRoot!.querySelector('span')!.textContent,
),
).toEqual('shadow dom two');
});
it('can stream events in live mode', async () => {
const status = await page.evaluate(`
const { Replayer } = rrweb;

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Node",
"target": "ES5",
"target": "ES6",
"noImplicitAny": true,
"strictNullChecks": true,
"removeComments": true,
@@ -10,7 +10,8 @@
"rootDir": "src",
"outDir": "build",
"lib": ["es6", "dom"],
"downlevelIteration": true
"downlevelIteration": true,
"importsNotUsedAsValues": "error"
},
"exclude": ["test"],
"include": [

View File

@@ -1,4 +1,4 @@
import { eventWithTime } from '../types';
import type { eventWithTime } from '../types';
export declare type PackFn = (event: eventWithTime) => string;
export declare type UnpackFn = (raw: string) => eventWithTime;
export declare type eventWithTimeAndPacker = eventWithTime & {

View File

@@ -1,4 +1,4 @@
import { RecordPlugin } from '../../../types';
import type { RecordPlugin } from '../../../types';
export declare type StringifyOptions = {
stringLengthLimit?: number;
numOfKeysLimit: number;

View File

@@ -1,2 +1,2 @@
import { StringifyOptions } from './index';
import type { StringifyOptions } from './index';
export declare function stringify(obj: any, stringifyOptions?: StringifyOptions): string;

View File

@@ -1,4 +1,4 @@
import { RecordPlugin } from '../../../types';
import type { RecordPlugin } from '../../../types';
export declare type SequentialIdOptions = {
key: string;
};

View File

@@ -1,5 +1,5 @@
import type { SequentialIdOptions } from '../record';
import { ReplayPlugin } from '../../../types';
import type { ReplayPlugin } from '../../../types';
declare type Options = SequentialIdOptions & {
warnOnMissingId: boolean;
};

View File

@@ -1,5 +1,5 @@
import { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
import { mutationCallBack } from '../types';
import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
import type { mutationCallBack } from '../types';
export declare class IframeManager {
private iframes;
private mutationCb;

View File

@@ -1,4 +1,4 @@
import { mutationRecord, MutationBufferParam } from '../types';
import type { mutationRecord, MutationBufferParam } from '../types';
export default class MutationBuffer {
private frozen;
private locked;

View File

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

View File

@@ -1,5 +1,5 @@
import { Mirror } from 'rrweb-snapshot';
import { blockClass, canvasMutationCallback, IWindow } from '../../../types';
import type { Mirror } from 'rrweb-snapshot';
import type { blockClass, canvasMutationCallback, IWindow } from '../../../types';
export declare type RafStamps = {
latestId: number;
invokeId: number | null;

View File

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

View File

@@ -1,4 +1,4 @@
import { IWindow, CanvasArg } from '../../../types';
import type { IWindow, CanvasArg } from '../../../types';
export declare function variableListFor(ctx: RenderingContext, ctor: string): any[];
export declare const saveWebGLVar: (value: any, win: IWindow, ctx: RenderingContext) => number | void;
export declare function serializeArg(value: any, win: IWindow, ctx: RenderingContext): CanvasArg;

View File

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

View File

@@ -1,5 +1,5 @@
import { mutationCallBack, scrollCallback, MutationBufferParam, SamplingStrategy } from '../types';
import { Mirror } from 'rrweb-snapshot';
import type { mutationCallBack, scrollCallback, MutationBufferParam, SamplingStrategy } from '../types';
import type { Mirror } from 'rrweb-snapshot';
declare type BypassOptions = Omit<MutationBufferParam, 'doc' | 'mutationCb' | 'mirror' | 'shadowDomManager'> & {
sampling: SamplingStrategy;
};

View File

@@ -1,4 +1,4 @@
import { ImageBitmapDataURLWorkerParams, ImageBitmapDataURLWorkerResponse } from '../../types';
import type { ImageBitmapDataURLWorkerParams, ImageBitmapDataURLWorkerResponse } from '../../types';
export interface ImageBitmapDataURLRequestWorker {
postMessage: (message: ImageBitmapDataURLWorkerParams, transfer?: [ImageBitmap]) => void;
onmessage: (message: MessageEvent<ImageBitmapDataURLWorkerResponse>) => void;

View File

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

View File

@@ -1,5 +1,5 @@
import type { Replayer } from '../';
import { CanvasArg, SerializedCanvasArg } from '../../types';
import type { CanvasArg, SerializedCanvasArg } from '../../types';
export declare function variableListFor(ctx: CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[];
export declare function isSerializedArg(arg: unknown): arg is SerializedCanvasArg;
export declare function deserializeArg(imageMap: Replayer['imageMap'], ctx: CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | null, preload?: {

View File

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

View File

@@ -1,4 +1,5 @@
import { Mirror } from 'rrweb-snapshot';
import { RRDocument } from 'rrdom/es/virtual-dom';
import { Timer } from './timer';
import { createPlayerService, createSpeedService } from './machine';
import { eventWithTime, playerConfig, playerMetaData, Handler } from '../types';
@@ -10,16 +11,14 @@ export declare class Replayer {
speedService: ReturnType<typeof createSpeedService>;
get timer(): Timer;
config: playerConfig;
usingVirtualDom: boolean;
virtualDom: RRDocument;
private mouse;
private mouseTail;
private tailPositions;
private emitter;
private nextUserInteractionEvent;
private legacy_missingNodeRetryMap;
private treeIndex;
private fragmentParentMap;
private elementStateMap;
private virtualStyleRulesMap;
private cache;
private imageMap;
private canvasEventMap;
@@ -62,17 +61,12 @@ export declare class Replayer {
private applyMutation;
private applyScroll;
private applyInput;
private applyText;
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;

View File

@@ -1,42 +0,0 @@
export declare enum StyleRuleType {
Insert = 0,
Remove = 1,
Snapshot = 2,
SetProperty = 3,
RemoveProperty = 4
}
declare type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number | number[];
};
declare type RemoveRule = {
type: StyleRuleType.Remove;
index: number | number[];
};
declare type SnapshotRule = {
type: StyleRuleType.Snapshot;
cssTexts: string[];
};
declare type SetPropertyRule = {
type: StyleRuleType.SetProperty;
index: number[];
property: string;
value: string | null;
priority: string | undefined;
};
declare type RemovePropertyRule = {
type: StyleRuleType.RemoveProperty;
index: number[];
property: string;
};
export declare type VirtualStyleRules = Array<InsertRule | RemoveRule | SnapshotRule | SetPropertyRule | RemovePropertyRule>;
export declare type VirtualStyleRulesMap = Map<Node, VirtualStyleRules>;
export declare function getNestedRule(rules: CSSRuleList, position: number[]): CSSGroupingRule;
export declare function getPositionsAndIndex(nestedIndex: number[]): {
positions: number[];
index: number | undefined;
};
export declare function applyVirtualStyleRulesToNode(storedRules: VirtualStyleRules, styleNode: HTMLStyleElement): void;
export declare function storeCSSRules(parentElement: HTMLStyleElement, virtualStyleRulesMap: VirtualStyleRulesMap): void;
export {};

View File

@@ -1,9 +1,10 @@
import { serializedNodeWithId, Mirror, INode, MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot';
import { PackFn, UnpackFn } from './packer/base';
import { IframeManager } from './record/iframe-manager';
import { ShadowDomManager } from './record/shadow-dom-manager';
import type { serializedNodeWithId, Mirror, INode, MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot';
import type { PackFn, UnpackFn } from './packer/base';
import type { IframeManager } from './record/iframe-manager';
import type { ShadowDomManager } from './record/shadow-dom-manager';
import type { Replayer } from './replay';
import { CanvasManager } from './record/observers/canvas/canvas-manager';
import type { RRNode } from 'rrdom/es/virtual-dom';
import type { CanvasManager } from './record/observers/canvas/canvas-manager';
export declare enum EventType {
DomContentLoaded = 0,
Load = 1,
@@ -115,6 +116,10 @@ export declare type eventWithTime = event & {
timestamp: number;
delay?: number;
};
export declare type canvasEventWithTime = eventWithTime & {
type: EventType.IncrementalSnapshot;
data: canvasMutationData;
};
export declare type blockClass = string | RegExp;
export declare type maskTextClass = string | RegExp;
export declare type SamplingStrategy = Partial<{
@@ -464,6 +469,7 @@ export declare type playerConfig = {
strokeStyle?: string;
};
unpackFn?: UnpackFn;
useVirtualDom: boolean;
plugins?: ReplayPlugin[];
};
export declare type playerMetaData = {
@@ -472,7 +478,7 @@ export declare type playerMetaData = {
totalTime: number;
};
export declare type missingNode = {
node: Node;
node: Node | RRNode;
mutation: addedNodeMutation;
};
export declare type missingNodeMap = {
@@ -507,9 +513,6 @@ export declare enum ReplayerEvents {
StateChange = "state-change",
PlayBack = "play-back"
}
export declare type ElementState = {
scroll?: [number, number];
};
export declare type KeepIframeSrcFn = (src: string) => boolean;
declare global {
interface Window {

View File

@@ -1,5 +1,6 @@
import { throttleOptions, listenerHandler, hookResetter, blockClass, addedNodeMutation, removedNodeMutation, textMutation, attributeMutation, mutationData, scrollData, inputData, DocumentDimension, IWindow, DeprecatedMirror } from './types';
import { Mirror } from 'rrweb-snapshot';
import type { throttleOptions, listenerHandler, hookResetter, blockClass, addedNodeMutation, DocumentDimension, IWindow, DeprecatedMirror, textMutation } from './types';
import type { IMirror, Mirror } from 'rrweb-snapshot';
import type { RRNode, RRIFrameElement } from 'rrdom/es/virtual-dom';
export declare function on(type: string, fn: EventListenerOrEventListenerObject, target?: Document | IWindow): listenerHandler;
export declare let _mirror: DeprecatedMirror;
export declare function throttle<T>(func: (arg: T) => void, wait: number, options?: throttleOptions): (arg: T) => void;
@@ -15,38 +16,6 @@ export declare function isIgnored(n: Node, mirror: Mirror): boolean;
export declare function isAncestorRemoved(target: Node, mirror: Mirror): boolean;
export declare function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent;
export declare function polyfill(win?: Window & typeof globalThis): void;
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[];
@@ -56,12 +25,17 @@ export declare function queueToResolveTrees(queue: addedNodeMutation[]): Resolve
export declare function iterateResolveTree(tree: ResolveTree, cb: (mutation: addedNodeMutation) => unknown): void;
export declare type AppendedIframe = {
mutationInQueue: addedNodeMutation;
builtNode: HTMLIFrameElement;
builtNode: HTMLIFrameElement | RRIFrameElement;
};
export declare function isSerializedIframe(n: Node, mirror: Mirror): n is HTMLIFrameElement;
export declare function isSerializedIframe<TNode extends Node | RRNode>(n: TNode, mirror: IMirror<TNode>): boolean;
export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension;
export declare function hasShadowRoot<T extends Node>(n: T): n is T & {
export declare function hasShadowRoot<T extends Node | RRNode>(n: T): n is T & {
shadowRoot: ShadowRoot;
};
export declare function getUniqueTextMutations(mutations: textMutation[]): textMutation[];
export declare function getNestedRule(rules: CSSRuleList, position: number[]): CSSGroupingRule;
export declare function getPositionsAndIndex(nestedIndex: number[]): {
positions: number[];
index: number | undefined;
};
export declare function uniqueTextMutations(mutations: textMutation[]): textMutation[];
export {};

1003
yarn.lock

File diff suppressed because it is too large Load Diff