move browser-only rrdom features to the new rrdom package (#913)

This commit is contained in:
yz-yu
2026-04-01 12:00:00 +08:00
committed by GitHub
parent eec8d6f717
commit 7662d4e0fb
31 changed files with 707 additions and 554 deletions

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["orta.vscode-jest"]
}

View File

@@ -1,15 +0,0 @@
{
"configurations": [
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"cwd": "${workspaceFolder}",
"runtimeExecutable": "yarn",
"args": ["test", "--runInBand", "--watchAll=false"]
}
]
}

View File

@@ -1,3 +0,0 @@
{
"jest.jestCommandLine": "yarn test"
}

View File

@@ -1,19 +1,7 @@
{
"name": "rrdom",
"version": "0.1.2",
"scripts": {
"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",
"lint": "yarn eslint src/**/*.ts"
},
"keywords": [
"rrweb",
"rrdom"
],
"homepage": "https://github.com/rrweb-io/rrweb/tree/main/packages/rrdom#readme",
"license": "MIT",
"main": "lib/rrdom.js",
"module": "es/rrdom.js",
@@ -25,17 +13,28 @@
"es",
"typings"
],
"repository": {
"type": "git",
"url": "git+https://github.com/rrweb-io/rrweb.git"
},
"scripts": {
"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",
"lint": "yarn eslint src/**/*.ts"
},
"bugs": {
"url": "https://github.com/rrweb-io/rrweb/issues"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^20.0.0",
"@rollup/plugin-node-resolve": "^13.0.4",
"@types/cssom": "^0.4.1",
"@types/cssstyle": "^2.2.1",
"@types/jest": "^27.4.1",
"@types/nwsapi": "^2.2.2",
"@types/puppeteer": "^5.4.4",
"@typescript-eslint/eslint-plugin": "^5.23.0",
"@typescript-eslint/parser": "^5.23.0",
"compare-versions": "^4.1.3",
"@types/puppeteer": "^5.4.4",
"eslint": "^8.15.0",
"jest": "^27.5.1",
"puppeteer": "^9.1.1",
@@ -43,13 +42,10 @@
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.31.2",
"rollup-plugin-web-worker-loader": "^1.6.1",
"rrweb-snapshot": "^1.1.14",
"ts-jest": "^27.1.3",
"typescript": "^4.6.2"
},
"dependencies": {
"cssom": "^0.5.0",
"cssstyle": "^2.3.0",
"nwsapi": "^2.2.0"
"rrweb-snapshot": "^1.1.14"
}
}

View File

@@ -27,16 +27,6 @@ const baseConfigs = [
name: pkg.name,
path: pkg.name,
},
{
input: './src/document-nodejs.ts',
name: 'RRDocument',
path: 'document-nodejs',
},
{
input: './src/virtual-dom.ts',
name: 'RRDocument',
path: 'virtual-dom',
},
];
let configs = [];

View File

