* create rrdom package

* test(rrdom): add unit tests for polyfill.ts

* fix(rrweb snapshot): type check errors

Errors are caused by the declaration similarity of @types/mocha and @types/jest if we install both of them in the whole project.

* Set tagNames to upper case by default

This mirrors the `Element.tagName` implementation:

```
For DOM trees which represent HTML documents, the returned tag name is always in the canonical upper-case form. For example, tagName called on a <div> element returns "DIV".
```
https://developer.mozilla.org/en-US/docs/Web/API/Element/tagName

* Add workspace file

* VSCode settings for rrdom tests

* Add basic test for RRDocument

* Only setup jest tests for rrdom

* mock Node type and Event type for nodejs environment

* test(rrdom): add snapshot for document.test.ts

* fix issue of nwsapi import and add unit tests for rrdom

* fix: querySelectorAll returns nothing when querying elements with ids and classNames

* fix: error of unit test for Event polyfill

Since Event class is built in nodejs after v15.0.0

* add a dummy implementation of canvas

* add style element support

* add unit test for style element

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
Lucky Feng
2022-01-11 23:15:57 +08:00
committed by GitHub
parent 588b3d6f62
commit 320a454c49
19 changed files with 2566 additions and 7 deletions

View File

@@ -17,7 +17,8 @@
"workspaces": [
"packages/rrweb",
"packages/rrweb-snapshot",
"packages/rrweb-player"
"packages/rrweb-player",
"packages/rrdom"
],
"devDependencies": {
"lerna": "^4.0.0"

4
packages/rrdom/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist
es
lib
typings

View File

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

15
packages/rrdom/.vscode/launch.json vendored Normal file
View File

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

3
packages/rrdom/.vscode/settings.json vendored Normal file
View File

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

View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@@ -0,0 +1,44 @@
{
"name": "rrdom",
"version": "0.0.0",
"scripts": {
"dev": "rollup -c -w",
"bundle": "rollup --config",
"bundle:es-only": "cross-env ES_ONLY=true rollup --config",
"test": "jest",
"prepublish": "npm run bundle"
},
"keywords": [
"rrweb",
"rrdom"
],
"license": "MIT",
"main": "lib/rrdom.js",
"module": "es/rrdom.js",
"typings": "es",
"unpkg": "dist/rrdom.js",
"files": [
"dist",
"lib",
"es",
"typings"
],
"devDependencies": {
"@rollup/plugin-commonjs": "^20.0.0",
"@rollup/plugin-node-resolve": "^13.0.4",
"@types/cssom": "^0.4.1",
"@types/jest": "^27.0.1",
"@types/nwsapi": "^2.2.2",
"jest": "^27.1.1",
"rollup": "^2.56.3",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.30.0",
"rrweb-snapshot": "^1.1.8",
"ts-jest": "^27.0.5",
"typescript": "^3.9.5"
},
"dependencies": {
"cssom": "^0.5.0",
"nwsapi": "^2.2.0"
}
}

View File

@@ -0,0 +1,103 @@
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 pkg from './package.json';
function toMinPath(path) {
return path.replace(/\.js$/, '.min.js');
}
const basePlugins = [
resolve({ browser: true }),
commonjs(),
typescript({
tsconfigOverride: { compilerOptions: { module: 'ESNext' } },
}),
];
const baseConfigs = [
{
input: './src/index.ts',
name: pkg.name,
path: pkg.name,
},
{
input: './src/document-nodejs.ts',
name: 'RRDocument',
path: 'document-nodejs',
},
];
let configs = [];
let extraConfigs = [];
for (let config of baseConfigs) {
configs.push(
// ES module
{
input: config.input,
plugins: basePlugins,
output: [
{
format: 'esm',
file: pkg.module.replace(pkg.name, config.path),
},
],
},
);
extraConfigs.push(
// browser
{
input: config.input,
plugins: basePlugins,
output: [
{
name: config.name,
format: 'iife',
file: pkg.unpkg.replace(pkg.name, config.path),
},
],
},
{
input: config.input,
plugins: basePlugins.concat(terser()),
output: [
{
name: config.name,
format: 'iife',
file: toMinPath(pkg.unpkg).replace(pkg.name, config.path),
sourcemap: true,
},
],
},
// CommonJS
{
input: config.input,
plugins: basePlugins,
output: [
{
format: 'cjs',
file: pkg.main.replace(pkg.name, config.path),
},
],
},
// ES module (packed)
{
input: config.input,
plugins: basePlugins.concat(terser()),
output: [
{
format: 'esm',
file: toMinPath(pkg.module).replace(pkg.name, config.path),
sourcemap: true,
},
],
},
);
}
if (!process.env.ES_ONLY) {
configs.push(...extraConfigs);
}
export default configs;

View File

@@ -0,0 +1,787 @@
import { INode, NodeType, serializedNodeWithId } from 'rrweb-snapshot';
import { NWSAPI } from 'nwsapi';
import { parseCSSText, camelize, toCSSText } from './style';
const nwsapi = require('nwsapi');
const cssom = require('cssom');
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 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 RRNode {
private mirror: Map<number, RRNode> = new Map();
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 {
return this.children.find(
(node) => node instanceof RRElement && node.tagName === 'HTML',
) as RRElement;
}
get body() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'BODY',
) || null
);
}
get head() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'HEAD',
) || null
);
}
get implementation() {
return this;
}
get firstElementChild() {
return this.documentElement;
}
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;
}
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;
}
querySelectorAll(selectors: string): RRNode[] {
return (this.nwsapi.select(selectors) as unknown) as RRNode[];
}
getElementsByTagName(tagName: string): RRElement[] {
if (this.documentElement)
return (this.documentElement as RRElement).getElementsByTagName(tagName);
return [];
}
getElementsByClassName(className: string): RRElement[] {
if (this.documentElement)
return (this.documentElement as RRElement).getElementsByClassName(
className,
);
return [];
}
getElementById(elementId: string): RRElement | null {
if (this.documentElement)
return (this.documentElement as RRElement).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('IMG');
break;
case 'CANVAS':
element = new RRCanvasElement('CANVAS');
break;
case 'STYLE':
element = new RRStyleElement('STYLE');
break;
default:
element = new RRElement(upperTagName);
break;
}
element.ownerDocument = this;
return element;
}
createElementNS(
_namespaceURI: 'http://www.w3.org/2000/svg',
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;
}
/**
* 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;
constructor(tagName: string) {
super();
this.tagName = tagName;
}
get classList() {
return new ClassList(
this.attributes.class as string | undefined,
(newClassName) => {
this.attributes.class = newClassName;
},
);
}
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;
}
get firstElementChild(): RRElement | null {
for (let child of this.children)
if (child instanceof RRElement) return child;
return null;
}
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;
}
getAttribute(name: string) {
let 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;
}
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];
}
appendChild(newChild: RRNode): RRNode {
this.children.push(newChild);
newChild.parentNode = this;
newChild.parentElement = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
}
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;
}
querySelectorAll(selectors: string): RRNode[] {
if (this.ownerDocument !== null) {
return (this.ownerDocument.nwsapi.select(
selectors,
(this as unknown) as Element,
) as unknown) as RRNode[];
}
return [];
}
getElementById(elementId: string): RRElement | null {
if (this instanceof RRElement && this.id === elementId) return this;
for (const child of this.children) {
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.filter((queriedClassName) =>
this.classList.some((name) => name === queriedClassName),
).length == queryClassList.length
)
elements.push(this);
for (const child of this.children) {
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.children) {
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 {
src: string;
width: number;
height: number;
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 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 (let child of this.childNodes)
if (child.nodeType === NodeType.Text)
result += (child as RRText).textContent;
this._sheet = cssom.parse(result);
}
return this._sheet;
}
}
export class RRIframeElement extends RRElement {
width: string = '';
height: string = '';
src: string = '';
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 RRNode {
textContent: string;
constructor(data: string) {
super();
this.textContent = data;
}
toString() {
return `${super.toString('RRText')} text=${JSON.stringify(
this.textContent,
)}`;
}
}
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 RRCDATASection extends RRNode {
data: string;
constructor(data: string) {
super();
this.data = data;
}
toString() {
return `${super.toString('RRCDATASection')} data=${JSON.stringify(
this.data,
)}`;
}
}
interface RRElementTagNameMap {
img: RRImageElement;
audio: RRMediaElement;
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,13 @@
import {
polyfillPerformance,
polyfillRAF,
polyfillEvent,
polyfillNode,
polyfillDocument,
} from './polyfill';
polyfillPerformance();
polyfillRAF();
polyfillEvent();
polyfillNode();
polyfillDocument();
export * from './document-nodejs';

View File

@@ -0,0 +1,87 @@
import { RRDocument, RRNode } from './document-nodejs';
/**
* Polyfill the performance for nodejs.
*/
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

