Files
cms-website/packages/adapter-wordpress/test/wxr-stream.test.ts
T
浅念 8edbbad45b fix(adapter-wp): 内存上界注释纠正;错误前事件排空确定化;post_id 严格解析;嵌套畸形 fail-fast;类型收紧与测试钉
审查修复六项(wxr-stream):
- 内存上界表述纠正:saxes 按节点内部缓冲整个 text/CDATA 节点,对抗性
  单字段文件峰值 ≤ maxWxrBytes 的 UTF-16 表示(约 2×),并非与文件
  大小解耦;maxFieldLength/maxCdataLength 封顶的是本模块缓冲与产出
  字段长度
- parser.write/close 抛错前先排空待 yield 队列再重抛:错误前已完成
  事件的交付不依赖 chunk 划分(测试钉死单 chunk 与 7 字节切块一致)
- wp:post_id / wp:term_id 严格解析:整串十进制 + Number.isSafeInteger,
  否则 null(表驱动测试:''/'0x10'/'12abc'/超安全域 → null)
- 嵌套 item/wp:term 立即 WxrParseError(fail-fast,防外层截断产出)
- Target.field 经 Exclude 收窄为纯文本字段,消除 as string 断言
- 测试钉死:提前 break 销毁上游流、text+CDATA 混合累计、限额错误
  message 含行列位置

142 测试全绿(新增 12)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 09:21:41 +08:00