@@ -22,7 +22,7 @@ import type {
RRStyleElement,
RRDocument,
Mirror,
} from './virtual-dom';
} from '.';
const NAMESPACES: Record<string, string> = {
svg: 'http://www.w3.org/2000/svg',
@@ -113,7 +113,7 @@ export function diff(
break;
}
case RRNodeType.Element: {
const oldElement = (oldTree ) as HTMLElement;
const oldElement = oldTree as HTMLElement;
const newRRElement = newTree as IRRElement;
diffProps(oldElement, newRRElement, rrnodeMirror);
scrollDataToApply = (newRRElement as RRElement).scrollData;
@@ -121,7 +121,7 @@ export function diff(
switch (newRRElement.tagName) {
case 'AUDIO':
case 'VIDEO': {
const oldMediaElement = (oldTree ) as HTMLMediaElement;
const oldMediaElement = oldTree as HTMLMediaElement;
const newMediaRRElement = newRRElement as RRMediaElement;
if (newMediaRRElement.paused !== undefined)
newMediaRRElement.paused
@@ -141,7 +141,7 @@ export function diff(
replayer.applyCanvas(
canvasMutation.event,
canvasMutation.mutation,
(oldTree ) as HTMLCanvasElement,
oldTree as HTMLCanvasElement,
),
);
break;
@@ -191,8 +191,7 @@ export function diff(
// IFrame element doesn't have child nodes.
if (newTree.nodeName === 'IFRAME') {
const oldContentDocument = ((oldTree ) as HTMLIFrameElement)
.contentDocument;
const oldContentDocument = (oldTree as HTMLIFrameElement).contentDocument;
const newIFrameElement = newTree as RRIFrameElement;
// If the iframe is cross-origin, the contentDocument will be null.
if (oldContentDocument) {
@@ -319,11 +318,9 @@ function diffChildren(
if (
replayer.mirror.getMeta(parentNode)?.type === RRNodeType.Document &&
replayer.mirror.getMeta(newNode)?.type === RRNodeType.Element &&
((parentNode ) as Document).documentElement
(parentNode as Document).documentElement
) {
parentNode.removeChild(
((parentNode ) as Document).documentElement,
);
parentNode.removeChild((parentNode as Document).documentElement);
oldChildren[oldStartIndex] = undefined;
oldStartNode = undefined;
}
@@ -417,8 +414,7 @@ export function getNestedRule(
return rule;
} else {
return getNestedRule(
((rule ).cssRules[position[1]] as CSSGroupingRule)
.cssRules,
(rule.cssRules[position[1]] as CSSGroupingRule).cssRules,
position.slice(2),
);
}

View File

@@ -1,376 +0,0 @@
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 class RRNode extends BaseRRNode {}
export class RRWindow {
scrollLeft = 0;
scrollTop = 0;
scrollTo(options?: ScrollToOptions) {
if (!options) return;
if (typeof options.left === 'number') this.scrollLeft = options.left;
if (typeof options.top === 'number') this.scrollTop = options.top;
}
}
export class RRDocument
extends BaseRRDocumentImpl(RRNode)
implements IRRDocument {
readonly nodeName: '#document' = '#document';
private _nwsapi: NWSAPI;
get nwsapi() {
if (!this._nwsapi) {
this._nwsapi = nwsapi({
document: (this as unknown) as Document,
DOMException: (null as unknown) as new (
message?: string,
name?: string,
) => DOMException,
});
this._nwsapi.configure({
LOGERRORS: false,
IDS_DUPES: true,
MIXEDCASE: true,
});
}
return this._nwsapi;
}
get documentElement(): RRElement | null {
return super.documentElement as RRElement | null;
}
get body(): RRElement | null {
return super.body as RRElement | null;
}
get head() {
return super.head as RRElement | null;
}
get implementation(): RRDocument {
return this;
}
get firstElementChild(): RRElement | null {
return this.documentElement;
}
appendChild(childNode: RRNode) {
return super.appendChild(childNode);
}
insertBefore(newChild: RRNode, refChild: RRNode | null) {
return super.insertBefore(newChild, refChild);
}
querySelectorAll(selectors: string): RRNode[] {
return (this.nwsapi.select(selectors) as unknown) as RRNode[];
}
getElementsByTagName(tagName: string): RRElement[] {
if (this.documentElement)
return this.documentElement.getElementsByTagName(tagName);
return [];
}
getElementsByClassName(className: string): RRElement[] {
if (this.documentElement)
return this.documentElement.getElementsByClassName(className);
return [];
}
getElementById(elementId: string): RRElement | null {
if (this.documentElement)
return this.documentElement.getElementById(elementId);
return null;
}
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);
break;
case 'IMG':
element = new RRImageElement(upperTagName);
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;
}
createElementNS(_namespaceURI: string, qualifiedName: string) {
return this.createElement(qualifiedName as keyof HTMLElementTagNameMap);
}
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;
}
}
export class RRDocumentType extends BaseRRDocumentTypeImpl(RRNode) {}
export class RRElement extends BaseRRElementImpl(RRNode) {
private _style: CSSStyleDeclarationType;
constructor(tagName: string) {
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 style() {
return (this._style as unknown) as CSSStyleDeclaration;
}
attachShadow(_init: ShadowRootInit): RRElement {
return super.attachShadow(_init) as RRElement;
}
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) {
const upperName = name && name.toLowerCase();
if (upperName in this.attributes) return this.attributes[upperName];
return null;
}
setAttribute(name: string, attribute: string) {
this.attributes[name.toLowerCase()] = attribute;
}
removeAttribute(name: string) {
delete this.attributes[name.toLowerCase()];
}
get firstElementChild(): RRElement | null {
for (const child of this.childNodes)
if (child.RRNodeType === RRNodeType.Element) return child as RRElement;
return null;
}
get nextElementSibling(): RRElement | null {
const parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.childNodes;
const 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) {
((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 result;
}
getElementById(elementId: string): RRElement | null {
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;
}
}
return null;
}
getElementsByClassName(className: string): RRElement[] {
let elements: RRElement[] = [];
const queryClassList = new ClassList(className);
// Make sure this element has all queried class names.
if (
this instanceof RRElement &&
queryClassList.classes.filter((queriedClassName) =>
this.classList.classes.some((name) => name === queriedClassName),
).length == queryClassList.classes.length
)
elements.push(this);
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByClassName(className));
}
return elements;
}
getElementsByTagName(tagName: string): RRElement[] {
let elements: RRElement[] = [];
const normalizedTagName = tagName.toUpperCase();
if (this instanceof RRElement && this.tagName === normalizedTagName)
elements.push(this);
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByTagName(tagName));
}
return elements;
}
}
export class RRImageElement extends RRElement {
src: string;
width: number;
height: number;
onload: ((this: GlobalEventHandlers, ev: Event) => any) | null;
}
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
export class RRCanvasElement extends RRElement {
/**
* This is just a dummy implementation to prevent rrweb replayer from drawing mouse tail. If further analysis of canvas is needed, we may implement it with node-canvas.
*/
getContext(): CanvasRenderingContext2D | null {
return null;
}
}
export class RRStyleElement extends RRElement {
private _sheet: CSSStyleSheet | null = null;
get sheet() {
if (!this._sheet) {
let result = '';
for (const child of this.childNodes)
if (child.RRNodeType === RRNodeType.Text)
result += (child as RRText).textContent;
this._sheet = cssom.parse(result);
}
return this._sheet;
}
}
export class RRIFrameElement extends RRElement {
width = '';
height = '';
src = '';
contentDocument: RRDocument = new RRDocument();
contentWindow: RRWindow = new RRWindow();
constructor(tagName: string) {
super(tagName);
const htmlElement = this.contentDocument.createElement('HTML');
this.contentDocument.appendChild(htmlElement);
htmlElement.appendChild(this.contentDocument.createElement('HEAD'));
htmlElement.appendChild(this.contentDocument.createElement('BODY'));
}
}
export class RRText extends BaseRRTextImpl(RRNode) {
readonly nodeName: '#text' = '#text';
}
export class RRComment extends BaseRRCommentImpl(RRNode) {
readonly nodeName: '#comment' = '#comment';
}
export class RRCDATASection extends BaseRRCDATASectionImpl(RRNode) {
readonly nodeName: '#cdata-section' = '#cdata-section';
}
interface RRElementTagNameMap {
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;

View File

@@ -1,13 +1,450 @@
import {
polyfillPerformance,
polyfillRAF,
polyfillEvent,
polyfillNode,
polyfillDocument,
} from './polyfill';
polyfillPerformance();
polyfillRAF();
polyfillEvent();
polyfillNode();
polyfillDocument();
export * from './document-nodejs';
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 type { VirtualStyleRules } from './diff';
import {
BaseRRNode as RRNode,
BaseRRCDATASectionImpl,
BaseRRCommentImpl,
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRTextImpl,
IRRDocument,
IRRElement,
IRRNode,
NodeType,
IRRDocumentType,
IRRText,
IRRComment,
} from './document';
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 DocumentType;
rrNode = rrdom.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case NodeType.ELEMENT_NODE:
const elementNode = 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 Text).textContent || '');
break;
case NodeType.CDATA_SECTION_NODE:
rrNode = rrdom.createCDATASection((node as CDATASection).data);
break;
case NodeType.COMMENT_NODE:
rrNode = rrdom.createComment((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 HTMLElement).shadowRoot
)
walk((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,
ReplayerHandler,
VirtualStyleRules,
} from './diff';
export * from './document';

View File

@@ -1,89 +0,0 @@
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;
((global as Window & typeof globalThis)
.performance as unknown) = require('perf_hooks').performance;
}
/**
* Polyfill requestAnimationFrame and cancelAnimationFrame for nodejs.
*/
export function polyfillRAF() {
if (typeof window !== 'undefined' || 'requestAnimationFrame' in global)
return;
const FPS = 60,
INTERVAL = 1_000 / FPS;
let timeoutHandle: NodeJS.Timeout | null = null,
rafCount = 0,
requests = Object.create(null);
function onFrameTimer() {
const currentRequests = requests;
requests = Object.create(null);
timeoutHandle = null;
Object.keys(currentRequests).forEach(function (id) {
const request = currentRequests[id];
if (request) request(Date.now());
});
}
function requestAnimationFrame(callback: (timestamp: number) => void) {
const cbHandle = ++rafCount;
requests[cbHandle] = callback;
if (timeoutHandle === null)
timeoutHandle = setTimeout(onFrameTimer, INTERVAL);
return cbHandle;
}
function cancelAnimationFrame(handleId: number) {
delete requests[handleId];
if (Object.keys(requests).length === 0 && timeoutHandle !== null) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
(global as Window &
typeof globalThis).requestAnimationFrame = requestAnimationFrame;
(global as Window &
typeof globalThis).cancelAnimationFrame = cancelAnimationFrame;
}
/**
* Try to polyfill Event type.
* The implementation of Event so far is empty because rrweb doesn't strongly depend on it in nodejs mode.
* Note: The Event class is available through the global object from nodejs v15.0.0.
*/
export function polyfillEvent() {
if (typeof Event !== 'undefined') return;
(global.Event as unknown) = function () {};
}
/**
* Polyfill Node type with RRNode for nodejs.
*/
export function polyfillNode() {
if (typeof Node !== 'undefined') return;
(global.Node as unknown) = RRNode;
}
/**
* Polyfill document object with RRDocument for nodejs.
*/
export function polyfillDocument() {
if (typeof document !== 'undefined') return;
const rrdom = new RRDocument();
(() => {
rrdom.appendChild(rrdom.createElement('html'));
rrdom.documentElement!.appendChild(rrdom.createElement('head'));
rrdom.documentElement!.appendChild(rrdom.createElement('body'));
})();
global.document = (rrdom as unknown) as Document;
}

View File

@@ -1,450 +0,0 @@
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 DocumentType;
rrNode = rrdom.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case NodeType.ELEMENT_NODE:
const elementNode = (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 Text).textContent || '');
break;
case NodeType.CDATA_SECTION_NODE:
rrNode = rrdom.createCDATASection(((node ) as CDATASection).data);
break;
case NodeType.COMMENT_NODE:
rrNode = rrdom.createComment(
((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 HTMLElement).shadowRoot
)
walk(((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,7 +1,7 @@
/**
* @jest-environment jsdom
*/
import { getDefaultSN, RRDocument, RRMediaElement } from '../src/virtual-dom';
import { getDefaultSN, RRDocument, RRMediaElement } from '../src';
import {
applyVirtualStyleRulesToNode,
createOrGetNode,

View File

@@ -1,547 +0,0 @@
/**
* @jest-environment jsdom
*/
import * as fs from 'fs';
import * as path from 'path';
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('RRDocument API', () => {
let rrdom: RRDocument;
beforeAll(() => {
// initialize rrdom
document.write(getHtml('main.html'));
rrdom = new RRDocument();
buildFromDom(document, undefined, rrdom);
});
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('can get head element', () => {
expect(rrdom.head).toBeDefined();
expect(rrdom.head!.tagName).toBe('HEAD');
expect(rrdom.head!.parentElement).toBe(rrdom.documentElement);
});
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.insertBefore(rrdom.createElement('div'), null),
).toThrowErrorMatchingInlineSnapshot(
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one RRElement on RRDocument allowed."`,
);
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');
const div1 = rrdom.getElementById('block1');
expect(div1).toBeDefined();
expect(div1!.firstElementChild).toBeDefined();
expect(div1!.firstElementChild!.id).toEqual('block2');
const div2 = div1!.firstElementChild;
expect(div2!.firstElementChild!.id).toEqual('block3');
});
it('getElementsByTagName', () => {
for (let tagname of [
'HTML',
'BODY',
'HEAD',
'STYLE',
'META',
'TITLE',
'SCRIPT',
'LINK',
'DIV',
'H1',
'P',
'BUTTON',
'IMG',
'CANVAS',
'FORM',
'INPUT',
]) {
const expectedResult = document.getElementsByTagName(tagname).length;
expect(rrdom.getElementsByTagName(tagname).length).toEqual(
expectedResult,
);
expect(
rrdom.getElementsByTagName(tagname.toLowerCase()).length,
).toEqual(expectedResult);
for (let node of rrdom.getElementsByTagName(tagname)) {
expect(node.tagName).toEqual(tagname);
}
}
const node = new RRDocument();
expect(node.getElementsByTagName('h2').length).toEqual(0);
});
it('getElementsByClassName', () => {
for (let className of [
'blocks',
'blocks1',
':hover',
'blocks1 blocks',
'blocks blocks1',
':hover blocks1',
':hover blocks1 blocks',
':hover blocks1 block',
]) {
const msg = `queried class name: '${className}'`;
expect({
message: msg,
result: rrdom.getElementsByClassName(className).length,
}).toEqual({
message: msg,
result: document.getElementsByClassName(className).length,
});
}
const node = new RRDocument();
expect(node.getElementsByClassName('block').length).toEqual(0);
});
it('getElementById', () => {
for (let elementId of ['block1', 'block2', 'block3']) {
expect(rrdom.getElementById(elementId)).not.toBeNull();
expect(rrdom.getElementById(elementId)!.id).toEqual(elementId);
}
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', () => {
expect(rrdom.querySelectorAll('H1')).toHaveLength(2);
expect(rrdom.querySelectorAll('H1')[0]).toBeInstanceOf(RRElement);
expect((rrdom.querySelectorAll('H1')[0] as RRElement).tagName).toEqual(
'H1',
);
expect(rrdom.querySelectorAll('H1')[1]).toBeInstanceOf(RRElement);
expect((rrdom.querySelectorAll('H1')[1] as RRElement).tagName).toEqual(
'H1',
);
});
it('querySelectorAll querying class name', () => {
for (let className of [
'.blocks',
'.blocks1',
'.\\:hover',
'.blocks1.blocks',
'.blocks.blocks1',
'.\\:hover.blocks1',
'.\\:hover.blocks1.blocks',
'.\\:hover.blocks1.block',
]) {
const msg = `queried class name: '${className}'`;
expect({
message: msg,
result: rrdom.querySelectorAll(className).length,
}).toEqual({
message: msg,
result: document.querySelectorAll(className).length,
});
}
for (let element of rrdom.querySelectorAll('.\\:hover')) {
expect(element).toBeInstanceOf(RRElement);
expect((element as RRElement).classList.classes).toContain(':hover');
}
});
it('querySelectorAll querying id', () => {
for (let query of ['#block1', '#block2', '#block3']) {
expect(rrdom.querySelectorAll(query).length).toEqual(1);
const targetElement = rrdom.querySelectorAll(query)[0] as RRElement;
expect(targetElement.id).toEqual(query.substring(1, query.length));
}
for (let query of ['#block', '#blocks', '#block1#block2'])
expect(rrdom.querySelectorAll(query).length).toEqual(0);
});
it('querySelectorAll', () => {
expect(rrdom.querySelectorAll('link[rel="stylesheet"]').length).toEqual(
1,
);
const targetLink = rrdom.querySelectorAll(
'link[rel="stylesheet"]',
)[0] as RRElement;
expect(targetLink.tagName).toEqual('LINK');
expect(targetLink.getAttribute('rel')).toEqual('stylesheet');
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);
expect(rrdom.getElementsByTagName('style')[0].tagName).toEqual('STYLE');
const styleElement = rrdom.getElementsByTagName(
'style',
)[0] as RRStyleElement;
expect(styleElement.sheet).toBeDefined();
expect(styleElement.sheet!.cssRules).toBeDefined();
expect(styleElement.sheet!.cssRules.length).toEqual(5);
const rules = styleElement.sheet!.cssRules;
expect(rules[0].cssText).toEqual(`h1 {color: 'black';}`);
expect(rules[1].cssText).toEqual(`.blocks {padding: 0;}`);
expect(rules[2].cssText).toEqual(`.blocks1 {margin: 0;}`);
expect(rules[3].cssText).toEqual(
`#block1 {width: 100px; height: 200px;}`,
);
expect(rules[4].cssText).toEqual(`@import url(main.css);`);
expect((rules[4] as CSSImportRule).href).toEqual('main.css');
expect(styleElement.sheet!.insertRule).toBeDefined();
const newRule = "p {color: 'black';}";
styleElement.sheet!.insertRule(newRule, 5);
expect(rules[5].cssText).toEqual(newRule);
expect(styleElement.sheet!.deleteRule).toBeDefined();
styleElement.sheet!.deleteRule(5);
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();
});
});
});
function getHtml(fileName: string) {
const filePath = path.resolve(__dirname, `./html/${fileName}`);
return fs.readFileSync(filePath, 'utf8');
}

