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>
This commit is contained in:
浅念
2026-10-21 12:04:15 +08:00
parent 6573b8b326
commit 073b9ca39c
2 changed files with 182 additions and 15 deletions
+128 -3
View File
@@ -9,14 +9,25 @@
// 手构样本经真实 parseWxrStream 喂入(组装层的输入契约就是 WXR 字节流,不走 mapItem
// 直调捷径——端到端才是本任务的被测对象)。
import { createReadStream } from 'node:fs'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { PassThrough, type Readable } from 'node:stream'
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 RowResult } from '../src/adapter.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, SANITIZER_VERSION } from '../src/versions.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))
@@ -27,6 +38,36 @@ function fromXml(inner: string): Readable {
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 (
@@ -297,6 +338,7 @@ describe('恶意样本:清洗记账 + 报告 removed-tag 计数 + HTML 渲染
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,
}
@@ -403,3 +445,86 @@ describe('致命解析错误:流拒绝 + 报告记 fatalError(Task 7 wxr:pro
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]))
})
})