Files
blog-app/docs/escape-html-behavior.test.mjs
2026-08-17 17:11:27 +08:00

71 lines
2.4 KiB
JavaScript

/**
* OPT-6 行为对齐测试: 对比 ai-panel.js 原 escapeHtml (div.textContent + innerHTML)
* 与 utils.js 主实现 (replace 链).
*
* 说明: 原实现依赖浏览器 DOM, Node 无法直接调用, 这里用最小 DOM stub
* 复刻 textContent -> innerHTML 的转义语义 (只转义 & < >, 不转义引号).
* utils.js 实现是其严格超集 (额外转义 " '), 因此逐字符对比断言:
* 对同一输入, utils 输出 反解实体后 必须与 stub 输出 反解实体后 一致,
* 且 utils 输出本身满足 5 个字符的转义期望.
*
* 运行: node --test docs/escape-html-behavior.test.mjs
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
/* utils.js 模块顶层有 document.addEventListener 副作用, Node 下需 stub */
globalThis.document = { addEventListener() {} };
const { escapeHtml } = await import('../static/js/utils.js');
/* 复刻 ai-panel.js 原实现的最小 DOM stub */
function legacyEscapeHtml(text) {
const div = {
_text: '',
set textContent(v) { this._text = String(v); },
get innerHTML() {
return this._text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
},
};
div.textContent = text == null ? '' : String(text);
return div.innerHTML;
}
/* 反解 HTML 实体, 用于跨实现等价比较 */
function unescape(s) {
return s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&#x27;/g, "'")
.replace(/&amp;/g, '&');
}
const cases = [
['<script>alert(1)</script>', '&lt;script&gt;alert(1)&lt;/script&gt;'],
['a & b', 'a &amp; b'],
['"quote"', '&quot;quote&quot;'],
["'apos'", '&#39;apos&#39;'],
['<img onerror="x">', '&lt;img onerror=&quot;x&quot;&gt;'],
];
for (const [input, expected] of cases) {
test(`utils.js escapeHtml: ${JSON.stringify(input)}`, () => {
assert.equal(escapeHtml(input), expected);
});
test(`等价性 (utils ⊇ legacy): ${JSON.stringify(input)}`, () => {
const legacy = legacyEscapeHtml(input);
const unified = escapeHtml(input);
assert.equal(unescape(unified), unescape(legacy));
});
}
test('null/undefined 输入行为一致', () => {
assert.equal(escapeHtml(null), legacyEscapeHtml(null));
assert.equal(escapeHtml(undefined), legacyEscapeHtml(undefined));
});