From e9cf631934bd6db4971619e3c1aa0860fa8fc4c0 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] replace script tag with noscript and inline the states of form field components --- src/rebuild.ts | 13 +++- src/snapshot.ts | 35 +++++++++- src/types.ts | 2 +- test/html/with-script.html | 18 +++++ test/index.ts | 70 -------------------- test/integration.ts | 132 +++++++++++++++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 75 deletions(-) create mode 100644 test/html/with-script.html delete mode 100644 test/index.ts create mode 100644 test/integration.ts diff --git a/src/rebuild.ts b/src/rebuild.ts index 3dee6026..f6be61ca 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -11,11 +11,20 @@ function buildNode(n: serializedNodeWithId): Node | null { n.systemId, ); case NodeType.Element: - const node = document.createElement(n.tagName); + const tagName = n.tagName === 'script' ? 'noscript' : n.tagName; + const node = document.createElement(tagName); for (const name in n.attributes) { if (n.attributes.hasOwnProperty(name)) { + let value = n.attributes[name]; + value = typeof value === 'boolean' ? '' : value; + // textarea hack + if (n.tagName === 'textarea' && name === 'value') { + const child = document.createTextNode(value); + node.appendChild(child); + continue; + } try { - node.setAttribute(name, n.attributes[name]); + node.setAttribute(name, value); } catch (error) { // skip invalid attribute } diff --git a/src/snapshot.ts b/src/snapshot.ts index ce805da5..c6f4eb60 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -11,6 +11,10 @@ function genId(): number { return _id++; } +function resetId() { + _id = 1; +} + function serializeNode(n: Node): serializedNode | false { switch (n.nodeType) { case n.DOCUMENT_NODE: @@ -31,6 +35,28 @@ function serializeNode(n: Node): serializedNode | false { for (const { name, value } of Array.from((n as HTMLElement).attributes)) { attributes[name] = value; } + if ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' + ) { + const value = (n as HTMLInputElement | HTMLTextAreaElement).value; + if ( + attributes.type !== 'radio' && + attributes.type !== 'checkbox' && + value + ) { + attributes.value = value; + } else if ((n as HTMLInputElement).checked) { + attributes.checked = (n as HTMLInputElement).checked; + } + } + if (tagName === 'option') { + const selectValue = (n as HTMLOptionElement).parentElement; + if (attributes.value === (selectValue as HTMLSelectElement).value) { + attributes.selected = (n as HTMLOptionElement).selected; + } + } return { type: NodeType.Element, tagName, @@ -65,7 +91,7 @@ function serializeNode(n: Node): serializedNode | false { } } -function snapshot(n: Node): serializedNodeWithId | null { +function _snapshot(n: Node): serializedNodeWithId | null { const _serializedNode = serializeNode(n); if (!_serializedNode) { // TODO: dev only @@ -80,10 +106,15 @@ function snapshot(n: Node): serializedNodeWithId | null { serializedNode.type === NodeType.Element ) { for (const childN of Array.from(n.childNodes)) { - serializedNode.childNodes.push(snapshot(childN)); + serializedNode.childNodes.push(_snapshot(childN)); } } return serializedNode; } +function snapshot(n: Node): serializedNodeWithId | null { + resetId(); + return _snapshot(n); +} + export default snapshot; diff --git a/src/types.ts b/src/types.ts index 25fc9962..17fc0e42 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,7 +20,7 @@ export type documentTypeNode = { }; export type attributes = { - [key: string]: string; + [key: string]: string | boolean; }; export type elementNode = { type: NodeType.Element; diff --git a/test/html/with-script.html b/test/html/with-script.html new file mode 100644 index 00000000..e3598090 --- /dev/null +++ b/test/html/with-script.html @@ -0,0 +1,18 @@ + + + + + + + + with script + + + + + + + + \ No newline at end of file diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index a9fba369..00000000 --- a/test/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import 'mocha'; -import mochaDom = require('mocha-jsdom'); -import { expect } from 'chai'; -import * as fs from 'fs'; -import * as path from 'path'; -import { JSDOM } from 'jsdom'; -import { snapshot, rebuild } from '../src'; - -const htmlFolder = path.join(__dirname, 'html'); -const htmls = fs.readdirSync(htmlFolder).map(filePath => { - return { - filePath, - content: fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'), - }; -}); - -describe('integration tests', () => { - mochaDom({ url: 'http://localhost' }); - - it('will snapshot document type', () => { - const raw = ''; - const dom = new JSDOM(raw); - const snap = snapshot(dom.window.document); - expect(snap).to.deep.equal({ - type: 0, - childNodes: [ - { - type: 2, - tagName: 'html', - attributes: {}, - childNodes: [ - { - type: 2, - tagName: 'head', - attributes: {}, - childNodes: [], - id: 3, - }, - { - type: 2, - tagName: 'body', - attributes: {}, - childNodes: [], - id: 4, - }, - ], - id: 2, - }, - ], - id: 1, - }); - }); - - it('will not throw error with invalid attribute', () => { - const raw = ``; - const dom = new JSDOM(raw); - expect(() => rebuild(snapshot(dom.window.document))).not.to.throw(); - }); - - for (const html of htmls) { - it('[html file]:' + html.filePath, () => { - const dom = new JSDOM(html.content); - const snap = snapshot(dom.window.document); - const rebuildDom = rebuild(snap); - expect((rebuildDom as Document).documentElement.outerHTML).to.equal( - dom.window.document.documentElement.outerHTML, - ); - }); - } -}); diff --git a/test/integration.ts b/test/integration.ts new file mode 100644 index 00000000..00375d0d --- /dev/null +++ b/test/integration.ts @@ -0,0 +1,132 @@ +import 'mocha'; +import mochaDom = require('mocha-jsdom'); +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import { JSDOM } from 'jsdom'; +import { snapshot, rebuild } from '../src'; + +const htmlFolder = path.join(__dirname, 'html'); +const htmls = fs.readdirSync(htmlFolder).map(filePath => { + return { + filePath, + content: fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'), + }; +}); + +describe('integration tests', () => { + mochaDom({ url: 'http://localhost' }); + + for (const html of htmls) { + it('[html file]:' + html.filePath, () => { + const dom = new JSDOM(html.content); + const snap = snapshot(dom.window.document); + const rebuildDom = rebuild(snap); + const htmlStr = dom.window.document.documentElement.outerHTML + .replace(/') + .replace(//g, '') + .replace(/<\/script>/g, ''); + expect((rebuildDom as Document).documentElement.outerHTML).to.equal( + htmlStr, + ); + }); + } + + it('will snapshot document type', () => { + const raw = ''; + const dom = new JSDOM(raw); + const snap = snapshot(dom.window.document); + expect(snap).to.deep.equal({ + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + id: 3, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + id: 4, + }, + ], + id: 2, + }, + ], + id: 1, + }); + }); + + it('will not throw error with invalid attribute', () => { + const raw = ``; + const dom = new JSDOM(raw); + expect(() => rebuild(snapshot(dom.window.document))).not.to.throw(); + }); + + it('will inline text input value', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('input').value = '1'; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( + '', + ); + }); + + it('will inline radio input value', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('input').checked = true; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( + '', + ); + }); + + it('will inline checkbox input value', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('input').checked = true; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( + '', + ); + }); + + it('will inline textarea value into text node', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('textarea').value = '1234'; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect( + (rebuildDom as Document).querySelector('textarea').outerHTML, + ).to.equal(''); + }); + + it('will inline options state', () => { + const raw = ` + + `; + const dom = new JSDOM(raw); + dom.window.document.querySelector('select').value = '2'; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('select').outerHTML).to.equal( + ``, + ); + }); +});