rrdom (#613)
* 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:
@@ -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
4
packages/rrdom/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
es
|
||||
lib
|
||||
typings
|
||||
3
packages/rrdom/.vscode/extensions.json
vendored
Normal file
3
packages/rrdom/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["orta.vscode-jest"]
|
||||
}
|
||||
15
packages/rrdom/.vscode/launch.json
vendored
Normal file
15
packages/rrdom/.vscode/launch.json
vendored
Normal 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
3
packages/rrdom/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"jest.jestCommandLine": "yarn test"
|
||||
}
|
||||
5
packages/rrdom/jest.config.js
Normal file
5
packages/rrdom/jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
44
packages/rrdom/package.json
Normal file
44
packages/rrdom/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
103
packages/rrdom/rollup.config.js
Normal file
103
packages/rrdom/rollup.config.js
Normal 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;
|
||||
787
packages/rrdom/src/document-nodejs.ts
Normal file
787
packages/rrdom/src/document-nodejs.ts
Normal 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(' '));
|
||||
};
|
||||
}
|
||||
13
packages/rrdom/src/index.ts
Normal file
13
packages/rrdom/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
polyfillPerformance,
|
||||
polyfillRAF,
|
||||
polyfillEvent,
|
||||
polyfillNode,
|
||||
polyfillDocument,
|
||||
} from './polyfill';
|
||||
polyfillPerformance();
|
||||
polyfillRAF();
|
||||
polyfillEvent();
|
||||
polyfillNode();
|
||||
polyfillDocument();
|
||||
export * from './document-nodejs';
|
||||
87
packages/rrdom/src/polyfill.ts
Normal file
87
packages/rrdom/src/polyfill.ts
Normal 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;
|
||||
}
|
||||
40
packages/rrdom/src/style.ts
Normal file
40
packages/rrdom/src/style.ts
Normal 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();
|
||||
};
|
||||
|
||||
@@ -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\\"
|
||||
"
|
||||
`;
|
||||
259
packages/rrdom/test/document-nodejs.test.ts
Normal file
259
packages/rrdom/test/document-nodejs.test.ts
Normal 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');
|
||||
}
|
||||
40
packages/rrdom/test/html/main.html
Normal file
40
packages/rrdom/test/html/main.html
Normal 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>
|
||||
83
packages/rrdom/test/polyfill.test.ts
Normal file
83
packages/rrdom/test/polyfill.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
packages/rrdom/test/util.ts
Normal file
19
packages/rrdom/test/util.ts
Normal 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;
|
||||
}
|
||||
19
packages/rrdom/tsconfig.json
Normal file
19
packages/rrdom/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user