Files
rrweb/packages/rrdom/test/virtual-dom.test.ts
2026-04-01 12:00:00 +08:00

534 lines
19 KiB
TypeScript

/**
* @jest-environment jsdom
*/
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
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,
BaseRRNode as RRNode,
} from '../src';
import { compileTSCode } from './utils';
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', () => {
jest.setTimeout(60_000);
let mirror: Mirror;
beforeEach(() => {
mirror = new Mirror();
});
describe('create a RRNode from a real Node', () => {
it('should support quicksmode documents', () => {
// separate 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(-2);
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(-2);
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(-3);
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(-3);
// 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(-4);
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(-4);
// 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(-5);
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(-5);
// 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(-6);
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(-6);
// 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(-7);
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(-7);
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(-2);
expect(mirror.getId(iframe.contentDocument)).toEqual(-2);
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(-2);
expect(mirror.getId(div.shadowRoot)).toEqual(-2);
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();
code = await compileTSCode(path.resolve(__dirname, '../src/index.ts'));
});
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 = 2; 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(-2);
expect(dom.unserializedId).toBe(-3);
expect(dom.close());
expect(dom.open());
expect(dom.childNodes.length).toEqual(0);
expect(dom.unserializedId).toBe(-2);
});
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');
}