Files
cms-website/packages/adapter-wordpress/test/adapter.test.ts
T

567 lines
25 KiB
TypeScript
Raw Normal View History

// Task 6 失败测试(先于实现):适配器全链组装(parseWxrStream → mapItem → sanitizeBodyHtml)
// + 导入前报告聚合(spec §7.7 对齐)。
//
// 全链事件契约:term 旁路直通(terms 是表数据不是内容行,不经映射);item 经映射三分支
// (row/unsupported/error),row 分支在组装层追加清洗(bodyHtml 为空跳过清洗零 diff)。
// changed 语义(Task 5 审查移交钉死):`html !== input` 字符串比较——diffs.length===0
// 不等于未变更(img 自闭合规范化等零 diff 等价变形)。
//
// 手构样本经真实 parseWxrStream 喂入(组装层的输入契约就是 WXR 字节流,不走 mapItem
// 直调捷径——端到端才是本任务的被测对象)。
import { createReadStream } from 'node:fs'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { PassThrough, Readable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { isUniversalRow } from '@sf/contracts'
import { WxrParseError } from '../src/parse/wxr-stream.js'
import {
runWxrAdapter,
type AdapterEvent,
type AdapterTimings,
type RowResult,
} from '../src/adapter.js'
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '../src/report.js'
import {
ADAPTER_VERSION,
MAPPING_VERSION,
PARSER_VERSION,
SANITIZER_VERSION,
} from '../src/versions.js'
const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url))
/** 从字符串构造单 chunk Readable(wxr-stream 测试同款) */
function fromXml(inner: string): Readable {
const s = new PassThrough()
s.end(inner)
return s
}
/** 按字节切块喂入(多 chunk 路径;wxr-stream 测试同款,7 字节必拆多字节字符) */
function fromXmlChunked(xml: string, size = 7): Readable {
return Readable.from(
(function* () {
const buf = Buffer.from(xml, 'utf8')
for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size)
})(),
)
}
/** 模拟文件读取中途 IO 失败:交付一个 chunk 后 destroy(err)(code/path 形态同 fs 错误) */
function ioErrorStream(prefix: string, code: string, path: string): Readable {
let sent = false
return new Readable({
read() {
if (sent) return
sent = true
this.push(prefix)
queueMicrotask(() => {
this.destroy(
Object.assign(new Error(`${code}: no such file or directory, open '${path}'`), {
code,
path,
}),
)
})
},
})
}
/** WXR 骨架(命名空间照 mini.xml;saxes xmlns:false 只认字面限定名,声明仅为形态完整) */
function wxrDoc(inner: string): string {
return (
'<?xml version="1.0" encoding="UTF-8"?>' +
'<rss version="2.0" xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"' +
' xmlns:content="http://purl.org/rss/1.0/modules/content/"' +
' xmlns:dc="http://purl.org/dc/elements/1.0/" xmlns:wp="http://wordpress.org/export/1.2/">' +
`<channel>${inner}</channel></rss>`
)
}
/** 手构 item XML(缺省为最平凡合法值;postId:null = 整个元素缺席 → 解析为 null) */
function itemXml(fields: {
postId?: number | null
postType?: string
status?: string
content?: string
title?: string
metas?: ReadonlyArray<{ key: string; value: string }>
}): string {
const parts: string[] = ['<item>']
parts.push(`<title><![CDATA[${fields.title ?? 'Handmade'}]]></title>`)
if (fields.postId !== null) parts.push(`<wp:post_id>${fields.postId ?? 1}</wp:post_id>`)
parts.push(`<wp:post_type><![CDATA[${fields.postType ?? 'post'}]]></wp:post_type>`)
parts.push(`<wp:status><![CDATA[${fields.status ?? 'publish'}]]></wp:status>`)
if (fields.content !== undefined) {
parts.push(`<content:encoded><![CDATA[${fields.content}]]></content:encoded>`)
}
for (const m of fields.metas ?? []) {
parts.push(
`<wp:postmeta><wp:meta_key><![CDATA[${m.key}]]></wp:meta_key>` +
`<wp:meta_value><![CDATA[${m.value}]]></wp:meta_value></wp:postmeta>`,
)
}
parts.push('</item>')
return parts.join('')
}
async function collect(gen: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[]> {
const out: AdapterEvent[] = []
for await (const ev of gen) out.push(ev)
return out
}
/** 概要签名(按文档序):row:<sourceType>:<sourceId> / term:<id>:<taxonomy> / unsupported / error */
function signature(events: AdapterEvent[]): string[] {
return events.map((ev) => {
switch (ev.kind) {
case 'row':
return `row:${ev.row.sourceType}:${ev.row.sourceId}`
case 'term':
return `term:${ev.term.termId}:${ev.term.taxonomy}`
case 'unsupported':
return `unsupported:${ev.sourceType}`
case 'error':
return `error:${ev.rawLocation}`
}
})
}
function rowEvents(events: AdapterEvent[]): RowResult[] {
return events.filter((ev): ev is RowResult => ev.kind === 'row')
}
/** 手构单 item 样本的唯一 row 事件(非唯一即失败——noUncheckedIndexedAccess 防线) */
function soleRow(events: AdapterEvent[]): RowResult {
const rows = rowEvents(events)
if (rows.length !== 1) throw new Error(`期望唯一 row 事件,实际 ${rows.length} 条`)
return rows[0] as RowResult
}
/** 迭代到错误或完结,保留已交付事件(致命解析路径断言用) */
async function collectUntilError(
gen: AsyncGenerator<AdapterEvent>,
): Promise<{ events: AdapterEvent[]; error: unknown }> {
const events: AdapterEvent[] = []
try {
for await (const ev of gen) events.push(ev)
return { events, error: null }
} catch (e) {
return { events, error: e }
}
}
describe('runWxrAdapter:mini.xml 全链', () => {
it('产出 5 row(post/product/page + 2 attachment)+ 2 term 旁路直通,0 unsupported / 0 error', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
expect(signature(events)).toStrictEqual([
'row:wp:post:101',
'row:wp:product:111',
'row:wp:page:121',
'term:31:category',
'term:32:product_cat',
'row:wp:attachment:201',
'row:wp:attachment:202',
])
})
it('post(101) 端到端:bodyHtml 已清洗(内链/图 URL 原样,img 自闭合规范化),changed=true', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
const ev = rowEvents(events).find((r) => r.row.sourceId === '101')
expect(ev).toBeDefined()
expect(ev?.row.bodyHtml).toBe(
'<p>Body with <a href="/products/fixture-product/">link</a> and ' +
'<img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png" /></p>',
)
// URL 原样(Task 5 移交:路径重写属阶段 3,本层只清洗不搬家)
expect(ev?.row.bodyHtml).toContain('href="/products/fixture-product/"')
expect(ev?.row.bodyHtml).toContain('src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png"')
expect(ev?.changed).toBe(true) // img 自闭合补空格:零 diff 的等价变形也是变更
expect(ev?.sanitizeDiffs).toStrictEqual([]) // mini 正文全白名单内
expect(isUniversalRow(ev?.row)).toBe(true)
})
it('attachment(201/202):bodyHtml 缺席 → 跳过清洗零 diff、changed=false', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
for (const sourceId of ['201', '202']) {
const ev = rowEvents(events).find((r) => r.row.sourceId === sourceId)
expect(ev).toBeDefined()
expect(ev?.row.sourceType).toBe('wp:attachment')
expect('bodyHtml' in (ev?.row ?? {})).toBe(false)
expect(ev?.sanitizeDiffs).toStrictEqual([])
expect(ev?.changed).toBe(false)
}
})
it('term 事件旁路直通:完整 WxrTerm(不经映射、不产 row)', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
const terms = events.filter((ev) => ev.kind === 'term')
expect(terms).toHaveLength(2)
expect(terms[0]).toStrictEqual({
kind: 'term',
term: {
rawLocation: 'wxr://term/1',
termId: 31,
taxonomy: 'category',
slug: 'company',
name: 'Company',
},
})
})
it('纯白名单正文(<p>Product body</p>):changed=false、零 diff(mini product/page 同证)', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
for (const sourceId of ['111', '121']) {
const ev = rowEvents(events).find((r) => r.row.sourceId === sourceId)
expect(ev?.changed).toBe(false)
expect(ev?.sanitizeDiffs).toStrictEqual([])
}
})
})
describe('RowResult 结构与 changed 语义(Task 5 移交钉死)', () => {
it('RowResult = {kind,row,issues,sanitizeDiffs,changed} 五键,issues 为 mapItem+契约复检并集', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
const ev = rowEvents(events).find((r) => r.row.sourceId === '101')
expect(ev).toBeDefined()
expect(Object.keys(ev as object).sort()).toStrictEqual([
'changed', 'issues', 'kind', 'row', 'sanitizeDiffs',
])
expect((ev as RowResult).issues).toStrictEqual([]) // mini 101:无映射 issue、契约复检通过
})
it('changed ≠ diffs.length:img 自闭合规范化零 diff 但 changed=true', async () => {
const events = await collect(
runWxrAdapter(
fromXml(wxrDoc(itemXml({ postId: 301, content: '<img src="https://ok.example/a.png"/>' }))),
{ mapConfig: { locale: 'zh-CN' } },
),
)
const ev = soleRow(events)
expect(ev.sanitizeDiffs).toStrictEqual([]) // 白名单内,零拒绝
expect(ev.row.bodyHtml).toBe('<img src="https://ok.example/a.png" />') // 等价变形
expect(ev.changed).toBe(true) // html !== input
})
})
describe('runWxrAdapterReport:报告结构(spec §7.7 对齐)', () => {
it('mini.xml:版本块居首 + 按类型计数 + 清洗统计 + 空错误清单 + 分段耗时', async () => {
const report = await runWxrAdapterReport(createReadStream(FIXTURE), {
mapConfig: { locale: 'zh-CN' },
})
// 首条版本块(spec §7.7:Snapshot 与三版本;本层无 Snapshot 语境,恒居首键位)
expect(Object.keys(report)[0]).toBe('versions')
expect(report.versions).toStrictEqual({
adapter: ADAPTER_VERSION,
mapping: MAPPING_VERSION,
sanitizer: SANITIZER_VERSION,
parser: expect.any(String),
})
// 按类型计数:changed → Created(将产新修订);无变化 → Skipped(无变化)
expect(report.counts).toStrictEqual({
'wp:post': { created: 1, skipped: 0, failed: 0, unsupported: 0 },
'wp:product': { created: 0, skipped: 1, failed: 0, unsupported: 0 },
'wp:page': { created: 0, skipped: 1, failed: 0, unsupported: 0 },
'wp:attachment': { created: 0, skipped: 2, failed: 0, unsupported: 0 },
})
expect(report.totals).toStrictEqual({ rows: 5, terms: 2, unsupported: 0, failed: 0 })
// 清洗统计:mini 零拒绝;changed 条目数=1(post 101 的 img 规范化)
expect(report.sanitize).toStrictEqual({
byDiffType: { 'removed-tag': 0, 'removed-attr': 0 },
changedRows: 1,
})
expect(report.rowErrors).toStrictEqual([]) // wppop 解析成功(111)不产生行级错误
expect(report.fatalError).toBeNull()
// 耗时分段(Task 5 移交:清洗耗时与解析分开计时)
expect(report.timing.parseMs).toBeGreaterThanOrEqual(0)
expect(report.timing.sanitizeMs).toBeGreaterThanOrEqual(0)
expect(report.timing.totalMs).toBeGreaterThanOrEqual(report.timing.parseMs)
})
it('版本常量语义锚定:三常量非空串、相互独立(未来 bump 不互绑)', async () => {
expect(ADAPTER_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
expect(MAPPING_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
expect(SANITIZER_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
})
})
describe('恶意样本:清洗记账 + 报告 removed-tag 计数 + HTML 渲染转义', () => {
it('bodyHtml 含 <script>:row 照常产出、sanitizeDiffs 记账、报告 removed-tag=1', async () => {
const xml = wxrDoc(
itemXml({ postId: 302, content: '<p>ok</p><script>alert(1)</script>' }),
)
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
const ev = soleRow(events)
expect(ev.row.bodyHtml).toBe('<p>ok</p>') // script 内文随 nonTextTags 语义一并丢弃
expect(ev.changed).toBe(true)
expect(ev.sanitizeDiffs).toStrictEqual([
{ type: 'removed-tag', tag: 'script', excerpt: '<script>' },
])
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.sanitize.byDiffType['removed-tag']).toBe(1)
expect(report.counts['wp:post']).toStrictEqual({ created: 1, skipped: 0, failed: 0, unsupported: 0 })
})
it('行级错误 excerpt 原样进报告对象;renderReportHtml 对 <>&" 全转义', async () => {
const xml = wxrDoc(
itemXml({ postId: 303, content: '<script src="x">evil()</script><p>ok</p>' }),
)
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
// 报告对象内 excerpt 未转义(JSON 语义层保留原文,转义是渲染层职责)
const entry = report.rowErrors.find((e) => e.excerpt !== undefined)
expect(entry).toMatchObject({ rawLocation: 'wxr://item/1', excerpt: '<script src="x">' })
const html = renderReportHtml(report)
expect(html).toContain('&lt;script src=&quot;x&quot;&gt;')
expect(html).not.toContain('<script src="x">')
expect(html).not.toContain('<script') // 静态骨架无 script 标签——动态文本亦无裸漏
// message 同经转义函数(清洗差异 message 携带 <tag> 形态)
expect(html).not.toContain('<script>')
})
it('renderReportHtml:空报告(零行零错误)也可渲染,无 undefined/null 字面量', () => {
const empty: WxrAdapterReport = {
versions: { adapter: ADAPTER_VERSION, mapping: MAPPING_VERSION, sanitizer: SANITIZER_VERSION, parser: 'saxes 6.0.0' },
counts: {},
totals: { rows: 0, terms: 0, unsupported: 0, failed: 0 },
sanitize: { byDiffType: { 'removed-tag': 0, 'removed-attr': 0 }, changedRows: 0 },
rowErrors: [],
rowErrorsTruncated: 0,
timing: { parseMs: 0, sanitizeMs: 0, totalMs: 0 },
fatalError: null,
}
const html = renderReportHtml(empty)
expect(html).toContain('版本')
expect(html).not.toContain('undefined')
expect(html).not.toContain('null')
})
})
describe('unsupported / error 汇聚', () => {
const XML = wxrDoc(
itemXml({ postId: 401, postType: 'nav_menu_item' }) + // 未配置路由 → unsupported
itemXml({ postId: null, postType: 'post' }), // post_id 缺失 → error
)
it('事件流:unsupported(wp:nav_menu_item)与 error(post_id 缺失)逐条产出', async () => {
const events = await collect(runWxrAdapter(fromXml(XML), { mapConfig: { locale: 'zh-CN' } }))
expect(signature(events)).toStrictEqual(['unsupported:wp:nav_menu_item', 'error:wxr://item/2'])
expect(events[0]).toMatchObject({
kind: 'unsupported',
sourceType: 'wp:nav_menu_item',
reason: 'post_type 未配置映射',
rawLocation: 'wxr://item/1',
})
expect(events[1]).toMatchObject({
kind: 'error',
issue: 'post_id 缺失或非法',
rawLocation: 'wxr://item/2',
})
})
it('报告:对应类型计数 + 行级错误(rawLocation 定位)', async () => {
const report = await runWxrAdapterReport(fromXml(XML), { mapConfig: { locale: 'zh-CN' } })
expect(report.counts).toStrictEqual({
'wp:nav_menu_item': { created: 0, skipped: 0, failed: 0, unsupported: 1 },
'wp:post': { created: 0, skipped: 0, failed: 1, unsupported: 0 },
})
expect(report.totals).toStrictEqual({ rows: 0, terms: 0, unsupported: 1, failed: 1 })
expect(report.rowErrors).toStrictEqual([
{ rawLocation: 'wxr://item/1', message: 'wp:nav_menu_item:post_type 未配置映射' },
{ rawLocation: 'wxr://item/2', message: 'post_id 缺失或非法' },
])
})
})
2026-10-21 15:30:42 +08:00
describe('unsupported / error 事件加法式补 postId(Plan 3 Task 1 前置事实 12)', () => {
it('unsupported(nav_menu_item):post_id 存在 → 事件携带 postId(String 化,含负值)', async () => {
const xml = wxrDoc(
itemXml({ postId: 401, postType: 'nav_menu_item' }) + itemXml({ postId: -7, postType: 'custom_block' }),
)
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
expect(events[0]).toMatchObject({
kind: 'unsupported',
sourceType: 'wp:nav_menu_item',
postId: '401',
})
// 负值 postId 与 row.sourceId 同口径(String 原样,'-7' 不归一为 '7')
expect(events[1]).toMatchObject({ kind: 'unsupported', sourceType: 'wp:custom_block', postId: '-7' })
})
it('error(post_id 缺失):postId 键缺席——不硬造 source_id(报告照常按计数归桶)', async () => {
const xml = wxrDoc(itemXml({ postId: null, postType: 'post' }))
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
const ev = events[0]
if (ev === undefined || ev.kind !== 'error') throw new Error('期望 error 事件')
expect('postId' in ev).toBe(false)
expect((ev as { postId?: string }).postId).toBeUndefined()
// 报告聚合不受加法字段影响:failed 计数照常
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.totals).toStrictEqual({ rows: 0, terms: 0, unsupported: 0, failed: 1 })
})
it('row 事件不受影响:RowResult 五键结构不变(postId 只加在 unsupported/error 变体上)', async () => {
const events = await collect(
runWxrAdapter(fromXml(wxrDoc(itemXml({ postId: 402 }))), { mapConfig: { locale: 'zh-CN' } }),
)
const ev = soleRow(events)
expect(Object.keys(ev).sort()).toStrictEqual(['changed', 'issues', 'kind', 'row', 'sanitizeDiffs'])
})
})
describe('wppop 解析失败路径全链贯通(Task 4 已实现,Task 6 验证贯通)', () => {
const RAW = 'not-php-at-all'
it('issues 记账 + extras.phpRaw 保留 + 报告行级错误收录', async () => {
const xml = wxrDoc(itemXml({ postId: 501, metas: [{ key: 'wppop_sections', value: RAW }] }))
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
const ev = soleRow(events) // 失败不崩不丢:row 照常产出
expect(ev.issues.some((i) => i.includes('wppop_sections') && i.includes('phpRaw'))).toBe(true)
expect(ev.row.extras.phpRaw).toBe(RAW)
expect(Object.hasOwn(ev.row.extras, 'wppop_sections')).toBe(false) // 两键互斥不残留混态
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.totals.rows).toBe(1)
expect(report.rowErrors.some((e) => e.message.includes('wppop_sections'))).toBe(true)
})
})
describe('JSON 可序列化(phpValueToJson 防线全链再断言)', () => {
it('mini.xml 报告对象 JSON.stringify → parse 往返等值(无 Map/循环引用)', async () => {
const report = await runWxrAdapterReport(createReadStream(FIXTURE), {
mapConfig: { locale: 'zh-CN' },
})
const json = JSON.stringify(report)
expect(JSON.parse(json)).toStrictEqual(report)
})
it('row 事件(含 wppop 解析产物 extras)逐条可序列化', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
for (const ev of rowEvents(events)) {
expect(() => JSON.stringify(ev)).not.toThrow()
expect(JSON.parse(JSON.stringify(ev) as string).row.sourceId).toBe(ev.row.sourceId)
}
})
})
describe('致命解析错误:流拒绝 + 报告记 fatalError(Task 7 wxr:probe 消费通道)', () => {
const TRUNCATED =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
itemXml({ postId: 601, content: '<p>done before truncation</p>' }) +
'<item><title>never closes'
it('runWxrAdapter:错误前已完成的 row 确定交付,随后以 WxrParseError 拒绝', async () => {
const { events, error } = await collectUntilError(
runWxrAdapter(fromXml(TRUNCATED), { mapConfig: { locale: 'zh-CN' } }),
)
expect(error).toBeInstanceOf(WxrParseError)
expect(rowEvents(events).map((r) => r.row.sourceId)).toStrictEqual(['601'])
})
it('runWxrAdapterReport:不向上抛——fatalError 记账、已完成部分照常聚合', async () => {
const report = await runWxrAdapterReport(fromXml(TRUNCATED), {
mapConfig: { locale: 'zh-CN' },
})
expect(report.fatalError).toContain('WxrParseError')
expect(report.totals.rows).toBe(1)
// 正文纯白名单 → changed=false(Skipped 桶);断言点在"已完成行照常聚合"而非桶别
expect(report.counts['wp:post']).toMatchObject({ skipped: 1 })
})
})
describe('IO 错误(Node 系统错误)归入 fatalError(审查 Fix 1)', () => {
it('流中途 ENOENT 形态错误:报告不抛、fatalError 含 code 与 path', async () => {
const report = await runWxrAdapterReport(
ioErrorStream('<?xml version="1.0"?><rss><channel>', 'ENOENT', 'D:\\wxr\\missing.xml'),
{ mapConfig: { locale: 'zh-CN' } },
)
expect(report.fatalError).toContain('ENOENT')
expect(report.fatalError).toContain('missing.xml')
// 已聚合部分照常保留(错误发生于首个事件完成前——零行零错误)
expect(report.totals.rows).toBe(0)
})
it('无 code 的程序 bug(TypeError):原样上抛,不静默记入报告', async () => {
let sent = false
const buggy = new Readable({
read() {
if (sent) return
sent = true
this.push('<?xml version="1.0"?>')
queueMicrotask(() => this.destroy(new TypeError('program bug')))
},
})
await expect(
runWxrAdapterReport(buggy, { mapConfig: { locale: 'zh-CN' } }),
).rejects.toThrow(TypeError)
})
})
describe('rowErrors 封顶(审查 Fix 2:超出部分只计数不固化)', () => {
it('对抗性输入 >10_000 差异:数组截断至上限、truncated 计数准确、清洗统计不截断', async () => {
const diffCount = 10_050
const xml = wxrDoc(
itemXml({ postId: 901, content: '<script></script>'.repeat(diffCount) }),
)
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.sanitize.byDiffType['removed-tag']).toBe(diffCount) // 统计维度完整
expect(report.sanitize.changedRows).toBe(1)
expect(report.rowErrors).toHaveLength(10_000)
expect(report.rowErrorsTruncated).toBe(diffCount - 10_000)
// 截断不破坏对象可序列化性
expect(JSON.parse(JSON.stringify(report) as string).rowErrorsTruncated).toBe(50)
})
})
describe('timings 注入契约(审查 Fix 3:分段计时侧信道)', () => {
it('多 chunk 流消费完成后:parseMs>0、sanitizeMs>=0(外部注入对象被原地累计)', async () => {
const timings: AdapterTimings = { parseMs: 0, sanitizeMs: 0 }
const xml = wxrDoc(itemXml({ postId: 801, content: '<p>body</p><script>x</script>' }))
const events = await collect(
runWxrAdapter(fromXmlChunked(xml), { mapConfig: { locale: 'zh-CN' }, timings }),
)
expect(rowEvents(events)).toHaveLength(1)
expect(timings.parseMs).toBeGreaterThan(0) // 解析真实发生且被计时(await 窗口累计)
expect(timings.sanitizeMs).toBeGreaterThanOrEqual(0) // bodyHtml 非空 → 清洗计时分支已走
})
})
describe('纯 term 流(审查 Fix 4:无 item 输入的报告形态)', () => {
it('仅 wp:term:counts 空对象、rows=0、terms=2、无行级错误', async () => {
const xml = wxrDoc(
'<wp:term><wp:term_id>31</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy>' +
'<wp:term_slug>company</wp:term_slug><wp:term_name>Company</wp:term_name></wp:term>' +
'<wp:term><wp:term_id>32</wp:term_id><wp:term_taxonomy>product_cat</wp:term_taxonomy>' +
'<wp:term_slug>pipe-fittings</wp:term_slug><wp:term_name>Pipe Fittings</wp:term_name></wp:term>',
)
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.counts).toStrictEqual({})
expect(report.totals).toStrictEqual({ rows: 0, terms: 2, unsupported: 0, failed: 0 })
expect(report.rowErrors).toStrictEqual([])
expect(report.fatalError).toBeNull()
})
})
describe('PARSER_VERSION 漂移保险(审查 Fix 5:fail loud)', () => {
it('声明主版本 === 实际安装的 saxes 主版本(升级依赖而漏改常量 → 此处爆红)', () => {
const req = createRequire(import.meta.url)
const installed = (req('saxes/package.json') as { version: string }).version
const declared = PARSER_VERSION.match(/^saxes (\d+)\./)
expect(declared).not.toBeNull()
expect(Number(declared?.[1])).toBe(Number(installed.split('.')[0]))
})
})