462 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createReadStream } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PassThrough, Readable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import {
parseWxrStream,
WxrError,
WxrLimitError,
WxrParseError,
type WxrEvent,
type WxrItem,
} from '../src/parse/wxr-stream.js'
// mini.xml(Plan 1 夹具):5 item(post 101 / product 111 / page 121 / attachment 201,202)
// + 2 term(31 category / 32 product_cat);term 块在第三个 item 之后、attachment 之前。
const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url))
/** 整串一次性喂入(单 chunk 路径) */
function fromString(xml: string): Readable {
const s = new PassThrough()
s.end(xml)
return s
}
/** 按字节切块喂入(跨 chunk 的 UTF-8 多字节与 token 边界路径;7 字节必拆中文与 ]]>) */
function fromStringChunked(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)
})(),
)
}
async function collect(gen: AsyncGenerator<WxrEvent>): Promise<WxrEvent[]> {
const out: WxrEvent[] = []
for await (const ev of gen) out.push(ev)
return out
}
/** 捕获 reject 的错误实例(而非仅匹配),以便断言 kind / line / column 细节(照 php-unserialize 测试先例) */
async function captureRejection(p: Promise<unknown>): Promise<unknown> {
try {
await p
} catch (e) {
return e
}
throw new Error('期望抛出但未抛出')
}
/** 迭代到错误或完结:同时保留已交付事件与错误(审查 Fix 2 的交付确定性断言用) */
async function collectUntilError(gen: AsyncGenerator<WxrEvent>): Promise<{ events: WxrEvent[]; error: unknown }> {
const events: WxrEvent[] = []
try {
for await (const ev of gen) events.push(ev)
return { events, error: null }
} catch (e) {
return { events, error: e }
}
}
function itemsOf(events: WxrEvent[]): WxrItem[] {
return events
.filter((ev): ev is Extract<WxrEvent, { kind: 'item' }> => ev.kind === 'item')
.map((ev) => ev.item)
}
/** 概要签名:item:<postId>:<postType> / term:<termId>:<taxonomy>(按文档序) */
function signature(events: WxrEvent[]): string[] {
return events.map((ev) =>
ev.kind === 'item'
? `item:${ev.item.postId}:${ev.item.postType}`
: `term:${ev.term.termId}:${ev.term.taxonomy}`,
)
}
describe('parseWxrStream:mini.xml 流式产出', () => {
it('产出顺序与文档一致:post → product → page → term → term → attachment → attachment', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
expect(signature(events)).toStrictEqual([
'item:101:post',
'item:111:product',
'item:121:page',
'term:31:category',
'term:32:product_cat',
'item:201:attachment',
'item:202:attachment',
])
})
it('跨 chunk 切块喂入(7 字节/块)产出与单 chunk 完全一致', async () => {
const { readFileSync } = await import('node:fs')
const xml = readFileSync(FIXTURE, 'utf8')
const events = await collect(parseWxrStream(fromStringChunked(xml)))
expect(signature(events)).toStrictEqual([
'item:101:post',
'item:111:product',
'item:121:page',
'term:31:category',
'term:32:product_cat',
'item:201:attachment',
'item:202:attachment',
])
})
it('item 事件字段完整:post 101 逐一断言具体值', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 101)
expect(item).toBeDefined()
expect(item).toMatchObject({
postId: 101,
postType: 'post',
status: 'publish',
postName: 'fixture-news-a',
link: 'https://fixture.example/news/fixture-news-a.html',
title: 'Fixture News A',
contentEncoded:
'<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>',
excerptEncoded: 'Fixture excerpt A',
postDate: '2026-10-05 09:00:00',
postModified: '2026-10-06 10:00:00',
attachmentUrl: '',
creator: 'editor',
})
})
it('attachment item:attachmentUrl 与 inherit 状态', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const a201 = itemsOf(events).find((it) => it.postId === 201)
expect(a201).toMatchObject({
postType: 'attachment',
status: 'inherit',
attachmentUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png',
})
})
it('term 事件字段:term_id/taxonomy/slug/name(term 31)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const term = events.find((ev) => ev.kind === 'term' && ev.term.termId === 31)
expect(term).toBeDefined()
expect(term?.kind === 'term' && term.term).toMatchObject({
termId: 31,
taxonomy: 'category',
slug: 'company',
name: 'Company',
})
})
it('postmeta 聚合:post 101 恰 3 条(_thumbnail_id + Yoast title/metadesc)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 101)
expect(item?.metas).toStrictEqual([
{ key: '_thumbnail_id', value: '201' },
{ key: '_yoast_wpseo_title', value: 'Fixture SEO Title A' },
{ key: '_yoast_wpseo_metadesc', value: 'Fixture SEO description A.' },
])
})
it('postmeta 聚合:product 111 恰 3 条(gallery 与 wppop_sections 原样字符串)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 111)
expect(item?.metas).toStrictEqual([
{ key: '_thumbnail_id', value: '202' },
{ key: '_product_image_gallery', value: '203,204' },
{
key: 'wppop_sections',
value: 'a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"<p>Tab</p>";}}',
},
])
})
it('category 元素聚合进 item 事件:post 101 → category/company/Company;product 111 无', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const post101 = itemsOf(events).find((it) => it.postId === 101)
expect(post101?.categories).toStrictEqual([{ domain: 'category', nicename: 'company', value: 'Company' }])
const product111 = itemsOf(events).find((it) => it.postId === 111)
expect(product111?.categories).toStrictEqual([])
})
it('rawLocation:wxr://item/<文档内序号从1起>,term 同理', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
expect(itemsOf(events).map((it) => it.rawLocation)).toStrictEqual([
'wxr://item/1',
'wxr://item/2',
'wxr://item/3',
'wxr://item/4',
'wxr://item/5',
])
expect(
events
.filter((ev) => ev.kind === 'term')
.map((ev) => (ev.kind === 'term' ? ev.term.rawLocation : '')),
).toStrictEqual(['wxr://term/1', 'wxr://term/2'])
})
})
// 标题 CDATA 长 16 字符:供 maxCdataLength / maxFieldLength(=10)触发超限
const SMALL_XML = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<item><title><![CDATA[AAAAAAAAAAAAAAAA]]></title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
<item><title><![CDATA[BBBBBBBBBBBBBBBB]]></title><wp:post_id>2</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
<item><title><![CDATA[CCCCCCCCCCCCCCCC]]></title><wp:post_id>3</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
</channel>
</rss>`
describe('parseWxrStream:限额防线(WxrLimitError,kind 区分)', () => {
it('maxItems=2:第 3 个 item 开始前抛 kind items', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxItems: 2 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err).toBeInstanceOf(WxrError)
expect(err.kind).toBe('items')
})
it('maxCdataLength=10:超长 CDATA 抛 kind cdata', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxCdataLength: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('cdata')
// 限额错误 message 附 saxes 行列位置(钉死,审查 Fix 6c)
expect(err.message).toMatch(/line \d+, column \d+/)
})
it('超限抛错前已完成事件确定性交付:单 chunk 与 7 字节切块产出一致(审查 Fix 2)', async () => {
const single = await collectUntilError(
parseWxrStream(fromString(SMALL_XML), { limits: { maxItems: 2 } }),
)
const chunked = await collectUntilError(
parseWxrStream(fromStringChunked(SMALL_XML), { limits: { maxItems: 2 } }),
)
expect(single.error).toBeInstanceOf(WxrLimitError)
expect(chunked.error).toBeInstanceOf(WxrLimitError)
expect(signature(single.events)).toStrictEqual(['item:1:post', 'item:2:post'])
expect(signature(chunked.events)).toStrictEqual(['item:1:post', 'item:2:post'])
})
it('maxFieldLength=10:单字段累计超限抛 kind fieldLength(防单个 title 灌爆内存)', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxFieldLength: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('fieldLength')
})
it('maxDepth=2:rss>channel>item 嵌套超限抛 kind depth', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxDepth: 2 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('depth')
})
it('timeBudgetMs:可注入时钟超预算 → kind time', async () => {
let calls = 0
const now = () => (calls++ === 0 ? 0 : 1_000_000) // 第 1 次取起点,其后均"超预算"
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { timeBudgetMs: 120_000 }, now })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('time')
})
it('maxTermCount=1:第 2 个 term 抛 kind terms', async () => {
const xml = SMALL_XML.replace(
'</channel>',
'<wp:term><wp:term_id>1</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy><wp:term_slug>a</wp:term_slug><wp:term_name>A</wp:term_name></wp:term>' +
'<wp:term><wp:term_id>2</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy><wp:term_slug>b</wp:term_slug><wp:term_name>B</wp:term_name></wp:term></channel>',
)
const err = (await captureRejection(
collect(parseWxrStream(fromString(xml), { limits: { maxTermCount: 1 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('terms')
})
it('maxWxrBytes:累计字节数超限抛 kind bytes', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxWxrBytes: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('bytes')
})
it('maxMetaPerItem:单 item postmeta 数超限抛 kind metaPerItem', async () => {
const xml = SMALL_XML.replace(
'</item>',
'<wp:postmeta><wp:meta_key><![CDATA[k1]]></wp:meta_key><wp:meta_value><![CDATA[v1]]></wp:meta_value></wp:postmeta>' +
'<wp:postmeta><wp:meta_key><![CDATA[k2]]></wp:meta_key><wp:meta_value><![CDATA[v2]]></wp:meta_value></wp:postmeta></item>',
)
const err = (await captureRejection(
collect(parseWxrStream(fromString(xml), { limits: { maxMetaPerItem: 1 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('metaPerItem')
})
})
describe('parseWxrStream:外部实体防线(不解析实体、不注入替换文本、不访问网络)', () => {
// 选定行为(钉死):saxes 6 实体表固定为 XML 五预定义 + 数字字符引用,DOCTYPE
// 内部/SYSTEM 实体声明一律不注册——引用即 "undefined entity." 结构错误(WxrParseError),
// 替换文本绝不进入任何产出字段;saxes 为纯字符串解析器,外部 SYSTEM 标识符从不被抓取。
const SECRET = 'SECRET-REPLACEMENT-TEXT'
async function withTempEntityXml(
doctype: string,
body: string,
run: (path: string) => Promise<void>,
): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'wxr-entity-'))
const path = join(dir, 'entity.xml')
await writeFile(
path,
`<?xml version="1.0" encoding="UTF-8"?>\n${doctype}\n<rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>${body}</channel></rss>`,
'utf8',
)
try {
await run(path)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
it('DOCTYPE 内部 ENTITY:引用处抛 WxrParseError,替换文本不进产出(临时文件 + createReadStream)', async () => {
await withTempEntityXml(
`<!DOCTYPE rss [<!ENTITY xxe "${SECRET}">]>`,
'<item><title>&xxe;</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type></item>',
async (path) => {
const events: WxrEvent[] = []
const err = (await captureRejection(
(async () => {
for await (const ev of parseWxrStream(createReadStream(path))) events.push(ev)
})(),
)) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('undefined entity')
// 无论解析走到哪一步:替换文本绝不出现于任何已产出事件
expect(JSON.stringify(events)).not.toContain(SECRET)
},
)
})
it('SYSTEM 外部实体:同样拒绝(不解析、不抓取——无任何网络 I/O)', async () => {
await withTempEntityXml(
'<!DOCTYPE rss [<!ENTITY ext SYSTEM "file:///etc/passwd">]>',
'<item><title>&ext;</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type></item>',
async (path) => {
const err = (await captureRejection(collect(parseWxrStream(createReadStream(path))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('undefined entity')
},
)
})
it('预定义实体不受影响:&amp; / 数字字符引用正常解析', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>plain</title><wp:post_name>a&amp;b&#65;</wp:post_name><wp:post_id>7</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.postName).toBe('a&bA')
})
})
describe('parseWxrStream:畸形 XML → WxrParseError(带 saxes 行列位置)', () => {
it('提前闭合:精确 line/column(4:17)与原始信息', async () => {
const xml =
'<?xml version="1.0"?>\n<rss><channel>\n<item><title>a</title></item>\n</channel></item></rss>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err).toBeInstanceOf(WxrError)
expect(err.line).toBe(4)
expect(err.column).toBe(17)
expect(err.message).toContain('unexpected close tag')
})
it('未闭合(截断)文件:close 阶段报错,行列为有效正数', async () => {
const xml = '<?xml version="1.0"?>\n<rss><channel>\n<item><title>a</title></item>\n</channel>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.line).toBeGreaterThanOrEqual(1)
expect(err.column).toBeGreaterThanOrEqual(0)
expect(err.message).toContain('unclosed')
})
})
describe('parseWxrStream:wp:post_id / wp:term_id 严格整数解析(审查 Fix 3)', () => {
it.each([
['', null],
['0x10', null],
['12abc', null],
['-7', -7],
['99999999999999999999', null],
['101', 101],
])('post_id 输入 %j → %j', async (raw, expected) => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
`<item><title>t</title><wp:post_id>${raw}</wp:post_id><wp:post_type>post</wp:post_type></item>` +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.postId).toBe(expected)
})
it('term_id 同一规则:非纯数字 → null', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<wp:term><wp:term_id>0x10</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy></wp:term>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(events[0]?.kind === 'term' && events[0].term.termId).toBeNull()
})
})
describe('parseWxrStream:嵌套畸形 fail-fast(审查 Fix 4)', () => {
it('item 内嵌 item:立即 WxrParseError,外层不被截断产出', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>outer</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type>' +
'<item><wp:post_id>2</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</item></channel></rss>'
const { events, error } = await collectUntilError(parseWxrStream(fromString(xml)))
expect(error).toBeInstanceOf(WxrParseError)
expect((error as WxrParseError).message).toContain('嵌套')
expect(events).toStrictEqual([]) // 嵌套发生在 item 中途——错误前无已完成事件
})
it('wp:term 内嵌 wp:term:同样 fail-fast', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<wp:term><wp:term_id>1</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy>' +
'<wp:term><wp:term_id>2</wp:term_id></wp:term>' +
'</wp:term></channel></rss>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('嵌套')
})
})
describe('parseWxrStream:流契约钉死(审查 Fix 6)', () => {
it('消费方提前 break:上游流被销毁(finally 兜底契约)', async () => {
const stream = fromString(SMALL_XML)
for await (const _ev of parseWxrStream(stream)) {
break // 收到首个事件即退出
}
expect(stream.destroyed).toBe(true)
})
it('text 与 CDATA 混合内容累计为同一字段值', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>pre<![CDATA[mid]]>post</title><wp:post_id>5</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.title).toBe('premidpost')
})
})