2026-10-21 11:44:55 +08:00
|
|
|
|
// 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'
|
2026-10-21 12:04:15 +08:00
|
|
|
|
import { createRequire } from 'node:module'
|
2026-10-21 11:44:55 +08:00
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-10-21 12:04:15 +08:00
|
|
|
|
import { PassThrough, Readable } from 'node:stream'
|
2026-10-21 11:44:55 +08:00
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
|
|
import { isUniversalRow } from '@sf/contracts'
|
|
|
|
|
|
import { WxrParseError } from '../src/parse/wxr-stream.js'
|
2026-10-21 12:04:15 +08:00
|
|
|
|
import {
|
|
|
|
|
|
runWxrAdapter,
|
|
|
|
|
|
type AdapterEvent,
|
|
|
|
|
|
type AdapterTimings,
|
|
|
|
|
|
type RowResult,
|
|
|
|
|
|
} from '../src/adapter.js'
|
2026-10-21 11:44:55 +08:00
|
|
|
|
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '../src/report.js'
|
2026-10-21 12:04:15 +08:00
|
|
|
|
import {
|
|
|
|
|
|
ADAPTER_VERSION,
|
|
|
|
|
|
MAPPING_VERSION,
|
|
|
|
|
|
PARSER_VERSION,
|
|
|
|
|
|
SANITIZER_VERSION,
|
|
|
|
|
|
} from '../src/versions.js'
|
2026-10-21 11:44:55 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 12:04:15 +08:00
|
|
|
|
/** 按字节切块喂入(多 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,
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 11:44:55 +08:00
|
|
|
|
/** 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('<script src="x">')
|
|
|
|
|
|
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: [],
|
2026-10-21 12:04:15 +08:00
|
|
|
|
rowErrorsTruncated: 0,
|
2026-10-21 11:44:55 +08:00
|
|
|
|
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'])
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-21 11:44:55 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-10-21 12:04:15 +08:00
|
|
|
|
|
|
|
|
|
|
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]))
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|