feat(adapter-wp): 全链组装与导入前报告
- src/versions.ts:ADAPTER/MAPPING/SANITIZER_VERSION 三常量(bump 语义 JSDoc 冻结: 映射表/清洗白名单实质变更才 bump 对应项,三者独立演进)+ PARSER_VERSION(saxes 硬编码) - src/adapter.ts:runWxrAdapter 串联 parseWxrStream→mapItem→sanitizeBodyHtml 为 AsyncGenerator<AdapterEvent>;term 旁路直通;bodyHtml 为空跳过清洗零 diff; changed 用 html!==input(Task 5 移交:diffs.length===0 ≠ 未变更);validateUniversalRow 加法式契约复检(不动 contracts);解析/清洗分段计时(parser.next() await 窗口归属解析) - src/report.ts:runWxrAdapterReport 聚合(首条版本块/按 sourceType Created-Skipped(无 变化)-Failed-Unsupported/清洗统计按 diff.type+changedRows/行级错误 rawLocation+message +excerpt/致命 WxrError 记 fatalError 不上抛——wxr:probe 消费通道);renderReportHtml 所有动态文本经 escapeHtml(<>&"',Task 5 移交的标记注入面防线) - test/adapter.test.ts:19 用例(mini 全链/changed 语义/报告结构/恶意样本转义/ unsupported-error 汇聚/wppop 失败贯通/JSON 可序列化/致命解析错误交付确定性) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// 适配器全链组装(Plan 2 Task 6):parseWxrStream → mapItem → sanitizeBodyHtml 串联为
|
||||
// AsyncGenerator<AdapterEvent>。三段件均为既有纯函数/纯流,本模块只做组装与计时,
|
||||
// 不引入新的业务判定。
|
||||
//
|
||||
// 事件契约(spec §7.7 / Plan 2 移交):
|
||||
// - term 旁路直通:terms 是表数据不是内容行,不经映射不经清洗,原样转发;
|
||||
// - item 经 mapItem 三分支:row → 组装层追加清洗(bodyHtml 为空时跳过清洗零 diff);
|
||||
// unsupported / error 原样透传(error 分支补 sourceType 报告锚点,见下);
|
||||
// - changed 语义(Task 5 审查移交钉死):`html !== input` 字符串比较——diffs.length===0
|
||||
// 不等于未变更(img 自闭合规范化等零 diff 等价变形),报告层"无变化 Skipped"以此为准。
|
||||
//
|
||||
// 计时分段(Task 5 移交:解析与清洗分开计时):手动泵 parser.next(),await 窗口内的
|
||||
// 时间全部归属解析(parseWxrStream 是拉取式生成器,其全部工作都发生在 next() 期间;
|
||||
// map/sanitize 发生在两次 next() 之间的循环体内),sanitizeBodyHtml 调用单独累计。
|
||||
// 经可选 timings 累计对象侧信道输出(消费完成后读取——生成器无法附带返回元数据)。
|
||||
|
||||
import type { Readable } from 'node:stream'
|
||||
import { isUniversalRow, type UniversalRow } from '@sf/contracts'
|
||||
import { parseWxrStream, type WxrEvent, type WxrItem, type WxrStreamOptions, type WxrTerm } from './parse/wxr-stream.js'
|
||||
import { mapItem, type MapConfig } from './map/item-to-row.js'
|
||||
import { sanitizeBodyHtml, type SanitizeDiff } from './sanitize/html.js'
|
||||
import type { WxrLimits } from './limits.js'
|
||||
|
||||
/** row 事件:映射产物 + 清洗结果(issues 为 mapItem issues 与契约复检 issues 的并集,文档序) */
|
||||
export interface RowResult {
|
||||
kind: 'row'
|
||||
row: UniversalRow
|
||||
/** 非致命问题清单(不阻碍 row 产出;报告层逐条收录为行级错误) */
|
||||
issues: string[]
|
||||
/** 清洗差异(文档序);bodyHtml 为空(跳过清洗)时恒为空数组 */
|
||||
sanitizeDiffs: SanitizeDiff[]
|
||||
/** `html !== input`:清洗输出与输入原文的字符串比较(≠ diffs.length>0) */
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 全链事件判别联合。unsupported/error 分支与 mapItem 的 MappedResult 同形,仅 error
|
||||
* 分支补 `sourceType`(`wp:${postType}` 报告锚点:post_id 缺失时 mapItem 拒猜身份,
|
||||
* 但 post_type 本身仍可作计数归桶依据——命名约定同 unsupported 分支,仅为报告可读性)。
|
||||
*/
|
||||
export type AdapterEvent =
|
||||
| RowResult
|
||||
| { kind: 'unsupported'; sourceType: string; reason: string; rawLocation: string }
|
||||
| { kind: 'error'; sourceType: string; issue: string; rawLocation: string }
|
||||
| { kind: 'term'; term: WxrTerm }
|
||||
|
||||
/** 分段计时累计(可注入对象,运行中被本模块累加;消费完成后读取终值) */
|
||||
export interface AdapterTimings {
|
||||
/** 解析耗时(parser.next() await 窗口累计,毫秒) */
|
||||
parseMs: number
|
||||
/** 清洗耗时(sanitizeBodyHtml 调用累计,毫秒;不含映射) */
|
||||
sanitizeMs: number
|
||||
}
|
||||
|
||||
/** runWxrAdapter 选项 */
|
||||
export interface AdapterOptions {
|
||||
/** 透传 parseWxrStream 的限额部分覆盖 */
|
||||
limits?: Partial<WxrLimits>
|
||||
/** 透传 parseWxrStream 的可注入时钟(time 预算测试用;非计时用途) */
|
||||
now?: () => number
|
||||
/** mapItem 配置;缺省 { locale: '' }(locale 空串与否由调用方自证) */
|
||||
mapConfig?: MapConfig
|
||||
/** 计时累计对象(缺省内部自建;报告层注入以在消费后读取分段耗时) */
|
||||
timings?: AdapterTimings
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约复检(Plan 1 移交的"加法式"检查):复用 isUniversalRow 守卫 + 浅层不变量复检,
|
||||
* 返回 issue 清单(空数组=通过)。不修改 contracts 包、不猜修复——发现即报告。
|
||||
* 与 mapItem 内部守卫的关系:mapItem 产出时自证(测试钉死),本函数在组装层再复检,
|
||||
* 防御未来映射层变更引入的回归(两道闸,加法不替代)。
|
||||
*/
|
||||
export function validateUniversalRow(row: UniversalRow): string[] {
|
||||
const issues: string[] = []
|
||||
if (!isUniversalRow(row)) {
|
||||
issues.push('契约守卫 isUniversalRow 未通过(row 结构不完整)')
|
||||
return issues
|
||||
}
|
||||
if (row.sourceType === '') issues.push('sourceType 为空串')
|
||||
if (row.sourceId === '') issues.push('sourceId 为空串')
|
||||
if (row.rawLocation === '') issues.push('rawLocation 为空串')
|
||||
row.taxonomyRefs.forEach((ref, i) => {
|
||||
if (ref.taxonomy === '' || ref.sourceTermId === '') {
|
||||
issues.push(`taxonomyRefs[${i}] 含空锚点(taxonomy/sourceTermId)`)
|
||||
}
|
||||
})
|
||||
row.attachmentRefs.forEach((ref, i) => {
|
||||
if (ref.role === '') issues.push(`attachmentRefs[${i}] role 为空串`)
|
||||
if (ref.sourceId !== undefined && ref.sourceId === '') {
|
||||
issues.push(`attachmentRefs[${i}] sourceId 为空串`)
|
||||
}
|
||||
if (ref.sourceId === undefined && ref.sourceUrl === undefined) {
|
||||
issues.push(`attachmentRefs[${i}] 无 sourceId/sourceUrl 锚点`)
|
||||
}
|
||||
})
|
||||
return issues
|
||||
}
|
||||
|
||||
/** 单个 item 事件的组装:映射 → 契约复检 → 清洗 → RowResult */
|
||||
function assembleItem(
|
||||
item: WxrItem,
|
||||
mapConfig: MapConfig,
|
||||
timings: AdapterTimings,
|
||||
): AdapterEvent {
|
||||
const mapped = mapItem(item, mapConfig)
|
||||
if (mapped.kind === 'unsupported') return mapped
|
||||
if (mapped.kind === 'error') {
|
||||
return { ...mapped, sourceType: `wp:${item.postType}` }
|
||||
}
|
||||
|
||||
const { row } = mapped
|
||||
const issues = [...mapped.issues, ...validateUniversalRow(row)]
|
||||
|
||||
// bodyHtml 为空(缺席或空串)→ 跳过清洗零 diff:attachment 无正文是常态,
|
||||
// 空串调用 sanitizeBodyHtml 虽无害但会虚增清洗计时
|
||||
let sanitizeDiffs: SanitizeDiff[] = []
|
||||
let changed = false
|
||||
if (row.bodyHtml !== undefined && row.bodyHtml !== '') {
|
||||
const input = row.bodyHtml
|
||||
const t0 = performance.now()
|
||||
const result = sanitizeBodyHtml(input)
|
||||
timings.sanitizeMs += performance.now() - t0
|
||||
row.bodyHtml = result.html
|
||||
sanitizeDiffs = result.diffs
|
||||
changed = result.html !== input
|
||||
}
|
||||
return { kind: 'row', row, issues, sanitizeDiffs, changed }
|
||||
}
|
||||
|
||||
/**
|
||||
* WXR 全链适配(组装层):Readable(WXR 字节流)→ AsyncGenerator<AdapterEvent>。
|
||||
*
|
||||
* 事件按文档序交错产出(row/unsupported/error/term);解析的限额/结构错误
|
||||
* (WxrLimitError/WxrParseError)原样向上抛——错误前已产出的事件不撤回
|
||||
* (交付确定性由 parseWxrStream 保证,本层不吞不补)。消费方提前 break 时
|
||||
* 经 finally 关闭解析器(级联 destroy 上游流)。
|
||||
*/
|
||||
export async function* runWxrAdapter(
|
||||
readable: Readable,
|
||||
opts: AdapterOptions = {},
|
||||
): AsyncGenerator<AdapterEvent, void, void> {
|
||||
const timings = opts.timings ?? { parseMs: 0, sanitizeMs: 0 }
|
||||
const mapConfig = opts.mapConfig ?? { locale: '' }
|
||||
// exactOptionalPropertyTypes:显式缺席键(不传 undefined 值),与 WxrStreamOptions 形态对齐
|
||||
const streamOpts: WxrStreamOptions = {}
|
||||
if (opts.limits !== undefined) streamOpts.limits = opts.limits
|
||||
if (opts.now !== undefined) streamOpts.now = opts.now
|
||||
const parser = parseWxrStream(readable, streamOpts)
|
||||
|
||||
try {
|
||||
// 手动泵而非 for-await:await 窗口 = 解析器自身工作时间(拉取式生成器的全部
|
||||
// 工作都发生在 next() 内),与循环体内的映射/清洗天然分段
|
||||
while (true) {
|
||||
const t0 = performance.now()
|
||||
const res: IteratorResult<WxrEvent> = await parser.next()
|
||||
timings.parseMs += performance.now() - t0
|
||||
if (res.done) break
|
||||
const ev = res.value
|
||||
if (ev.kind === 'term') {
|
||||
yield { kind: 'term', term: ev.term }
|
||||
} else {
|
||||
yield assembleItem(ev.item, mapConfig, timings)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 正常完结(幂等无害)/ 消费方提前退出 / 解析抛错后:级联关闭解析器,
|
||||
// 触发其 finally(destroy 上游流)
|
||||
await parser.return()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from './versions.js'
|
||||
export * from './limits.js'
|
||||
export * from './parse/php-unserialize.js'
|
||||
export * from './parse/wxr-stream.js'
|
||||
export * from './map/item-to-row.js'
|
||||
export * from './sanitize/html.js'
|
||||
export * from './adapter.js'
|
||||
export * from './report.js'
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// 导入前报告(Plan 2 Task 6,spec §7.7 对齐):消费 runWxrAdapter 全链事件流并聚合为
|
||||
// 可 JSON 序列化的报告对象(WxrAdapterReport),另提供 HTML 渲染(renderReportHtml)。
|
||||
//
|
||||
// 对象层与渲染层职责分离:
|
||||
// - 报告对象保留**原文**(excerpt/message 未转义)——JSON 归档、diff、机器消费的语义层;
|
||||
// - renderReportHtml 对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告的动态文本
|
||||
// 来自不可信 WXR 内容(清洗差异 excerpt 含 `<script src="x">` 形态),渲染进 HTML
|
||||
// 必须转义,否则报告本身成为存储型 XSS 载体(Task 5 审查移交)。
|
||||
//
|
||||
// 计数语义(导入前投影,非入库结果):
|
||||
// - Created:changed=true 的 row(清洗输出 ≠ 输入原文 → 导入将产生新内容);
|
||||
// - Skipped(无变化):changed=false 的 row(spec §7.5:无业务变化 → Skip);
|
||||
// - Failed:error 事件(post_id 缺失/非法,无法建立稳定身份);
|
||||
// - Unsupported:unsupported 事件(post_type 未配置路由)。
|
||||
// 归桶键:row 用 row.sourceType;unsupported 用 `wp:${postType}`;error 用组装层补的
|
||||
// sourceType 报告锚点。行级错误(rowErrors)收录全部非致命问题:映射 issues、契约复检
|
||||
// issues、清洗差异(含 excerpt)、unsupported/error 事件——宁多报不漏报(审计定位用途)。
|
||||
//
|
||||
// 致命错误策略:WxrError(解析限额/结构错误)使流终止——报告不向上抛,记入 fatalError
|
||||
// 并保留已聚合部分(Task 7 wxr:probe 对损坏文件要出报告而非崩溃);非 WxrError 的意外
|
||||
// 异常视为程序 bug,原样上抛。
|
||||
|
||||
import type { Readable } from 'node:stream'
|
||||
import { runWxrAdapter, type AdapterEvent, type AdapterOptions, type AdapterTimings } from './adapter.js'
|
||||
import { WxrError } from './parse/wxr-stream.js'
|
||||
import type { SanitizeDiff } from './sanitize/html.js'
|
||||
import { ADAPTER_VERSION, MAPPING_VERSION, PARSER_VERSION, SANITIZER_VERSION } from './versions.js'
|
||||
|
||||
/** 版本块(报告首条):三段件版本 + 解析库版本(spec §7.7) */
|
||||
export interface WxrVersions {
|
||||
adapter: string
|
||||
mapping: string
|
||||
sanitizer: string
|
||||
parser: string
|
||||
}
|
||||
|
||||
/** 按 sourceType 的计数(导入前投影语义见模块头) */
|
||||
export interface SourceTypeCounts {
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
unsupported: number
|
||||
}
|
||||
|
||||
/** 行级错误条目:rawLocation 定位 + message + 可选 excerpt(原文,渲染层转义) */
|
||||
export interface RowErrorEntry {
|
||||
rawLocation: string
|
||||
message: string
|
||||
excerpt?: string
|
||||
}
|
||||
|
||||
/** 导入前报告对象(纯数据,JSON.stringify 直接可序列化——无 Map/循环引用) */
|
||||
export interface WxrAdapterReport {
|
||||
versions: WxrVersions
|
||||
counts: Record<string, SourceTypeCounts>
|
||||
totals: { rows: number; terms: number; unsupported: number; failed: number }
|
||||
sanitize: {
|
||||
byDiffType: { 'removed-tag': number; 'removed-attr': number }
|
||||
changedRows: number
|
||||
}
|
||||
rowErrors: RowErrorEntry[]
|
||||
timing: { parseMs: number; sanitizeMs: number; totalMs: number }
|
||||
/** 致命错误(WxrError.name + message);流正常完结为 null */
|
||||
fatalError: string | null
|
||||
}
|
||||
|
||||
/** 取指定类型的计数桶(缺则建桶——报告对象自建自持,无需预注册类型清单) */
|
||||
function countsBucket(report: WxrAdapterReport, sourceType: string): SourceTypeCounts {
|
||||
let bucket = report.counts[sourceType]
|
||||
if (bucket === undefined) {
|
||||
bucket = { created: 0, skipped: 0, failed: 0, unsupported: 0 }
|
||||
report.counts[sourceType] = bucket
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
/** 清洗差异 → 行级错误条目(message 携带标签形态,excerpt 为被移除内容重构摘要) */
|
||||
function diffEntry(rawLocation: string, diff: SanitizeDiff): RowErrorEntry {
|
||||
const target =
|
||||
diff.type === 'removed-tag' ? `<${diff.tag}>` : `${diff.attr}(标签 ${diff.tag})`
|
||||
return {
|
||||
rawLocation,
|
||||
message: `清洗移除未批准${diff.type === 'removed-tag' ? '标签' : '属性'}:${target}`,
|
||||
excerpt: diff.excerpt,
|
||||
}
|
||||
}
|
||||
|
||||
/** 单事件聚合(纯函数式地累积进 report——顺序敏感仅在于 rowErrors 的文档序) */
|
||||
function aggregate(report: WxrAdapterReport, ev: AdapterEvent): void {
|
||||
switch (ev.kind) {
|
||||
case 'term':
|
||||
report.totals.terms++
|
||||
break
|
||||
case 'row': {
|
||||
countsBucket(report, ev.row.sourceType)[ev.changed ? 'created' : 'skipped']++
|
||||
report.totals.rows++
|
||||
if (ev.changed) report.sanitize.changedRows++
|
||||
for (const issue of ev.issues) {
|
||||
report.rowErrors.push({ rawLocation: ev.row.rawLocation, message: issue })
|
||||
}
|
||||
for (const diff of ev.sanitizeDiffs) {
|
||||
report.sanitize.byDiffType[diff.type]++
|
||||
report.rowErrors.push(diffEntry(ev.row.rawLocation, diff))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'unsupported':
|
||||
countsBucket(report, ev.sourceType).unsupported++
|
||||
report.totals.unsupported++
|
||||
report.rowErrors.push({
|
||||
rawLocation: ev.rawLocation,
|
||||
message: `${ev.sourceType}:${ev.reason}`,
|
||||
})
|
||||
break
|
||||
case 'error':
|
||||
countsBucket(report, ev.sourceType).failed++
|
||||
report.totals.failed++
|
||||
report.rowErrors.push({ rawLocation: ev.rawLocation, message: ev.issue })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全链运行并聚合报告:内部消费 {@link runWxrAdapter} 的事件流,返回 WxrAdapterReport。
|
||||
* 计时:totalMs 覆盖整次消费(performance.now 分段——解析/清洗经注入的 timings 累计,
|
||||
* 见 adapter.ts 的分段依据;totalMs ≥ parseMs 不含严格子式保证,映射耗时计入差值)。
|
||||
*/
|
||||
export async function runWxrAdapterReport(
|
||||
readable: Readable,
|
||||
opts: AdapterOptions = {},
|
||||
): Promise<WxrAdapterReport> {
|
||||
const timings: AdapterTimings = opts.timings ?? { parseMs: 0, sanitizeMs: 0 }
|
||||
const startedAt = performance.now()
|
||||
const report: WxrAdapterReport = {
|
||||
versions: {
|
||||
adapter: ADAPTER_VERSION,
|
||||
mapping: MAPPING_VERSION,
|
||||
sanitizer: SANITIZER_VERSION,
|
||||
parser: PARSER_VERSION,
|
||||
},
|
||||
counts: {},
|
||||
totals: { rows: 0, terms: 0, unsupported: 0, failed: 0 },
|
||||
sanitize: { byDiffType: { 'removed-tag': 0, 'removed-attr': 0 }, changedRows: 0 },
|
||||
rowErrors: [],
|
||||
timing: { parseMs: 0, sanitizeMs: 0, totalMs: 0 },
|
||||
fatalError: null,
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const ev of runWxrAdapter(readable, { ...opts, timings })) {
|
||||
aggregate(report, ev)
|
||||
}
|
||||
} catch (err) {
|
||||
// 致命错误策略见模块头:WxrError 记账不抛(保留已聚合部分),其余视为程序 bug
|
||||
if (!(err instanceof WxrError)) throw err
|
||||
report.fatalError = `${err.name}: ${err.message}`
|
||||
}
|
||||
|
||||
report.timing = {
|
||||
parseMs: timings.parseMs,
|
||||
sanitizeMs: timings.sanitizeMs,
|
||||
totalMs: performance.now() - startedAt,
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
/** HTML 转义(渲染层唯一动态文本通道):<>&"' 全转义,& 最先避免二次转义陷阱 */
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/[&<>"']/g, (ch) => {
|
||||
switch (ch) {
|
||||
case '&':
|
||||
return '&'
|
||||
case '<':
|
||||
return '<'
|
||||
case '>':
|
||||
return '>'
|
||||
case '"':
|
||||
return '"'
|
||||
default:
|
||||
return '''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const esc = escapeHtml
|
||||
|
||||
/**
|
||||
* 报告 → HTML 片段(结构简单:版本块/计数表/汇总/清洗统计/错误清单/耗时)。
|
||||
* **所有**动态文本经 {@link escapeHtml};静态骨架无 script/事件属性。
|
||||
* 计数表按 sourceType 字典序排列(对象键序不保证,渲染须确定)。
|
||||
*/
|
||||
export function renderReportHtml(report: WxrAdapterReport): string {
|
||||
const lines: string[] = []
|
||||
lines.push('<h2>WXR 导入前报告</h2>')
|
||||
|
||||
lines.push('<h3>版本</h3>')
|
||||
lines.push(
|
||||
`<ul><li>adapter: ${esc(report.versions.adapter)}</li>` +
|
||||
`<li>mapping: ${esc(report.versions.mapping)}</li>` +
|
||||
`<li>sanitizer: ${esc(report.versions.sanitizer)}</li>` +
|
||||
`<li>parser: ${esc(report.versions.parser)}</li></ul>`,
|
||||
)
|
||||
|
||||
lines.push('<h3>类型计数</h3>')
|
||||
lines.push(
|
||||
'<table><thead><tr><th>sourceType</th><th>Created</th><th>Skipped(无变化)</th>' +
|
||||
'<th>Failed</th><th>Unsupported</th></tr></thead><tbody>',
|
||||
)
|
||||
const types = Object.keys(report.counts).sort()
|
||||
if (types.length === 0) {
|
||||
lines.push('<tr><td colspan="5">(无)</td></tr>')
|
||||
}
|
||||
for (const type of types) {
|
||||
const c = report.counts[type]
|
||||
if (c === undefined) continue
|
||||
lines.push(
|
||||
`<tr><td>${esc(type)}</td><td>${c.created}</td><td>${c.skipped}</td>` +
|
||||
`<td>${c.failed}</td><td>${c.unsupported}</td></tr>`,
|
||||
)
|
||||
}
|
||||
lines.push('</tbody></table>')
|
||||
|
||||
lines.push('<h3>汇总</h3>')
|
||||
lines.push(
|
||||
`<ul><li>rows: ${report.totals.rows}</li><li>terms: ${report.totals.terms}</li>` +
|
||||
`<li>unsupported: ${report.totals.unsupported}</li><li>failed: ${report.totals.failed}</li></ul>`,
|
||||
)
|
||||
|
||||
lines.push('<h3>清洗统计</h3>')
|
||||
lines.push(
|
||||
`<ul><li>removed-tag: ${report.sanitize.byDiffType['removed-tag']}</li>` +
|
||||
`<li>removed-attr: ${report.sanitize.byDiffType['removed-attr']}</li>` +
|
||||
`<li>changed rows: ${report.sanitize.changedRows}</li></ul>`,
|
||||
)
|
||||
|
||||
lines.push('<h3>行级错误</h3>')
|
||||
if (report.rowErrors.length === 0) {
|
||||
lines.push('<p>(无)</p>')
|
||||
} else {
|
||||
lines.push('<ul>')
|
||||
for (const entry of report.rowErrors) {
|
||||
const excerpt =
|
||||
entry.excerpt === undefined ? '' : `<blockquote>${esc(entry.excerpt)}</blockquote>`
|
||||
lines.push(`<li>${esc(entry.rawLocation)}:${esc(entry.message)}${excerpt}</li>`)
|
||||
}
|
||||
lines.push('</ul>')
|
||||
}
|
||||
|
||||
if (report.fatalError !== null) {
|
||||
lines.push(`<h3>致命错误</h3><p>${esc(report.fatalError)}</p>`)
|
||||
}
|
||||
|
||||
lines.push('<h3>耗时</h3>')
|
||||
lines.push(
|
||||
`<ul><li>parse: ${report.timing.parseMs.toFixed(3)}ms</li>` +
|
||||
`<li>sanitize: ${report.timing.sanitizeMs.toFixed(3)}ms</li>` +
|
||||
`<li>total: ${report.timing.totalMs.toFixed(3)}ms</li></ul>`,
|
||||
)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 适配器版本常量(Plan 2 Task 6):导入前报告的首条版本块(spec §7.7)由此单源供给。
|
||||
//
|
||||
// bump 语义(JSDoc 冻结,未来随各模块实质变更 bump 对应项):
|
||||
// - ADAPTER_VERSION:全链组装行为变更(事件契约/清洗接入方式/报告聚合语义)时 bump;
|
||||
// - MAPPING_VERSION:**映射表**实质变更时 bump——DEFAULT_TYPE_ROUTES 路由增删、
|
||||
// STATUS_MAP 值域、META_KEYS 分流键或字段映射语义(含 knownFields 键名约定);
|
||||
// - SANITIZER_VERSION:**清洗白名单**实质变更时 bump——SANITIZE_CONFIG 的标签/属性/
|
||||
// 协议白名单任何增删改(差异收集机制本身不变更产出时可不 bump)。
|
||||
// 三者独立演进:改映射表不必 bump 清洗版本,反之亦然。下游(阶段 3 导入)以版本块
|
||||
// 判断是否需要重新标准化(spec §7.5:适配器、映射或清洗版本变化可显式触发)。
|
||||
//
|
||||
// 版本号不 bump 的变更:纯 bug 修复(恢复既定语义)、测试、文档、内部性能优化。
|
||||
|
||||
/** 适配器组装版本(parse → map → sanitize 全链行为) */
|
||||
export const ADAPTER_VERSION = '1.0.0'
|
||||
|
||||
/** 映射表版本(类型路由/status 值域/meta 分流/字段映射语义) */
|
||||
export const MAPPING_VERSION = '1.0.0'
|
||||
|
||||
/** 清洗白名单版本(SANITIZE_CONFIG 标签/属性/协议白名单) */
|
||||
export const SANITIZER_VERSION = '1.0.0'
|
||||
|
||||
/**
|
||||
* 解析库版本(硬编码常量):saxes 实际解析版本,升级依赖(package.json + lockfile)
|
||||
* 时须同步改这里。不读 package.json 的原因:运行时读文件引入 IO 与路径假设,且
|
||||
* `^6.0.0` 语义版本范围不等于实际解析版本——显式常量更诚实,漂移由评审把关。
|
||||
*/
|
||||
export const PARSER_VERSION = 'saxes 6.0.0'
|
||||
@@ -0,0 +1,405 @@
|
||||
// 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 { fileURLToPath } from 'node:url'
|
||||
import { PassThrough, type 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 { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '../src/report.js'
|
||||
import { ADAPTER_VERSION, MAPPING_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
|
||||
}
|
||||
|
||||
/** 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: [],
|
||||
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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user