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:
@@ -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]))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user