// 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 ( '' + '' + `${inner}` ) } /** 手构 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[] = [''] parts.push(`<![CDATA[${fields.title ?? 'Handmade'}]]>`) if (fields.postId !== null) parts.push(`${fields.postId ?? 1}`) parts.push(``) parts.push(``) if (fields.content !== undefined) { parts.push(``) } for (const m of fields.metas ?? []) { parts.push( `` + ``, ) } parts.push('') return parts.join('') } async function collect(gen: AsyncGenerator): Promise { const out: AdapterEvent[] = [] for await (const ev of gen) out.push(ev) return out } /** 概要签名(按文档序):row:: / term:: / 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, ): 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( '

Body with link and ' + '

', ) // 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('纯白名单正文(

Product body

):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: '' }))), { mapConfig: { locale: 'zh-CN' } }, ), ) const ev = soleRow(events) expect(ev.sanitizeDiffs).toStrictEqual([]) // 白名单内,零拒绝 expect(ev.row.bodyHtml).toBe('') // 等价变形 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 含 ' }), ) const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })) const ev = soleRow(events) expect(ev.row.bodyHtml).toBe('

ok

') // script 内文随 nonTextTags 语义一并丢弃 expect(ev.changed).toBe(true) expect(ev.sanitizeDiffs).toStrictEqual([ { type: 'removed-tag', tag: 'script', excerpt: '

ok

' }), ) 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: ''.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: '

body

' })) 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( '31category' + 'companyCompany' + '32product_cat' + 'pipe-fittingsPipe Fittings', ) 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])) }) })