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): Promise { 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): Promise { try { await p } catch (e) { return e } throw new Error('期望抛出但未抛出') } /** 迭代到错误或完结:同时保留已交付事件与错误(审查 Fix 2 的交付确定性断言用) */ async function collectUntilError(gen: AsyncGenerator): 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 => ev.kind === 'item') .map((ev) => ev.item) } /** 概要签名:item:: / term::(按文档序) */ 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: '

Body with link and

', 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:"

Tab

";}}', }, ]) }) 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 = ` <![CDATA[AAAAAAAAAAAAAAAA]]>1postpublish <![CDATA[BBBBBBBBBBBBBBBB]]>2postpublish <![CDATA[CCCCCCCCCCCCCCCC]]>3postpublish ` 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( '', '1categoryaA' + '2categorybB', ) 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( '', '' + '', ) 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, ): Promise { const dir = await mkdtemp(join(tmpdir(), 'wxr-entity-')) const path = join(dir, 'entity.xml') await writeFile( path, `\n${doctype}\n${body}`, 'utf8', ) try { await run(path) } finally { await rm(dir, { recursive: true, force: true }) } } it('DOCTYPE 内部 ENTITY:引用处抛 WxrParseError,替换文本不进产出(临时文件 + createReadStream)', async () => { await withTempEntityXml( `]>`, '&xxe;1post', 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( ']>', '&ext;1post', async (path) => { const err = (await captureRejection(collect(parseWxrStream(createReadStream(path))))) as WxrParseError expect(err).toBeInstanceOf(WxrParseError) expect(err.message).toContain('undefined entity') }, ) }) it('预定义实体不受影响:& / 数字字符引用正常解析', async () => { const xml = '' + 'plaina&bA7post' + '' 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 = '\n\na\n' 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 = '\n\na\n' 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 = '' + `t${raw}post` + '' const events = await collect(parseWxrStream(fromString(xml))) expect(itemsOf(events)[0]?.postId).toBe(expected) }) it('term_id 同一规则:非纯数字 → null', async () => { const xml = '' + '0x10category' + '' 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 = '' + 'outer1post' + '2post' + '' 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 = '' + '1category' + '2' + '' 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 = '' + 'pre<![CDATA[mid]]>post5post' + '' const events = await collect(parseWxrStream(fromString(xml))) expect(itemsOf(events)[0]?.title).toBe('premidpost') }) })