@@ -0,0 +1,40 @@
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;
}
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();
};

View File

@@ -0,0 +1,48 @@
// 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,259 @@
/**
* @jest-environment jsdom
*/
import * as fs from 'fs';
import * as path from 'path';
import { RRDocument, RRElement, RRStyleElement } from '../src/document-nodejs';
import { printRRDom } from './util';
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);
});
it('get className', () => {
expect(rrdom.getElementsByTagName('DIV')[0].className).toEqual(
'blocks blocks1',
);
expect(rrdom.getElementsByTagName('DIV')[1].className).toEqual(
'blocks blocks1 :hover',
);
});
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('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',
);
expect(rrdom.getElementsByTagName('div')[0].getAttribute('iD')).toEqual(
'block1',
);
expect(
rrdom.getElementsByTagName('p')[0].getAttribute('class'),
).toBeNull();
});
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('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',
'BODY',
'HEAD',
'STYLE',
'META',
'TITLE',
'SCRIPT',
'LINK',
'DIV',
'H1',
'P',
'BUTTON',
'IMG',
'CANVAS',
]) {
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);
}
}
});
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,
});
}
});
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();
});
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).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);
});
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);`);
});
});
});
function getHtml(fileName: string) {
const filePath = path.resolve(__dirname, `./html/${fileName}`);
return fs.readFileSync(filePath, 'utf8');
}

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Main</title>
<link rel="stylesheet" href="somelink">
<style>
h1 {
color: 'black';
}
.blocks {
padding: 0;
}
.blocks1 {
margin: 0;
}
#block1 {
width: 100px;
height: 200px;
}
@import url("main.css");
</style>
</head>
<body>
<h1>This is a h1 heading</h1>
<h1 style="font-size: 16px">This is a h1 heading with styles</h1>
<div id="block1" class="blocks blocks1">
<div id="block2" class="blocks blocks1 :hover">
Text 1
<div id="block3">
<p>This is a paragraph</p>
<button>button1</button>
</div>
Text 2
</div>
<img src="somelink" alt="This is an image" />
</div>
</body>
</html>

View File

@@ -0,0 +1,83 @@
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', () => {
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 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 polyfill Event type', () => {
polyfillEvent();
expect(global.Event).toBeDefined();
expect(Event).toBeDefined();
});
it('should polyfill Node type', () => {
expect(global.Node).toBeUndefined();
polyfillNode();
expect(global.Node).toBeDefined();
expect(Node).toBeDefined();
expect(Node).toEqual(RRNode);
});
it('should polyfill document object', () => {
expect(global.document).toBeUndefined();
polyfillDocument();
expect(global.document).toBeDefined();
expect(document).toBeDefined();
expect(document).toBeInstanceOf(RRDocument);
});
});

View File

@@ -0,0 +1,19 @@
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,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"noImplicitAny": true,
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "build",
"lib": ["es6", "dom"],
"skipLibCheck": true,
"declaration": true
},
"compileOnSave": true,
"exclude": ["test"],
"include": ["src", "test.d.ts"]
}

998
yarn.lock

File diff suppressed because it is too large Load Diff