Files
cms-website/packages/adapter-wordpress/test/adapter.test.ts
T
浅念 073b9ca39c fix(adapter-wp): IO 错误归入 fatalError;rowErrors 封顶;timings 契约与纯 term 流测试;PARSER 版本漂移保险
审查修复(Task 6 质量审查 5 项):
- Fix 1:Node 系统错误(Error 且 code 字符串——ENOENT/EACCES 等 IO 形态)与 WxrError
  同路记入 fatalError(message 含 code 与 path),报告不上抛保留已聚合部分;无 code 的
  程序 bug 仍原样上抛;模块头契约注释同步修正
- Fix 2:rowErrors 固化封顶 10_000 条 + rowErrorsTruncated 计数(超出只计数不固化,
  防对抗性输入使报告体积失去上界;counts/byDiffType 统计维度不受截断影响);HTML
  渲染追加截断说明行
- Fix 3:timings 注入契约测试(多 chunk 流消费完成后外部对象 parseMs>0/sanitizeMs>=0)
- Fix 4:纯 wp:term 流测试(counts 空对象/rows=0/terms 计数)
- Fix 5:PARSER_VERSION 漂移保险(createRequire 读 saxes/package.json,主版本不一致
  即爆红,fail loud)

247 → 253 测试全绿;tsc/lint/build 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 12:04:15 +08:00

531 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 缺失或非法' },
])
})
})
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]))
})
})