534 lines
19 KiB
TypeScript
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');
|
|
}
|