View File

@@ -1,131 +0,0 @@
import { compare } from 'compare-versions';
import { RRDocument, RRNode } from '../src/document-nodejs';
import {
polyfillPerformance,
polyfillRAF,
polyfillEvent,
polyfillNode,
polyfillDocument,
} from '../src/polyfill';
describe('polyfill for nodejs', () => {
it('should polyfill performance api', () => {
if (compare(process.version, 'v16.0.0', '<'))
expect(global.performance).toBeUndefined();
polyfillPerformance();
expect(global.performance).toBeDefined();
expect(performance).toBeDefined();
expect(performance.now).toBeDefined();
expect(performance.now()).toBeCloseTo(
require('perf_hooks').performance.now(),
1e-10,
);
});
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();
polyfillRAF();
expect(global.requestAnimationFrame).toBeDefined();
expect(global.cancelAnimationFrame).toBeDefined();
expect(requestAnimationFrame).toBeDefined();
expect(cancelAnimationFrame).toBeDefined();
jest.useFakeTimers();
const AnimationTime = 1_000; // target animation time(unit: ms)
const startTime = Date.now();
let frameCount = 0;
const rafCallback1 = () => {
const currentTime = Date.now();
frameCount++;
if (currentTime - startTime < AnimationTime) {
requestAnimationFrame(rafCallback1);
} else {
expect(frameCount).toBeGreaterThanOrEqual(55);
expect(frameCount).toBeLessThanOrEqual(65);
}
};
requestAnimationFrame(rafCallback1);
// Fast-forward until all timers have been executed
jest.runAllTimers();
let rafHandle;
const rafCallback2 = () => {
rafHandle = requestAnimationFrame(rafCallback2);
};
rafHandle = requestAnimationFrame(rafCallback2);
// If this function doesn't work, recursive function will never end.
cancelAnimationFrame(rafHandle);
jest.runAllTimers();
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();
expect(global.Node).toBeDefined();
expect(Node).toBeDefined();
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();
expect(global.document).toBeDefined();
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

@@ -27,8 +27,8 @@ import {
RRCanvasElement,
RRDocument,
RRElement,
RRNode,
} from '../src/virtual-dom';
BaseRRNode as RRNode,
} from '../src';
const _typescript = (typescript as unknown) as typeof typescript.default;
const printRRDomCode = `
@@ -219,9 +219,9 @@ describe('RRDocument for browser environment', () => {
beforeAll(async () => {
browser = await puppeteer.launch();
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/virtual-dom.ts'),
input: path.resolve(__dirname, '../src/index.ts'),
plugins: [
resolve(),
(resolve() as unknown) as rollup.Plugin,
(_typescript({
tsconfigOverride: { compilerOptions: { module: 'ESNext' } },
}) as unknown) as rollup.Plugin,

View File

@@ -16,5 +16,5 @@
},
"compileOnSave": true,
"exclude": ["test"],
"include": ["src", "test.d.ts", "../rrweb/src/record/workers/workers.d.ts"]
"include": ["src", "../rrweb/src/record/workers/workers.d.ts"]
}