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

* rrdom: add a diff function for properties

* implement diffChildren function and unit tests

* finish basic functions of diff algorithm

* fix several bugs in the diff algorithm

* replace the virtual parent optimization in applyMutation()

* fix: moveAndHover after the diff algorithm is executed

* replace virtual style map with rrdom

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

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

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

* fix: svg viewBox attribute doesn't work

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

* feat: replace treeIndex optimization with rrdom

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

* feat: add iframe support

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

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

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

* add test for fast-forward scroll events

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

* code style tweak

* fix: enable to diff iframe elements

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

* try to fix build error of rrweb-player

* correct the attributes definition in rrdom

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

* add support for shadow dom

* add support for MediaInteraction

* add canvas support

* fix unit test error in rrdom

* add support for Text, Comment

* try to refactor RRDom

* refactor RRDom to reduce duplicate code

* rename document-browser to virtual-dom

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

* Merge branch 'master' into virtual-dom

* add more test for virtual-dom.ts

* use cssstyle in document-nodejs

* fix: bundle error

* improve document-nodejs

* enable to diff scroll positions of an element

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

* revert unknown change

* improve the css style parser for comments

* improve code style

* update typings

* add handling for the case where legacy_missingNodeRetryMap is not empty

* only import types from rrweb into rrdom

* Apply suggestions from code review

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

* Apply suggestions from code review

* fix building error in rrweb

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

* fix rrweb test error and bump up other packages

* add support for custom property of css styles

* add a switch for virtual-dom optimization

* Apply suggestions from code review

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

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

* apply review suggestions

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

* tweak the diff algorithm

* add description of the flag useVirtualDom and remove outdated logConfig

* Remove console.log

* Contain changes to document

* Upgrade rollup to 2.70.2

* Revert "Upgrade rollup to 2.70.2"

This reverts commit b1be81a2a76565935c9dc391f31beb7f64d25956.

* Fix type checking rrdom

* Fix typing error while bundling

* Fix tslib error on build

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

* Increase memory limit for rollup

* Use esbuild for bundling

Speeds up bundling significantly

* Avoid circular dependencies and import un-bundled rrdom

* Fix imports

* Revert back to pre-esbuild

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

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

* Speed up bundling and use less memory

This fixes the out of memory errors happening while bundling

* remove __sn from rrdom

* fix typo

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

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

* improve test cases

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

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

related commit 0f6729d27a323260b36fbe79485a86715c0bc98a

* improve import code structure

Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
This commit is contained in:
Justin Halsall
2022-05-12 06:01:13 +02:00
committed by GitHub
parent 69499be6e2
commit de755ae577
99 changed files with 7087 additions and 2821 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>shadow dom</title>
</head>
<body>
<div>
<template shadowroot="open">
<span> shadow dom one </span>
<div>
<template shadowroot="open">
<span> shadow dom two </span>
</template>
</div>
</template>
</div>
</body>
</html>

View File

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

View File

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

View File

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