move browser-only rrdom features to the new rrdom package (#913)
This commit is contained in:
4
packages/rrdom-nodejs/.gitignore
vendored
Normal file
4
packages/rrdom-nodejs/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
es
|
||||
lib
|
||||
typings
|
||||
3
packages/rrdom-nodejs/.vscode/extensions.json
vendored
Normal file
3
packages/rrdom-nodejs/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["orta.vscode-jest"]
|
||||
}
|
||||
15
packages/rrdom-nodejs/.vscode/launch.json
vendored
Normal file
15
packages/rrdom-nodejs/.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-nodejs/.vscode/settings.json
vendored
Normal file
3
packages/rrdom-nodejs/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"jest.jestCommandLine": "yarn test"
|
||||
}
|
||||
5
packages/rrdom-nodejs/jest.config.js
Normal file
5
packages/rrdom-nodejs/jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
55
packages/rrdom-nodejs/package.json
Normal file
55
packages/rrdom-nodejs/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "rrdom-nodejs",
|
||||
"version": "0.1.2",
|
||||
"scripts": {
|
||||
"dev": "rollup -c -w",
|
||||
"bundle": "rollup --config",
|
||||
"bundle:es-only": "cross-env ES_ONLY=true rollup --config",
|
||||
"check-types": "tsc -noEmit",
|
||||
"test": "jest",
|
||||
"prepublish": "npm run bundle",
|
||||
"lint": "yarn eslint src/**/*.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"rrweb",
|
||||
"rrdom-nodejs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"main": "lib/rrdom-nodejs.js",
|
||||
"module": "es/rrdom-nodejs.js",
|
||||
"typings": "es",
|
||||
"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/cssstyle": "^2.2.1",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/nwsapi": "^2.2.2",
|
||||
"@types/puppeteer": "^5.4.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"compare-versions": "^4.1.3",
|
||||
"eslint": "^8.15.0",
|
||||
"jest": "^27.5.1",
|
||||
"puppeteer": "^9.1.1",
|
||||
"rollup": "^2.56.3",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"rollup-plugin-typescript2": "^0.31.2",
|
||||
"rollup-plugin-web-worker-loader": "^1.6.1",
|
||||
"ts-jest": "^27.1.3",
|
||||
"typescript": "^4.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cssom": "^0.5.0",
|
||||
"cssstyle": "^2.3.0",
|
||||
"nwsapi": "^2.2.0",
|
||||
"rrweb-snapshot": "^1.1.14",
|
||||
"rrdom": "^0.1.2"
|
||||
}
|
||||
}
|
||||
84
packages/rrdom-nodejs/rollup.config.js
Normal file
84
packages/rrdom-nodejs/rollup.config.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import typescript from 'rollup-plugin-typescript2';
|
||||
import webWorkerLoader from 'rollup-plugin-web-worker-loader';
|
||||
import pkg from './package.json';
|
||||
|
||||
function toMinPath(path) {
|
||||
return path.replace(/\.js$/, '.min.js');
|
||||
}
|
||||
|
||||
const basePlugins = [
|
||||
resolve({ browser: true }),
|
||||
commonjs(),
|
||||
|
||||
// supports bundling `web-worker:..filename` from rrweb
|
||||
webWorkerLoader(),
|
||||
|
||||
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(
|
||||
// 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;
|
||||
382
packages/rrdom-nodejs/src/document-nodejs.ts
Normal file
382
packages/rrdom-nodejs/src/document-nodejs.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { NodeType as RRNodeType } from 'rrweb-snapshot';
|
||||
import type { NWSAPI } from 'nwsapi';
|
||||
import type { CSSStyleDeclaration as CSSStyleDeclarationType } from 'cssstyle';
|
||||
import {
|
||||
BaseRRCDATASectionImpl,
|
||||
BaseRRCommentImpl,
|
||||
BaseRRDocumentImpl,
|
||||
BaseRRDocumentTypeImpl,
|
||||
BaseRRElementImpl,
|
||||
BaseRRMediaElementImpl,
|
||||
BaseRRNode,
|
||||
BaseRRTextImpl,
|
||||
ClassList,
|
||||
IRRDocument,
|
||||
CSSStyleDeclaration,
|
||||
} from 'rrdom';
|
||||
const nwsapi = require('nwsapi');
|
||||
const cssom = require('cssom');
|
||||
const cssstyle = require('cssstyle');
|
||||
|
||||
export class RRNode extends BaseRRNode {}
|
||||
|
||||
export class RRWindow {
|
||||
scrollLeft = 0;
|
||||
scrollTop = 0;
|
||||
scrollTo(options?: ScrollToOptions) {
|
||||
if (!options) return;
|
||||
if (typeof options.left === 'number') this.scrollLeft = options.left;
|
||||
if (typeof options.top === 'number') this.scrollTop = options.top;
|
||||
}
|
||||
}
|
||||
|
||||
export class RRDocument
|
||||
extends BaseRRDocumentImpl(RRNode)
|
||||
implements IRRDocument {
|
||||
readonly nodeName: '#document' = '#document';
|
||||
private _nwsapi: NWSAPI;
|
||||
get nwsapi() {
|
||||
if (!this._nwsapi) {
|
||||
this._nwsapi = nwsapi({
|
||||
document: (this as unknown) as Document,
|
||||
DOMException: (null as unknown) as new (
|
||||
message?: string,
|
||||
name?: string,
|
||||
) => DOMException,
|
||||
});
|
||||
this._nwsapi.configure({
|
||||
LOGERRORS: false,
|
||||
IDS_DUPES: true,
|
||||
MIXEDCASE: true,
|
||||
});
|
||||
}
|
||||
return this._nwsapi;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get documentElement(): RRElement | null {
|
||||
return super.documentElement as RRElement | null;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get body(): RRElement | null {
|
||||
return super.body as RRElement | null;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get head() {
|
||||
return super.head as RRElement | null;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get implementation(): RRDocument {
|
||||
return this;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get firstElementChild(): RRElement | null {
|
||||
return this.documentElement;
|
||||
}
|
||||
|
||||
appendChild(childNode: RRNode) {
|
||||
return super.appendChild(childNode);
|
||||
}
|
||||
|
||||
insertBefore(newChild: RRNode, refChild: RRNode | null) {
|
||||
return super.insertBefore(newChild, refChild);
|
||||
}
|
||||
|
||||
querySelectorAll(selectors: string): RRNode[] {
|
||||
return (this.nwsapi.select(selectors) as unknown) as RRNode[];
|
||||
}
|
||||
|
||||
getElementsByTagName(tagName: string): RRElement[] {
|
||||
if (this.documentElement)
|
||||
return this.documentElement.getElementsByTagName(tagName);
|
||||
return [];
|
||||
}
|
||||
|
||||
getElementsByClassName(className: string): RRElement[] {
|
||||
if (this.documentElement)
|
||||
return this.documentElement.getElementsByClassName(className);
|
||||
return [];
|
||||
}
|
||||
|
||||
getElementById(elementId: string): RRElement | null {
|
||||
if (this.documentElement)
|
||||
return this.documentElement.getElementById(elementId);
|
||||
return null;
|
||||
}
|
||||
|
||||
createDocument(
|
||||
_namespace: string | null,
|
||||
_qualifiedName: string | null,
|
||||
_doctype?: DocumentType | null,
|
||||
) {
|
||||
return new RRDocument();
|
||||
}
|
||||
|
||||
createDocumentType(
|
||||
qualifiedName: string,
|
||||
publicId: string,
|
||||
systemId: string,
|
||||
) {
|
||||
const documentTypeNode = new RRDocumentType(
|
||||
qualifiedName,
|
||||
publicId,
|
||||
systemId,
|
||||
);
|
||||
documentTypeNode.ownerDocument = this;
|
||||
return documentTypeNode;
|
||||
}
|
||||
|
||||
createElement<K extends keyof HTMLElementTagNameMap>(
|
||||
tagName: K,
|
||||
): RRElementType<K>;
|
||||
createElement(tagName: string): RRElement;
|
||||
createElement(tagName: string) {
|
||||
const upperTagName = tagName.toUpperCase();
|
||||
let element;
|
||||
switch (upperTagName) {
|
||||
case 'AUDIO':
|
||||
case 'VIDEO':
|
||||
element = new RRMediaElement(upperTagName);
|
||||
break;
|
||||
case 'IFRAME':
|
||||
element = new RRIFrameElement(upperTagName);
|
||||
break;
|
||||
case 'IMG':
|
||||
element = new RRImageElement(upperTagName);
|
||||
break;
|
||||
case 'CANVAS':
|
||||
element = new RRCanvasElement(upperTagName);
|
||||
break;
|
||||
case 'STYLE':
|
||||
element = new RRStyleElement(upperTagName);
|
||||
break;
|
||||
default:
|
||||
element = new RRElement(upperTagName);
|
||||
break;
|
||||
}
|
||||
element.ownerDocument = this;
|
||||
return element;
|
||||
}
|
||||
|
||||
createElementNS(_namespaceURI: string, qualifiedName: string) {
|
||||
return this.createElement(qualifiedName as keyof HTMLElementTagNameMap);
|
||||
}
|
||||
|
||||
createComment(data: string) {
|
||||
const commentNode = new RRComment(data);
|
||||
commentNode.ownerDocument = this;
|
||||
return commentNode;
|
||||
}
|
||||
|
||||
createCDATASection(data: string) {
|
||||
const sectionNode = new RRCDATASection(data);
|
||||
sectionNode.ownerDocument = this;
|
||||
return sectionNode;
|
||||
}
|
||||
|
||||
createTextNode(data: string) {
|
||||
const textNode = new RRText(data);
|
||||
textNode.ownerDocument = this;
|
||||
return textNode;
|
||||
}
|
||||
}
|
||||
|
||||
export class RRDocumentType extends BaseRRDocumentTypeImpl(RRNode) {}
|
||||
|
||||
export class RRElement extends BaseRRElementImpl(RRNode) {
|
||||
private _style: CSSStyleDeclarationType;
|
||||
constructor(tagName: string) {
|
||||
super(tagName);
|
||||
this._style = new cssstyle.CSSStyleDeclaration();
|
||||
const style = this._style;
|
||||
Object.defineProperty(this.attributes, 'style', {
|
||||
get() {
|
||||
return style.cssText;
|
||||
},
|
||||
set(cssText: string) {
|
||||
style.cssText = cssText;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get style() {
|
||||
return (this._style as unknown) as CSSStyleDeclaration;
|
||||
}
|
||||
|
||||
attachShadow(_init: ShadowRootInit): RRElement {
|
||||
return super.attachShadow(_init) as RRElement;
|
||||
}
|
||||
|
||||
appendChild(newChild: RRNode): RRNode {
|
||||
return super.appendChild(newChild) as RRNode;
|
||||
}
|
||||
|
||||
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
|
||||
return super.insertBefore(newChild, refChild) as RRNode;
|
||||
}
|
||||
|
||||
getAttribute(name: string) {
|
||||
const upperName = name && name.toLowerCase();
|
||||
if (upperName in this.attributes) return this.attributes[upperName];
|
||||
return null;
|
||||
}
|
||||
|
||||
setAttribute(name: string, attribute: string) {
|
||||
this.attributes[name.toLowerCase()] = attribute;
|
||||
}
|
||||
|
||||
removeAttribute(name: string) {
|
||||
delete this.attributes[name.toLowerCase()];
|
||||
}
|
||||
|
||||
get firstElementChild(): RRElement | null {
|
||||
for (const child of this.childNodes)
|
||||
if (child.RRNodeType === RRNodeType.Element) return child as RRElement;
|
||||
return null;
|
||||
}
|
||||
|
||||
get nextElementSibling(): RRElement | null {
|
||||
const parentNode = this.parentNode;
|
||||
if (!parentNode) return null;
|
||||
const siblings = parentNode.childNodes;
|
||||
const index = siblings.indexOf(this);
|
||||
for (let i = index + 1; i < siblings.length; i++)
|
||||
if (siblings[i] instanceof RRElement) return siblings[i] as RRElement;
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelectorAll(selectors: string): RRNode[] {
|
||||
const result: RRElement[] = [];
|
||||
if (this.ownerDocument !== null) {
|
||||
((this.ownerDocument as RRDocument).nwsapi.select(
|
||||
selectors,
|
||||
(this as unknown) as Element,
|
||||
(element) => {
|
||||
if (((element as unknown) as RRElement) !== this)
|
||||
result.push((element as unknown) as RRElement);
|
||||
},
|
||||
) as unknown) as RRNode[];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getElementById(elementId: string): RRElement | null {
|
||||
if (this.id === elementId) return this;
|
||||
for (const child of this.childNodes) {
|
||||
if (child instanceof RRElement) {
|
||||
const result = child.getElementById(elementId);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getElementsByClassName(className: string): RRElement[] {
|
||||
let elements: RRElement[] = [];
|
||||
const queryClassList = new ClassList(className);
|
||||
// Make sure this element has all queried class names.
|
||||
if (
|
||||
this instanceof RRElement &&
|
||||
queryClassList.classes.filter((queriedClassName) =>
|
||||
this.classList.classes.some((name) => name === queriedClassName),
|
||||
).length == queryClassList.classes.length
|
||||
)
|
||||
elements.push(this);
|
||||
for (const child of this.childNodes) {
|
||||
if (child instanceof RRElement)
|
||||
elements = elements.concat(child.getElementsByClassName(className));
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
getElementsByTagName(tagName: string): RRElement[] {
|
||||
let elements: RRElement[] = [];
|
||||
const normalizedTagName = tagName.toUpperCase();
|
||||
if (this instanceof RRElement && this.tagName === normalizedTagName)
|
||||
elements.push(this);
|
||||
for (const child of this.childNodes) {
|
||||
if (child instanceof RRElement)
|
||||
elements = elements.concat(child.getElementsByTagName(tagName));
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
}
|
||||
|
||||
export class RRImageElement extends RRElement {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
onload: ((this: GlobalEventHandlers, ev: Event) => any) | null;
|
||||
}
|
||||
|
||||
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
|
||||
|
||||
export class RRCanvasElement extends RRElement {
|
||||
/**
|
||||
* This is just a dummy implementation to prevent rrweb replayer from drawing mouse tail. If further analysis of canvas is needed, we may implement it with node-canvas.
|
||||
*/
|
||||
getContext(): CanvasRenderingContext2D | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class RRStyleElement extends RRElement {
|
||||
private _sheet: CSSStyleSheet | null = null;
|
||||
|
||||
get sheet() {
|
||||
if (!this._sheet) {
|
||||
let result = '';
|
||||
for (const child of this.childNodes)
|
||||
if (child.RRNodeType === RRNodeType.Text)
|
||||
result += (child as RRText).textContent;
|
||||
this._sheet = cssom.parse(result);
|
||||
}
|
||||
return this._sheet;
|
||||
}
|
||||
}
|
||||
|
||||
export class RRIFrameElement extends RRElement {
|
||||
width = '';
|
||||
height = '';
|
||||
src = '';
|
||||
contentDocument: RRDocument = new RRDocument();
|
||||
contentWindow: RRWindow = new RRWindow();
|
||||
|
||||
constructor(tagName: string) {
|
||||
super(tagName);
|
||||
const htmlElement = this.contentDocument.createElement('HTML');
|
||||
this.contentDocument.appendChild(htmlElement);
|
||||
htmlElement.appendChild(this.contentDocument.createElement('HEAD'));
|
||||
htmlElement.appendChild(this.contentDocument.createElement('BODY'));
|
||||
}
|
||||
}
|
||||
|
||||
export class RRText extends BaseRRTextImpl(RRNode) {
|
||||
readonly nodeName: '#text' = '#text';
|
||||
}
|
||||
|
||||
export class RRComment extends BaseRRCommentImpl(RRNode) {
|
||||
readonly nodeName: '#comment' = '#comment';
|
||||
}
|
||||
|
||||
export class RRCDATASection extends BaseRRCDATASectionImpl(RRNode) {
|
||||
readonly nodeName: '#cdata-section' = '#cdata-section';
|
||||
}
|
||||
|
||||
interface RRElementTagNameMap {
|
||||
audio: RRMediaElement;
|
||||
canvas: RRCanvasElement;
|
||||
iframe: RRIFrameElement;
|
||||
img: RRImageElement;
|
||||
style: RRStyleElement;
|
||||
video: RRMediaElement;
|
||||
}
|
||||
|
||||
type RRElementType<
|
||||
K extends keyof HTMLElementTagNameMap
|
||||
> = K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
|
||||
13
packages/rrdom-nodejs/src/index.ts
Normal file
13
packages/rrdom-nodejs/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';
|
||||
89
packages/rrdom-nodejs/src/polyfill.ts
Normal file
89
packages/rrdom-nodejs/src/polyfill.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { RRDocument, RRNode } from './document-nodejs';
|
||||
|
||||
/**
|
||||
* Polyfill the performance for nodejs.
|
||||
* Note: The performance api is available through the global object from nodejs v16.0.0.
|
||||
* https://github.com/nodejs/node/pull/37970
|
||||
*/
|
||||
export function polyfillPerformance() {
|
||||
if (typeof window !== 'undefined' || 'performance' in global) return;
|
||||
((global as Window & typeof globalThis)
|
||||
.performance as unknown) = require('perf_hooks').performance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyfill requestAnimationFrame and cancelAnimationFrame for nodejs.
|
||||
*/
|
||||
export function polyfillRAF() {
|
||||
if (typeof window !== 'undefined' || 'requestAnimationFrame' in global)
|
||||
return;
|
||||
|
||||
const FPS = 60,
|
||||
INTERVAL = 1_000 / FPS;
|
||||
let timeoutHandle: NodeJS.Timeout | null = null,
|
||||
rafCount = 0,
|
||||
requests = Object.create(null);
|
||||
|
||||
function onFrameTimer() {
|
||||
const currentRequests = requests;
|
||||
requests = Object.create(null);
|
||||
timeoutHandle = null;
|
||||
Object.keys(currentRequests).forEach(function (id) {
|
||||
const request = currentRequests[id];
|
||||
if (request) request(Date.now());
|
||||
});
|
||||
}
|
||||
|
||||
function requestAnimationFrame(callback: (timestamp: number) => void) {
|
||||
const cbHandle = ++rafCount;
|
||||
requests[cbHandle] = callback;
|
||||
if (timeoutHandle === null)
|
||||
timeoutHandle = setTimeout(onFrameTimer, INTERVAL);
|
||||
return cbHandle;
|
||||
}
|
||||
|
||||
function cancelAnimationFrame(handleId: number) {
|
||||
delete requests[handleId];
|
||||
if (Object.keys(requests).length === 0 && timeoutHandle !== null) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
(global as Window &
|
||||
typeof globalThis).requestAnimationFrame = requestAnimationFrame;
|
||||
(global as Window &
|
||||
typeof globalThis).cancelAnimationFrame = cancelAnimationFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to polyfill Event type.
|
||||
* The implementation of Event so far is empty because rrweb doesn't strongly depend on it in nodejs mode.
|
||||
* Note: The Event class is available through the global object from nodejs v15.0.0.
|
||||
*/
|
||||
export function polyfillEvent() {
|
||||
if (typeof Event !== 'undefined') return;
|
||||
(global.Event as unknown) = function () {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyfill Node type with RRNode for nodejs.
|
||||
*/
|
||||
export function polyfillNode() {
|
||||
if (typeof Node !== 'undefined') return;
|
||||
(global.Node as unknown) = RRNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyfill document object with RRDocument for nodejs.
|
||||
*/
|
||||
export function polyfillDocument() {
|
||||
if (typeof document !== 'undefined') return;
|
||||
const rrdom = new RRDocument();
|
||||
(() => {
|
||||
rrdom.appendChild(rrdom.createElement('html'));
|
||||
rrdom.documentElement!.appendChild(rrdom.createElement('head'));
|
||||
rrdom.documentElement!.appendChild(rrdom.createElement('body'));
|
||||
})();
|
||||
global.document = (rrdom as unknown) as Document;
|
||||
}
|
||||
547
packages/rrdom-nodejs/test/document-nodejs.test.ts
Normal file
547
packages/rrdom-nodejs/test/document-nodejs.test.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { NodeType as RRNodeType } from 'rrweb-snapshot';
|
||||
import {
|
||||
RRCanvasElement,
|
||||
RRCDATASection,
|
||||
RRComment,
|
||||
RRDocument,
|
||||
RRElement,
|
||||
RRIFrameElement,
|
||||
RRImageElement,
|
||||
RRMediaElement,
|
||||
RRStyleElement,
|
||||
RRText,
|
||||
} from '../src/document-nodejs';
|
||||
import { buildFromDom } from 'rrdom';
|
||||
|
||||
describe('RRDocument for nodejs environment', () => {
|
||||
describe('RRDocument API', () => {
|
||||
let rrdom: RRDocument;
|
||||
beforeAll(() => {
|
||||
// initialize rrdom
|
||||
document.write(getHtml('main.html'));
|
||||
rrdom = new RRDocument();
|
||||
buildFromDom(document, undefined, rrdom);
|
||||
});
|
||||
|
||||
it('can create different type of RRNodes', () => {
|
||||
const document = rrdom.createDocument('', '');
|
||||
expect(document).toBeInstanceOf(RRDocument);
|
||||
const audio = rrdom.createElement('audio');
|
||||
expect(audio).toBeInstanceOf(RRMediaElement);
|
||||
const video = rrdom.createElement('video');
|
||||
expect(video).toBeInstanceOf(RRMediaElement);
|
||||
const iframe = rrdom.createElement('iframe');
|
||||
expect(iframe).toBeInstanceOf(RRIFrameElement);
|
||||
const image = rrdom.createElement('img');
|
||||
expect(image).toBeInstanceOf(RRImageElement);
|
||||
const canvas = rrdom.createElement('canvas');
|
||||
expect(canvas).toBeInstanceOf(RRCanvasElement);
|
||||
const style = rrdom.createElement('style');
|
||||
expect(style).toBeInstanceOf(RRStyleElement);
|
||||
const elementNS = rrdom.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'div',
|
||||
);
|
||||
expect(elementNS).toBeInstanceOf(RRElement);
|
||||
expect(elementNS.tagName).toEqual('DIV');
|
||||
const text = rrdom.createTextNode('text');
|
||||
expect(text).toBeInstanceOf(RRText);
|
||||
expect(text.textContent).toEqual('text');
|
||||
const comment = rrdom.createComment('comment');
|
||||
expect(comment).toBeInstanceOf(RRComment);
|
||||
expect(comment.textContent).toEqual('comment');
|
||||
const CDATA = rrdom.createCDATASection('data');
|
||||
expect(CDATA).toBeInstanceOf(RRCDATASection);
|
||||
expect(CDATA.data).toEqual('data');
|
||||
});
|
||||
|
||||
it('can get head element', () => {
|
||||
expect(rrdom.head).toBeDefined();
|
||||
expect(rrdom.head!.tagName).toBe('HEAD');
|
||||
expect(rrdom.head!.parentElement).toBe(rrdom.documentElement);
|
||||
});
|
||||
|
||||
it('can get body element', () => {
|
||||
expect(rrdom.body).toBeDefined();
|
||||
expect(rrdom.body!.tagName).toBe('BODY');
|
||||
expect(rrdom.body!.parentElement).toBe(rrdom.documentElement);
|
||||
});
|
||||
|
||||
it('can get implementation', () => {
|
||||
expect(rrdom.implementation).toBeDefined();
|
||||
expect(rrdom.implementation).toBe(rrdom);
|
||||
});
|
||||
|
||||
it('can insert elements', () => {
|
||||
expect(() =>
|
||||
rrdom.insertBefore(rrdom.createDocumentType('', '', ''), null),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one RRDoctype on RRDocument allowed."`,
|
||||
);
|
||||
expect(() =>
|
||||
rrdom.insertBefore(rrdom.createElement('div'), null),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one RRElement on RRDocument allowed."`,
|
||||
);
|
||||
const node = new RRDocument();
|
||||
const doctype = rrdom.createDocumentType('', '', '');
|
||||
const documentElement = node.createElement('html');
|
||||
node.insertBefore(documentElement, null);
|
||||
node.insertBefore(doctype, documentElement);
|
||||
expect(node.childNodes.length).toEqual(2);
|
||||
expect(node.childNodes[0]).toBe(doctype);
|
||||
expect(node.childNodes[1]).toBe(documentElement);
|
||||
expect(node.documentElement).toBe(documentElement);
|
||||
});
|
||||
|
||||
it('get firstElementChild', () => {
|
||||
expect(rrdom.firstElementChild).toBeDefined();
|
||||
expect(rrdom.firstElementChild!.tagName).toEqual('HTML');
|
||||
|
||||
const div1 = rrdom.getElementById('block1');
|
||||
expect(div1).toBeDefined();
|
||||
expect(div1!.firstElementChild).toBeDefined();
|
||||
expect(div1!.firstElementChild!.id).toEqual('block2');
|
||||
const div2 = div1!.firstElementChild;
|
||||
expect(div2!.firstElementChild!.id).toEqual('block3');
|
||||
});
|
||||
|
||||
it('getElementsByTagName', () => {
|
||||
for (let tagname of [
|
||||
'HTML',
|
||||
'BODY',
|
||||
'HEAD',
|
||||
'STYLE',
|
||||
'META',
|
||||
'TITLE',
|
||||
'SCRIPT',
|
||||
'LINK',
|
||||
'DIV',
|
||||
'H1',
|
||||
'P',
|
||||
'BUTTON',
|
||||
'IMG',
|
||||
'CANVAS',
|
||||
'FORM',
|
||||
'INPUT',
|
||||
]) {
|
||||
const expectedResult = document.getElementsByTagName(tagname).length;
|
||||
expect(rrdom.getElementsByTagName(tagname).length).toEqual(
|
||||
expectedResult,
|
||||
);
|
||||
expect(
|
||||
rrdom.getElementsByTagName(tagname.toLowerCase()).length,
|
||||
).toEqual(expectedResult);
|
||||
for (let node of rrdom.getElementsByTagName(tagname)) {
|
||||
expect(node.tagName).toEqual(tagname);
|
||||
}
|
||||
}
|
||||
const node = new RRDocument();
|
||||
expect(node.getElementsByTagName('h2').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('getElementsByClassName', () => {
|
||||
for (let className of [
|
||||
'blocks',
|
||||
'blocks1',
|
||||
':hover',
|
||||
'blocks1 blocks',
|
||||
'blocks blocks1',
|
||||
':hover blocks1',
|
||||
':hover blocks1 blocks',
|
||||
':hover blocks1 block',
|
||||
]) {
|
||||
const msg = `queried class name: '${className}'`;
|
||||
expect({
|
||||
message: msg,
|
||||
result: rrdom.getElementsByClassName(className).length,
|
||||
}).toEqual({
|
||||
message: msg,
|
||||
result: document.getElementsByClassName(className).length,
|
||||
});
|
||||
}
|
||||
const node = new RRDocument();
|
||||
expect(node.getElementsByClassName('block').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('getElementById', () => {
|
||||
for (let elementId of ['block1', 'block2', 'block3']) {
|
||||
expect(rrdom.getElementById(elementId)).not.toBeNull();
|
||||
expect(rrdom.getElementById(elementId)!.id).toEqual(elementId);
|
||||
}
|
||||
for (let elementId of ['block', 'blocks', 'blocks1'])
|
||||
expect(rrdom.getElementById(elementId)).toBeNull();
|
||||
const node = new RRDocument();
|
||||
expect(node.getElementById('id')).toBeNull();
|
||||
});
|
||||
|
||||
it('querySelectorAll querying tag name', () => {
|
||||
expect(rrdom.querySelectorAll('H1')).toHaveLength(2);
|
||||
expect(rrdom.querySelectorAll('H1')[0]).toBeInstanceOf(RRElement);
|
||||
expect((rrdom.querySelectorAll('H1')[0] as RRElement).tagName).toEqual(
|
||||
'H1',
|
||||
);
|
||||
expect(rrdom.querySelectorAll('H1')[1]).toBeInstanceOf(RRElement);
|
||||
expect((rrdom.querySelectorAll('H1')[1] as RRElement).tagName).toEqual(
|
||||
'H1',
|
||||
);
|
||||
});
|
||||
|
||||
it('querySelectorAll querying class name', () => {
|
||||
for (let className of [
|
||||
'.blocks',
|
||||
'.blocks1',
|
||||
'.\\:hover',
|
||||
'.blocks1.blocks',
|
||||
'.blocks.blocks1',
|
||||
'.\\:hover.blocks1',
|
||||
'.\\:hover.blocks1.blocks',
|
||||
'.\\:hover.blocks1.block',
|
||||
]) {
|
||||
const msg = `queried class name: '${className}'`;
|
||||
expect({
|
||||
message: msg,
|
||||
result: rrdom.querySelectorAll(className).length,
|
||||
}).toEqual({
|
||||
message: msg,
|
||||
result: document.querySelectorAll(className).length,
|
||||
});
|
||||
}
|
||||
for (let element of rrdom.querySelectorAll('.\\:hover')) {
|
||||
expect(element).toBeInstanceOf(RRElement);
|
||||
expect((element as RRElement).classList.classes).toContain(':hover');
|
||||
}
|
||||
});
|
||||
|
||||
it('querySelectorAll querying id', () => {
|
||||
for (let query of ['#block1', '#block2', '#block3']) {
|
||||
expect(rrdom.querySelectorAll(query).length).toEqual(1);
|
||||
const targetElement = rrdom.querySelectorAll(query)[0] as RRElement;
|
||||
expect(targetElement.id).toEqual(query.substring(1, query.length));
|
||||
}
|
||||
for (let query of ['#block', '#blocks', '#block1#block2'])
|
||||
expect(rrdom.querySelectorAll(query).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('querySelectorAll', () => {
|
||||
expect(rrdom.querySelectorAll('link[rel="stylesheet"]').length).toEqual(
|
||||
1,
|
||||
);
|
||||
const targetLink = rrdom.querySelectorAll(
|
||||
'link[rel="stylesheet"]',
|
||||
)[0] as RRElement;
|
||||
expect(targetLink.tagName).toEqual('LINK');
|
||||
expect(targetLink.getAttribute('rel')).toEqual('stylesheet');
|
||||
|
||||
expect(rrdom.querySelectorAll('.blocks#block1').length).toEqual(1);
|
||||
expect(rrdom.querySelectorAll('.blocks#block3').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RRElement API', () => {
|
||||
let rrdom: RRDocument;
|
||||
beforeAll(() => {
|
||||
// initialize rrdom
|
||||
document.write(getHtml('main.html'));
|
||||
rrdom = new RRDocument();
|
||||
buildFromDom(document, undefined, rrdom);
|
||||
});
|
||||
|
||||
it('can get attribute', () => {
|
||||
expect(
|
||||
rrdom.getElementsByTagName('DIV')[0].getAttribute('class'),
|
||||
).toEqual('blocks blocks1');
|
||||
expect(
|
||||
rrdom.getElementsByTagName('dIv')[0].getAttribute('cLaSs'),
|
||||
).toEqual('blocks blocks1');
|
||||
expect(rrdom.getElementsByTagName('DIV')[0].getAttribute('id')).toEqual(
|
||||
'block1',
|
||||
);
|
||||
expect(rrdom.getElementsByTagName('div')[0].getAttribute('iD')).toEqual(
|
||||
'block1',
|
||||
);
|
||||
expect(
|
||||
rrdom.getElementsByTagName('p')[0].getAttribute('class'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('can set attribute', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
expect(node.getAttribute('class')).toEqual(null);
|
||||
node.setAttribute('class', 'className');
|
||||
expect(node.getAttribute('cLass')).toEqual('className');
|
||||
expect(node.getAttribute('iD')).toEqual(null);
|
||||
node.setAttribute('iD', 'id');
|
||||
expect(node.getAttribute('id')).toEqual('id');
|
||||
});
|
||||
|
||||
it('can remove attribute', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
node.setAttribute('Class', 'className');
|
||||
expect(node.getAttribute('class')).toEqual('className');
|
||||
node.removeAttribute('clAss');
|
||||
expect(node.getAttribute('class')).toEqual(null);
|
||||
node.removeAttribute('Id');
|
||||
expect(node.getAttribute('id')).toEqual(null);
|
||||
});
|
||||
|
||||
it('get nextElementSibling', () => {
|
||||
expect(rrdom.documentElement!.firstElementChild).not.toBeNull();
|
||||
expect(rrdom.documentElement!.firstElementChild!.tagName).toEqual('HEAD');
|
||||
expect(
|
||||
rrdom.documentElement!.firstElementChild!.nextElementSibling,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
rrdom.documentElement!.firstElementChild!.nextElementSibling!.tagName,
|
||||
).toEqual('BODY');
|
||||
expect(
|
||||
rrdom.documentElement!.firstElementChild!.nextElementSibling!
|
||||
.nextElementSibling,
|
||||
).toBeNull();
|
||||
|
||||
expect(rrdom.getElementsByTagName('h1').length).toEqual(2);
|
||||
const element1 = rrdom.getElementsByTagName('h1')[0];
|
||||
const element2 = rrdom.getElementsByTagName('h1')[1];
|
||||
expect(element1.tagName).toEqual('H1');
|
||||
expect(element2.tagName).toEqual('H1');
|
||||
expect(element1.nextElementSibling).toEqual(element2);
|
||||
expect(element2.nextElementSibling).not.toBeNull();
|
||||
expect(element2.nextElementSibling!.id).toEqual('block1');
|
||||
expect(element2.nextElementSibling!.nextElementSibling).toBeNull();
|
||||
|
||||
const node = rrdom.createElement('div');
|
||||
expect(node.nextElementSibling).toBeNull();
|
||||
});
|
||||
|
||||
it('can get CSS style declaration', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
const style = node.style;
|
||||
expect(style).toBeDefined();
|
||||
expect(style.setProperty).toBeDefined();
|
||||
expect(style.removeProperty).toBeDefined();
|
||||
|
||||
node.attributes.style =
|
||||
'color: blue; background-color: red; width: 78%; height: 50vh !important;';
|
||||
expect(node.style.color).toBe('blue');
|
||||
expect(node.style.backgroundColor).toBe('red');
|
||||
expect(node.style.width).toBe('78%');
|
||||
expect(node.style.height).toBe('50vh');
|
||||
});
|
||||
|
||||
it('can set CSS property', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
const style = node.style;
|
||||
style.setProperty('color', 'red');
|
||||
expect(node.attributes.style).toEqual('color: red;');
|
||||
// camelCase style is unacceptable
|
||||
style.setProperty('backgroundColor', 'blue');
|
||||
expect(node.attributes.style).toEqual('color: red;');
|
||||
style.setProperty('height', '50vh', 'important');
|
||||
expect(node.attributes.style).toEqual(
|
||||
'color: red; height: 50vh !important;',
|
||||
);
|
||||
|
||||
// kebab-case
|
||||
style.setProperty('background-color', 'red');
|
||||
expect(node.attributes.style).toEqual(
|
||||
'color: red; height: 50vh !important; background-color: red;',
|
||||
);
|
||||
|
||||
// remove the property
|
||||
style.setProperty('background-color', null);
|
||||
expect(node.attributes.style).toEqual(
|
||||
'color: red; height: 50vh !important;',
|
||||
);
|
||||
});
|
||||
|
||||
it('can remove CSS property', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
node.attributes.style =
|
||||
'color: blue; background-color: red; width: 78%; height: 50vh;';
|
||||
const style = node.style;
|
||||
expect(style.removeProperty('color')).toEqual('blue');
|
||||
expect(node.attributes.style).toEqual(
|
||||
'background-color: red; width: 78%; height: 50vh;',
|
||||
);
|
||||
expect(style.removeProperty('height')).toEqual('50vh');
|
||||
expect(node.attributes.style).toEqual(
|
||||
'background-color: red; width: 78%;',
|
||||
);
|
||||
// kebab-case
|
||||
expect(style.removeProperty('background-color')).toEqual('red');
|
||||
expect(node.attributes.style).toEqual('width: 78%;');
|
||||
style.setProperty('background-color', 'red');
|
||||
expect(node.attributes.style).toEqual(
|
||||
'width: 78%; background-color: red;',
|
||||
);
|
||||
expect(style.removeProperty('backgroundColor')).toEqual('');
|
||||
expect(node.attributes.style).toEqual(
|
||||
'width: 78%; background-color: red;',
|
||||
);
|
||||
// remove a non-exist property
|
||||
expect(style.removeProperty('margin')).toEqual('');
|
||||
});
|
||||
|
||||
it('can parse more inline styles correctly', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
// general
|
||||
node.attributes.style =
|
||||
'display: inline-block; margin: 0 auto; border: 5px solid #BADA55; font-size: .75em; position:absolute;width: 33.3%; z-index:1337; font-family: "Goudy Bookletter 1911", Gill Sans Extrabold, sans-serif;';
|
||||
|
||||
const style = node.style;
|
||||
expect(style.display).toEqual('inline-block');
|
||||
expect(style.margin).toEqual('0px auto');
|
||||
expect(style.border).toEqual('5px solid #bada55');
|
||||
expect(style.fontSize).toEqual('.75em');
|
||||
expect(style.position).toEqual('absolute');
|
||||
expect(style.width).toEqual('33.3%');
|
||||
expect(style.zIndex).toEqual('1337');
|
||||
expect(style.fontFamily).toEqual(
|
||||
'"Goudy Bookletter 1911", Gill Sans Extrabold, sans-serif',
|
||||
);
|
||||
|
||||
// multiple of same property
|
||||
node.attributes.style = 'color:rgba(0,0,0,1);color:white';
|
||||
expect(style.color).toEqual('white');
|
||||
|
||||
// url
|
||||
node.attributes.style =
|
||||
'background-image: url("http://example.com/img.png")';
|
||||
expect(node.style.backgroundImage).toEqual(
|
||||
'url(http://example.com/img.png)',
|
||||
);
|
||||
|
||||
// comment
|
||||
node.attributes.style =
|
||||
'top: 0; /* comment1 */ bottom: /* comment2 */42rem;';
|
||||
expect(node.style.top).toEqual('0px');
|
||||
expect(node.style.bottom).toEqual('42rem');
|
||||
// empty comment
|
||||
node.attributes.style = 'top: /**/0;';
|
||||
expect(node.style.top).toEqual('0px');
|
||||
|
||||
// incomplete
|
||||
node.attributes.style = 'overflow:';
|
||||
expect(node.style.overflow).toEqual('');
|
||||
});
|
||||
|
||||
it('querySelectorAll', () => {
|
||||
const element = rrdom.getElementById('block2')!;
|
||||
expect(element).toBeDefined();
|
||||
expect(element.id).toEqual('block2');
|
||||
|
||||
const result = element.querySelectorAll('div');
|
||||
expect(result.length).toBe(1);
|
||||
expect((result[0]! as RRElement).tagName).toEqual('DIV');
|
||||
expect(element.querySelectorAll('.blocks').length).toEqual(0);
|
||||
|
||||
const element2 = rrdom.getElementById('block1')!;
|
||||
expect(element2).toBeDefined();
|
||||
expect(element2.id).toEqual('block1');
|
||||
expect(element2.querySelectorAll('div').length).toEqual(2);
|
||||
expect(element2.querySelectorAll('.blocks').length).toEqual(1);
|
||||
});
|
||||
|
||||
it('can attach shadow dom', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
expect(node.shadowRoot).toBeNull();
|
||||
node.attachShadow({ mode: 'open' });
|
||||
expect(node.shadowRoot).not.toBeNull();
|
||||
expect(node.shadowRoot!.RRNodeType).toBe(RRNodeType.Element);
|
||||
expect(node.shadowRoot!.tagName).toBe('SHADOWROOT');
|
||||
expect(node.parentNode).toBeNull();
|
||||
});
|
||||
|
||||
it('can insert new child before an existing child', () => {
|
||||
const node = rrdom.createElement('div');
|
||||
const child1 = rrdom.createElement('h1');
|
||||
const child2 = rrdom.createElement('h2');
|
||||
expect(() =>
|
||||
node.insertBefore(node, child1),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode."`,
|
||||
);
|
||||
expect(node.insertBefore(child1, null)).toBe(child1);
|
||||
expect(node.childNodes[0]).toBe(child1);
|
||||
expect(child1.parentNode).toBe(node);
|
||||
expect(child1.parentElement).toBe(node);
|
||||
|
||||
expect(node.insertBefore(child2, child1)).toBe(child2);
|
||||
expect(node.childNodes.length).toBe(2);
|
||||
expect(node.childNodes[0]).toBe(child2);
|
||||
expect(node.childNodes[1]).toBe(child1);
|
||||
expect(child2.parentNode).toBe(node);
|
||||
expect(child2.parentElement).toBe(node);
|
||||
});
|
||||
|
||||
it('style element', () => {
|
||||
expect(rrdom.getElementsByTagName('style').length).not.toEqual(0);
|
||||
expect(rrdom.getElementsByTagName('style')[0].tagName).toEqual('STYLE');
|
||||
const styleElement = rrdom.getElementsByTagName(
|
||||
'style',
|
||||
)[0] as RRStyleElement;
|
||||
expect(styleElement.sheet).toBeDefined();
|
||||
expect(styleElement.sheet!.cssRules).toBeDefined();
|
||||
expect(styleElement.sheet!.cssRules.length).toEqual(5);
|
||||
const rules = styleElement.sheet!.cssRules;
|
||||
expect(rules[0].cssText).toEqual(`h1 {color: 'black';}`);
|
||||
expect(rules[1].cssText).toEqual(`.blocks {padding: 0;}`);
|
||||
expect(rules[2].cssText).toEqual(`.blocks1 {margin: 0;}`);
|
||||
expect(rules[3].cssText).toEqual(
|
||||
`#block1 {width: 100px; height: 200px;}`,
|
||||
);
|
||||
expect(rules[4].cssText).toEqual(`@import url(main.css);`);
|
||||
expect((rules[4] as CSSImportRule).href).toEqual('main.css');
|
||||
|
||||
expect(styleElement.sheet!.insertRule).toBeDefined();
|
||||
const newRule = "p {color: 'black';}";
|
||||
styleElement.sheet!.insertRule(newRule, 5);
|
||||
expect(rules[5].cssText).toEqual(newRule);
|
||||
|
||||
expect(styleElement.sheet!.deleteRule).toBeDefined();
|
||||
styleElement.sheet!.deleteRule(5);
|
||||
expect(rules[5]).toBeUndefined();
|
||||
expect(rules[4].cssText).toEqual(`@import url(main.css);`);
|
||||
});
|
||||
|
||||
it('can create an RRIframeElement', () => {
|
||||
const iframe = rrdom.createElement('iframe');
|
||||
expect(iframe.tagName).toEqual('IFRAME');
|
||||
expect(iframe.width).toEqual('');
|
||||
expect(iframe.height).toEqual('');
|
||||
expect(iframe.contentDocument).toBeDefined();
|
||||
expect(iframe.contentDocument!.childNodes.length).toBe(1);
|
||||
expect(iframe.contentDocument!.documentElement).toBeDefined();
|
||||
expect(iframe.contentDocument!.head).toBeDefined();
|
||||
expect(iframe.contentDocument!.body).toBeDefined();
|
||||
expect(iframe.contentWindow).toBeDefined();
|
||||
expect(iframe.contentWindow!.scrollTop).toEqual(0);
|
||||
expect(iframe.contentWindow!.scrollLeft).toEqual(0);
|
||||
expect(iframe.contentWindow!.scrollTo).toBeDefined();
|
||||
|
||||
// empty parameter and did nothing
|
||||
iframe.contentWindow!.scrollTo();
|
||||
expect(iframe.contentWindow!.scrollTop).toEqual(0);
|
||||
expect(iframe.contentWindow!.scrollLeft).toEqual(0);
|
||||
|
||||
iframe.contentWindow!.scrollTo({ top: 10, left: 20 });
|
||||
expect(iframe.contentWindow!.scrollTop).toEqual(10);
|
||||
expect(iframe.contentWindow!.scrollLeft).toEqual(20);
|
||||
});
|
||||
|
||||
it('should have a RRCanvasElement', () => {
|
||||
const canvas = rrdom.createElement('canvas');
|
||||
expect(canvas.getContext()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getHtml(fileName: string) {
|
||||
const filePath = path.resolve(__dirname, `../../rrdom/test/html/${fileName}`);
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
131
packages/rrdom-nodejs/test/polyfill.test.ts
Normal file
131
packages/rrdom-nodejs/test/polyfill.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { compare } from 'compare-versions';
|
||||
import { RRDocument, RRNode } from '../src/document-nodejs';
|
||||
import {
|
||||
polyfillPerformance,
|
||||
polyfillRAF,
|
||||
polyfillEvent,
|
||||
polyfillNode,
|
||||
polyfillDocument,
|
||||
} from '../src/polyfill';
|
||||
|
||||
describe('polyfill for nodejs', () => {
|
||||
it('should polyfill performance api', () => {
|
||||
if (compare(process.version, 'v16.0.0', '<'))
|
||||
expect(global.performance).toBeUndefined();
|
||||
polyfillPerformance();
|
||||
expect(global.performance).toBeDefined();
|
||||
expect(performance).toBeDefined();
|
||||
expect(performance.now).toBeDefined();
|
||||
expect(performance.now()).toBeCloseTo(
|
||||
require('perf_hooks').performance.now(),
|
||||
1e-10,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not polyfill performance if it already exists', () => {
|
||||
if (compare(process.version, 'v16.0.0', '>=')) {
|
||||
const originalPerformance = global.performance;
|
||||
polyfillPerformance();
|
||||
expect(global.performance).toBe(originalPerformance);
|
||||
}
|
||||
const fakePerformance = (jest.fn() as unknown) as Performance;
|
||||
global.performance = fakePerformance;
|
||||
polyfillPerformance();
|
||||
expect(global.performance).toEqual(fakePerformance);
|
||||
});
|
||||
|
||||
it('should polyfill requestAnimationFrame', () => {
|
||||
expect(global.requestAnimationFrame).toBeUndefined();
|
||||
expect(global.cancelAnimationFrame).toBeUndefined();
|
||||
polyfillRAF();
|
||||
expect(global.requestAnimationFrame).toBeDefined();
|
||||
expect(global.cancelAnimationFrame).toBeDefined();
|
||||
expect(requestAnimationFrame).toBeDefined();
|
||||
expect(cancelAnimationFrame).toBeDefined();
|
||||
|
||||
jest.useFakeTimers();
|
||||
const AnimationTime = 1_000; // target animation time(unit: ms)
|
||||
const startTime = Date.now();
|
||||
let frameCount = 0;
|
||||
const rafCallback1 = () => {
|
||||
const currentTime = Date.now();
|
||||
frameCount++;
|
||||
if (currentTime - startTime < AnimationTime) {
|
||||
requestAnimationFrame(rafCallback1);
|
||||
} else {
|
||||
expect(frameCount).toBeGreaterThanOrEqual(55);
|
||||
expect(frameCount).toBeLessThanOrEqual(65);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(rafCallback1);
|
||||
// Fast-forward until all timers have been executed
|
||||
jest.runAllTimers();
|
||||
|
||||
let rafHandle;
|
||||
const rafCallback2 = () => {
|
||||
rafHandle = requestAnimationFrame(rafCallback2);
|
||||
};
|
||||
rafHandle = requestAnimationFrame(rafCallback2);
|
||||
|
||||
// If this function doesn't work, recursive function will never end.
|
||||
cancelAnimationFrame(rafHandle);
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not polyfill requestAnimationFrame if it already exists', () => {
|
||||
const fakeRequestAnimationFrame = (jest.fn() as unknown) as typeof global.requestAnimationFrame;
|
||||
global.requestAnimationFrame = fakeRequestAnimationFrame;
|
||||
const fakeCancelAnimationFrame = (jest.fn() as unknown) as typeof global.cancelAnimationFrame;
|
||||
global.cancelAnimationFrame = fakeCancelAnimationFrame;
|
||||
polyfillRAF();
|
||||
expect(global.requestAnimationFrame).toBe(fakeRequestAnimationFrame);
|
||||
expect(global.cancelAnimationFrame).toBe(fakeCancelAnimationFrame);
|
||||
});
|
||||
|
||||
it('should polyfill Event type', () => {
|
||||
// if the second version is greater
|
||||
if (compare(process.version, 'v15.0.0', '<'))
|
||||
expect(global.Event).toBeUndefined();
|
||||
polyfillEvent();
|
||||
expect(global.Event).toBeDefined();
|
||||
expect(Event).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not polyfill Event type if it already exists', () => {
|
||||
const fakeEvent = (jest.fn() as unknown) as typeof global.Event;
|
||||
global.Event = fakeEvent;
|
||||
polyfillEvent();
|
||||
expect(global.Event).toBe(fakeEvent);
|
||||
});
|
||||
|
||||
it('should polyfill Node type', () => {
|
||||
expect(global.Node).toBeUndefined();
|
||||
polyfillNode();
|
||||
expect(global.Node).toBeDefined();
|
||||
expect(Node).toBeDefined();
|
||||
expect(Node).toEqual(RRNode);
|
||||
});
|
||||
|
||||
it('should not polyfill Node type if it already exists', () => {
|
||||
const fakeNode = (jest.fn() as unknown) as typeof global.Node;
|
||||
global.Node = fakeNode;
|
||||
polyfillNode();
|
||||
expect(global.Node).toBe(fakeNode);
|
||||
});
|
||||
|
||||
it('should polyfill document object', () => {
|
||||
expect(global.document).toBeUndefined();
|
||||
polyfillDocument();
|
||||
expect(global.document).toBeDefined();
|
||||
expect(document).toBeDefined();
|
||||
expect(document).toBeInstanceOf(RRDocument);
|
||||
});
|
||||
|
||||
it('should not polyfill document object if it already exists', () => {
|
||||
const fakeDocument = (jest.fn() as unknown) as typeof global.document;
|
||||
global.document = fakeDocument;
|
||||
polyfillDocument();
|
||||
expect(global.document).toBe(fakeDocument);
|
||||
});
|
||||
});
|
||||
20
packages/rrdom-nodejs/tsconfig.json
Normal file
20
packages/rrdom-nodejs/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "commonjs",
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "build",
|
||||
"lib": ["es6", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"importsNotUsedAsValues": "error"
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"exclude": ["test"],
|
||||
"include": ["src", "test.d.ts", "../rrweb/src/record/workers/workers.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user