* create rrdom package

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

* fix(rrweb snapshot): type check errors

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

* Set tagNames to upper case by default

This mirrors the `Element.tagName` implementation:

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

* Add workspace file

* VSCode settings for rrdom tests

* Add basic test for RRDocument

* Only setup jest tests for rrdom

* mock Node type and Event type for nodejs environment

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

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

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

* fix: error of unit test for Event polyfill

Since Event class is built in nodejs after v15.0.0

* add a dummy implementation of canvas

* add style element support

* add unit test for style element

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
Lucky Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 044e1c1408
commit dd2cdedcd6
19 changed files with 2566 additions and 7 deletions

View File

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

View File

@@ -0,0 +1,259 @@
/**
* @jest-environment jsdom
*/
import * as fs from 'fs';
import * as path from 'path';
import { RRDocument, RRElement, RRStyleElement } from '../src/document-nodejs';
import { printRRDom } from './util';
describe('RRDocument for nodejs environment', () => {
describe('buildFromDom', () => {
it('should create an RRDocument from a html document', () => {
// setup document
document.write(getHtml('main.html'));
// create RRDocument from document
const rrdoc = new RRDocument();
rrdoc.buildFromDom(document);
expect(printRRDom(rrdoc)).toMatchSnapshot();
});
});
describe('RRDocument API', () => {
let rrdom: RRDocument;
beforeAll(() => {
// initialize rrdom
document.write(getHtml('main.html'));
rrdom = new RRDocument();
rrdom.buildFromDom(document);
});
it('get className', () => {
expect(rrdom.getElementsByTagName('DIV')[0].className).toEqual(
'blocks blocks1',
);
expect(rrdom.getElementsByTagName('DIV')[1].className).toEqual(
'blocks blocks1 :hover',
);
});
it('get id', () => {
expect(rrdom.getElementsByTagName('DIV')[0].id).toEqual('block1');
expect(rrdom.getElementsByTagName('DIV')[1].id).toEqual('block2');
expect(rrdom.getElementsByTagName('DIV')[2].id).toEqual('block3');
});
it('get attribute name', () => {
expect(
rrdom.getElementsByTagName('DIV')[0].getAttribute('class'),
).toEqual('blocks blocks1');
expect(
rrdom.getElementsByTagName('dIv')[0].getAttribute('cLaSs'),
).toEqual('blocks blocks1');
expect(rrdom.getElementsByTagName('DIV')[0].getAttribute('id')).toEqual(
'block1',
);
expect(rrdom.getElementsByTagName('div')[0].getAttribute('iD')).toEqual(
'block1',
);
expect(
rrdom.getElementsByTagName('p')[0].getAttribute('class'),
).toBeNull();
});
it('get firstElementChild', () => {
expect(rrdom.firstElementChild).toBeDefined();
expect(rrdom.firstElementChild.tagName).toEqual('HTML');
const div1 = rrdom.getElementById('block1');
expect(div1).toBeDefined();
expect(div1!.firstElementChild).toBeDefined();
expect(div1!.firstElementChild!.id).toEqual('block2');
const div2 = div1!.firstElementChild;
expect(div2!.firstElementChild!.id).toEqual('block3');
});
it('get nextElementSibling', () => {
expect(rrdom.documentElement.firstElementChild).not.toBeNull();
expect(rrdom.documentElement.firstElementChild!.tagName).toEqual('HEAD');
expect(
rrdom.documentElement.firstElementChild!.nextElementSibling,
).not.toBeNull();
expect(
rrdom.documentElement.firstElementChild!.nextElementSibling!.tagName,
).toEqual('BODY');
expect(
rrdom.documentElement.firstElementChild!.nextElementSibling!
.nextElementSibling,
).toBeNull();
expect(rrdom.getElementsByTagName('h1').length).toEqual(2);
const element1 = rrdom.getElementsByTagName('h1')[0];
const element2 = rrdom.getElementsByTagName('h1')[1];
expect(element1.tagName).toEqual('H1');
expect(element2.tagName).toEqual('H1');
expect(element1.nextElementSibling).toEqual(element2);
expect(element2.nextElementSibling).not.toBeNull();
expect(element2.nextElementSibling!.id).toEqual('block1');
expect(element2.nextElementSibling!.nextElementSibling).toBeNull();
});
it('getElementsByTagName', () => {
for (let tagname of [
'HTML',
'BODY',
'HEAD',
'STYLE',
'META',
'TITLE',
'SCRIPT',
'LINK',
'DIV',
'H1',
'P',
'BUTTON',
'IMG',
'CANVAS',
]) {
const expectedResult = document.getElementsByTagName(tagname).length;
expect(rrdom.getElementsByTagName(tagname).length).toEqual(
expectedResult,
);
expect(
rrdom.getElementsByTagName(tagname.toLowerCase()).length,
).toEqual(expectedResult);
for (let node of rrdom.getElementsByTagName(tagname)) {
expect(node.tagName).toEqual(tagname);
}
}
});
it('getElementsByClassName', () => {
for (let className of [
'blocks',
'blocks1',
':hover',
'blocks1 blocks',
'blocks blocks1',
':hover blocks1',
':hover blocks1 blocks',
':hover blocks1 block',
]) {
const msg = `queried class name: '${className}'`;
expect({
message: msg,
result: rrdom.getElementsByClassName(className).length,
}).toEqual({
message: msg,
result: document.getElementsByClassName(className).length,
});
}
});
it('getElementById', () => {
for (let elementId of ['block1', 'block2', 'block3']) {
expect(rrdom.getElementById(elementId)).not.toBeNull();
expect(rrdom.getElementById(elementId)!.id).toEqual(elementId);
}
for (let elementId of ['block', 'blocks', 'blocks1'])
expect(rrdom.getElementById(elementId)).toBeNull();
});
it('querySelectorAll querying tag name', () => {
expect(rrdom.querySelectorAll('H1')).toHaveLength(2);
expect(rrdom.querySelectorAll('H1')[0]).toBeInstanceOf(RRElement);
expect((rrdom.querySelectorAll('H1')[0] as RRElement).tagName).toEqual(
'H1',
);
expect(rrdom.querySelectorAll('H1')[1]).toBeInstanceOf(RRElement);
expect((rrdom.querySelectorAll('H1')[1] as RRElement).tagName).toEqual(
'H1',
);
});
it('querySelectorAll querying class name', () => {
for (let className of [
'.blocks',
'.blocks1',
'.\\:hover',
'.blocks1.blocks',
'.blocks.blocks1',
'.\\:hover.blocks1',
'.\\:hover.blocks1.blocks',
'.\\:hover.blocks1.block',
]) {
const msg = `queried class name: '${className}'`;
expect({
message: msg,
result: rrdom.querySelectorAll(className).length,
}).toEqual({
message: msg,
result: document.querySelectorAll(className).length,
});
}
for (let element of rrdom.querySelectorAll('.\\:hover')) {
expect(element).toBeInstanceOf(RRElement);
expect((element as RRElement).classList).toContain(':hover');
}
});
it('querySelectorAll querying id', () => {
for (let query of ['#block1', '#block2', '#block3']) {
expect(rrdom.querySelectorAll(query).length).toEqual(1);
const targetElement = rrdom.querySelectorAll(query)[0] as RRElement;
expect(targetElement.id).toEqual(query.substring(1, query.length));
}
for (let query of ['#block', '#blocks', '#block1#block2'])
expect(rrdom.querySelectorAll(query).length).toEqual(0);
});
it('querySelectorAll', () => {
expect(rrdom.querySelectorAll('link[rel="stylesheet"]').length).toEqual(
1,
);
const targetLink = rrdom.querySelectorAll(
'link[rel="stylesheet"]',
)[0] as RRElement;
expect(targetLink.tagName).toEqual('LINK');
expect(targetLink.getAttribute('rel')).toEqual('stylesheet');
expect(rrdom.querySelectorAll('.blocks#block1').length).toEqual(1);
expect(rrdom.querySelectorAll('.blocks#block3').length).toEqual(0);
});
it('style element', () => {
expect(rrdom.getElementsByTagName('style').length).not.toEqual(0);
expect(rrdom.getElementsByTagName('style')[0].tagName).toEqual('STYLE');
const styleElement = rrdom.getElementsByTagName(
'style',
)[0] as RRStyleElement;
expect(styleElement.sheet).toBeDefined();
expect(styleElement.sheet!.cssRules).toBeDefined();
expect(styleElement.sheet!.cssRules.length).toEqual(5);
const rules = styleElement.sheet!.cssRules;
expect(rules[0].cssText).toEqual(`h1 {color: 'black';}`);
expect(rules[1].cssText).toEqual(`.blocks {padding: 0;}`);
expect(rules[2].cssText).toEqual(`.blocks1 {margin: 0;}`);
expect(rules[3].cssText).toEqual(
`#block1 {width: 100px; height: 200px;}`,
);
expect(rules[4].cssText).toEqual(`@import url(main.css);`);
expect((rules[4] as CSSImportRule).href).toEqual('main.css');
expect(styleElement.sheet!.insertRule).toBeDefined();
const newRule = "p {color: 'black';}";
styleElement.sheet!.insertRule(newRule, 5);
expect(rules[5].cssText).toEqual(newRule);
expect(styleElement.sheet!.deleteRule).toBeDefined();
styleElement.sheet!.deleteRule(5);
expect(rules[5]).toBeUndefined();
expect(rules[4].cssText).toEqual(`@import url(main.css);`);
});
});
});
function getHtml(fileName: string) {
const filePath = path.resolve(__dirname, `./html/${fileName}`);
return fs.readFileSync(filePath, 'utf8');
}

View File

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

View File

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

View File

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