// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。 // 覆盖契约(对照计划 Task 2): // 1. build job 状态机:queued→snapshotting→building(Task 5 真实构建)→validating // (Task 6 最小门禁)→succeeded + // build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+ // 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、 // theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+ // 幂等键复用(同 published 集合 → reused 不重跑;只有 succeeded 可复用——failed/ // 崩溃残留行走 stale 宣判+#a 后缀新行自动恢复); // 2. 六文件物化(artifacts/jobs//input/):site.json(config v1)/content.json // (published 修订数组,data 全量 + media 引用面,slug 原样)/taxonomies.json(双源全量, // source_id 原样含 auto:)/routes.json(html+asset+redirect 并入,query_key/ // redirect_status/redirect_location/verification_status/indexable/priority 保留)/ // assets.json(ready 资产实体路径解析:register 快照 metadata.storageRoot+storage_path)/ // render-manifest.json(versions 四常量 + counts + sourceHashes 三哈希 + urlRewrite 统计); // 3. bodyHtml URL 重写:mini post 101 正文绝对 URL 图片命中 media ready 集合 → 根相对 // public_path;未命中 URL 保留原样 + rewritten/unresolved 计数入 render-manifest; // 4. canonical JSON 确定性:同输入两次物化 → 六文件字节一致(键排序、无空白); // 5. NOT_PUBLISHED:未 publish 站点 → 明确报错且零 build_jobs 行; // 6. ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json // 仍为 A(published 冻结语义,spec §15 阶段 4 验收明文)。 // 运行于 globalSetup 临时库(ADR-015);storage/artifacts 根 os.tmpdir,finally 清理。 import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { readFile, rm, writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { ADAPTER_VERSION, MAPPING_VERSION, PARSER_VERSION, SANITIZER_VERSION, } from '@sf/adapter-wp' import { canonicalJson } from '../../apps/api/src/common/canonical.js' import { createImportJob } from '../../apps/api/src/modules/import/import.service.js' import { registerSource } from '../../apps/api/src/modules/source/register.service.js' import { BuildError, createBuildJob, defaultBuildIdempotencyKey, } from '../../apps/api/src/modules/build/build.service.js' import { buildMediaPathIndex, materializeRenderSnapshot, rewriteBodyHtml, } from '../../apps/api/src/modules/build/render-snapshot.js' import { cleanupSiteRows, makeWork, rand, seedPublishedSite, withTestDb, type Db } from './helpers.js' const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml') function sha256of(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex') } /** db.execute 行对象规整(原型差异会让 toStrictEqual 报"无视觉差异"失败——import-media 同款) */ function plain(rows: any[]): any[] { return rows.map((r) => ({ ...r })) } /** 单值计数便捷 */ async function countOf(db: Db, sql: string): Promise { const rows = (await db.execute(sql)) as any return Number(rows[0].n) } /** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */ function parsedFiles(m: { files: Record }): Record { const out: Record = {} for (const [name, content] of Object.entries(m.files)) { out[name.replace(/\.json$/, '')] = JSON.parse(content) } return out } describe('build job + Render Snapshot 物化', () => { it('状态机 queued→snapshotting→building→validating(门禁)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', { timeout: 300_000 }, async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-sm') try { const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const statuses: string[] = [] const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts'), onStatus: (s) => statuses.push(s), }) expect(result.reused).toBe(false) expect(result.status).toBe('succeeded') // 状态机序列:snapshotting 后进 building(真实 astro 构建管线)→ validating // (Task 6 最小门禁——mini 夹具产物过五门禁)→ succeeded expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded']) // build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言; // raw execute 的 timestamptz 回串不做 Date 形状断言) const job = (await db.execute(`select status, attempt, extract(epoch from started_at)::float8 as started_ts, extract(epoch from finished_at)::float8 as finished_ts, error_code from build_jobs where id = '${result.jobId}'`)) as any expect(job).toHaveLength(1) expect(job[0].status).toBe('succeeded') expect(Number(job[0].attempt)).toBe(1) expect(typeof job[0].started_ts).toBe('number') expect(job[0].started_ts).toBeGreaterThan(0) expect(typeof job[0].finished_ts).toBe('number') expect(job[0].finished_ts).toBeGreaterThanOrEqual(job[0].started_ts) expect(job[0].error_code).toBeNull() // build_input_snapshots 行:三哈希 + 主题 + config 引用 + 版本列真实值 const snap = (await db.execute(`select content_snapshot_hash, route_snapshot_hash, asset_manifest_hash, site_config_revision_id, theme_key, theme_version, node_version, pnpm_version, astro_version, locale, timezone, manifest_path, extract(epoch from build_timestamp)::float8 as build_ts from build_input_snapshots where build_job_id = '${result.jobId}'`)) as any expect(snap).toHaveLength(1) const s = snap[0] const manifest = JSON.parse(await readFile(join(result.inputDir, 'render-manifest.json'), 'utf8')) expect(s.content_snapshot_hash).toBe(manifest.sourceHashes.content) expect(s.route_snapshot_hash).toBe(manifest.sourceHashes.route) expect(s.asset_manifest_hash).toBe(manifest.sourceHashes.asset) // 三哈希独立可复算:sha256(文件字节含尾换行)——哈希链与落盘文件互证(审查 Fix 1) for (const [col, file] of [ ['content_snapshot_hash', 'content.json'], ['route_snapshot_hash', 'routes.json'], ['asset_manifest_hash', 'assets.json'], ] as const) { const raw = await readFile(join(result.inputDir, file), 'utf8') expect(s[col]).toBe(sha256of(raw)) } const config = (await db.execute(`select scr.id from site_config_revisions scr where scr.site_id = '${siteId}'`)) as any expect(s.site_config_revision_id).toBe(config[0].id) expect(s.theme_key).toBe('pilot-minimal') expect(typeof s.theme_version).toBe('string') expect(s.theme_version).not.toBe('') // 真实读 process.versions(不硬编码) expect(s.node_version).toBe(process.versions.node) expect(typeof s.pnpm_version).toBe('string') expect(s.pnpm_version).not.toBe('') expect(s.locale).toBe('en') expect(s.timezone).not.toBe('') expect(s.manifest_path).toBe(join(result.inputDir, 'render-manifest.json')) expect(typeof s.build_ts).toBe('number') expect(s.build_ts).toBeGreaterThan(0) // 报告落盘 const report = JSON.parse(await readFile(result.reportPath, 'utf8')) expect(report.jobId).toBe(result.jobId) expect(report.status).toBe('succeeded') // 幂等复用:同 published 集合 + 同主题版本 → 同 key 复用既有 job(不重跑) const again = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts'), }) expect(again.reused).toBe(true) expect(again.jobId).toBe(result.jobId) expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(1) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)【materializeRenderSnapshot 直调——spawn 缩减】', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-files') try { const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }) // 六文件面齐备(RENDER_SNAPSHOT_FILES 冻结清单——物化产物键即清单) expect(Object.keys(m.files).sort()).toStrictEqual( ['assets.json', 'content.json', 'render-manifest.json', 'routes.json', 'site.json', 'taxonomies.json'], ) const files = parsedFiles(m) // site.json = config v1 数据(Task 1 首版生成的 {name,origin,locale,brand}) expect(files.site).toStrictEqual({ name: key, origin: `https://${key}.example`, locale: 'en', brand: {}, }) // content.json:3 条 published 修订(typeKey/slug 排序钉死);每条 data 全量 + // revisionId/checksum 与库内 published 修订对拍;slug 原样 const dbRows = plain( (await db.execute(`select ce.id as content_id, ce.type_key, el.locale, el.slug as locale_slug, cr.id as revision_id, cr.checksum, cr.data from entry_locales el join content_entries ce on ce.id = el.content_entry_id join content_revisions cr on cr.id = el.published_revision_id where el.site_id = '${siteId}'`)) as any, ) expect(dbRows).toHaveLength(3) expect(files.content.map((c: any) => [c.typeKey, c.slug])).toStrictEqual([ ['basic_page', 'about-us'], ['news_article', 'fixture-news-a'], ['product', 'fixture-product'], ]) for (const entry of files.content) { const row = dbRows.find((r: any) => r.revision_id === entry.revisionId) expect(row).toBeDefined() expect(entry.contentId).toBe(row.content_id) expect(entry.locale).toBe('en') expect(entry.checksum).toBe(row.checksum) // data 全量对拍(bodyHtml 除外——URL 重写另测) const { bodyHtml: _ignored, ...rest } = entry.data const { bodyHtml: _dbIgnored, ...dbRest } = JSON.parse(JSON.stringify(row.data)) expect(rest).toStrictEqual(dbRest) expect(typeof entry.data.bodyHtml).toBe('string') } // content.json media 引用面:published 修订的 media_usages(fieldPath → public_path) const newsEntry = files.content.find((c: any) => c.slug === 'fixture-news-a') const productEntry = files.content.find((c: any) => c.slug === 'fixture-product') const aPub = '/wp-content/uploads/2026/10/fixture-a.png' const bPub = '/wp-content/uploads/2026/10/fixture-b.png' expect(newsEntry.media).toStrictEqual({ 'attachmentRefs.featured': aPub, 'data.bodyHtml.img[0]': aPub, }) expect(productEntry.media).toStrictEqual({ 'attachmentRefs.featured': bPub }) // taxonomies.json:双源全量;source_id 原样(wp:term 的 '31'/'32' 字符串) expect(files.taxonomies.map((t: any) => [t.key, t.objectType, t.publicBasePath])).toStrictEqual([ ['category', 'post', '/category/'], ['product_cat', 'product', '/product-category/'], ]) const company = files.taxonomies[0].terms[0] expect({ sourceId: company.sourceId, slug: company.slug, name: company.name, parentTermId: company.parentTermId }) .toStrictEqual({ sourceId: '31', slug: 'company', name: 'Company', parentTermId: null }) expect(company.contentEntryIds).toStrictEqual([newsEntry.contentId]) const pipeFittings = files.taxonomies[1].terms[0] expect(pipeFittings.sourceId).toBe('32') expect(pipeFittings.contentEntryIds).toStrictEqual([]) // routes.json:html+asset+redirect 并入(含查询形态 redirect 与 redirect 字段面) expect(files.routes.map((r: any) => [r.routePath, r.routeKind, r.queryKey])).toStrictEqual([ ['/', 'html', null], ['/', 'redirect', 'attachment_id=201'], ['/', 'redirect', 'attachment_id=202'], ['/about-us/', 'html', null], ['/category/company/', 'html', null], ['/news/', 'html', null], ['/news/fixture-news-a.html', 'html', null], ['/product-category/pipe-fittings/', 'html', null], ['/products/', 'html', null], ['/products/fixture-product/', 'html', null], ['/wp-content/uploads/2026/10/fixture-a.png', 'asset', null], ['/wp-content/uploads/2026/10/fixture-b.png', 'asset', null], ]) const homeHtml = files.routes[0] expect({ queryPolicy: homeHtml.queryPolicy, indexable: homeHtml.indexable, priority: homeHtml.priority, verificationStatus: homeHtml.verificationStatus }) .toStrictEqual({ queryPolicy: 'ignore', indexable: true, priority: 1000, verificationStatus: 'verified_html' }) const redirectA = files.routes[1] expect({ queryPolicy: redirectA.queryPolicy, targetStatus: redirectA.targetStatus, redirectStatus: redirectA.redirectStatus, redirectLocation: redirectA.redirectLocation, indexable: redirectA.indexable }) .toStrictEqual({ queryPolicy: 'exact', targetStatus: '301', redirectStatus: 301, redirectLocation: aPub, indexable: false }) const newsHtml = files.routes.find((r: any) => r.routePath === '/news/fixture-news-a.html') expect(newsHtml.targetContentId).toBe(newsEntry.contentId) // assets.json:ready 资产实体路径解析(register 快照 metadata.storageRoot+storage_path) const bundle = plain((await db.execute(`select storage_path from source_snapshots where site_id = '${siteId}' and kind = 'media_bundle'`)) as any)[0] expect(files.assets).toHaveLength(2) const assetA = files.assets[0] expect(assetA.publicPath).toBe(aPub) expect(assetA.storagePath).toBe(`${bundle.storage_path}/2026/10/fixture-a.png`) expect(assetA.copyPath).toBe(resolve(w.storageRoot, assetA.storagePath)) expect(existsSync(assetA.copyPath)).toBe(true) expect(assetA.sha256).toBe(sha256of(w.pngA)) expect(assetA.detectedMimeType).toBe('image/png') expect(Number(assetA.byteSize)).toBe(w.pngA.length) expect(assetA.width).toBeNull() expect(assetA.height).toBeNull() expect(assetA.altText).toBeNull() // render-manifest:版本四常量 + counts + 三哈希 + 重写统计 expect(files['render-manifest'].versions).toStrictEqual({ adapter: ADAPTER_VERSION, mapping: MAPPING_VERSION, sanitizer: SANITIZER_VERSION, parser: PARSER_VERSION, }) expect(files['render-manifest'].counts).toStrictEqual({ contents: 3, taxonomies: 2, routes: 12, assets: 2, }) expect(files['render-manifest'].sourceHashes).toStrictEqual(m.sourceHashes) expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 }) expect(typeof files['render-manifest'].generatedAt).toBe('string') } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计【materializeRenderSnapshot 直调——spawn 缩减】', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-rewrite') try { const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const files = parsedFiles(await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })) const news = files.content.find((c: any) => c.slug === 'fixture-news-a') // 库内 published 修订的 bodyHtml(重写前真相):绝对 URL img + 根相对内链 a const dbBody = plain((await db.execute(`select cr.data->>'bodyHtml' as body from content_revisions cr where cr.id = (select published_revision_id from entry_locales el join content_entries ce on ce.id = el.content_entry_id where el.site_id = '${siteId}' and ce.type_key = 'news_article')`)) as any)[0].body expect(dbBody).toContain('https://fixture.example/wp-content/uploads/2026/10/fixture-a.png') // 重写:命中 ready 集合(剥 host 按 path 比对 public_path)→ 根相对 expect(news.data.bodyHtml).toBe( dbBody.replace( 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png', '/wp-content/uploads/2026/10/fixture-a.png', ), ) expect(news.data.bodyHtml).not.toContain('https://fixture.example/wp-content') // 未命中的根相对内链保留原样(不虚构目标) expect(news.data.bodyHtml).toContain('href="/products/fixture-product/"') // 统计入 render-manifest(rewritten=1 img;unresolved=1 内链 a) expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 }) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('canonical JSON 确定性:同输入两次物化 → 六文件字节一致(键排序、无空白)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-det') try { const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const fixedAt = '2026-10-22T00:00:00.000Z' const first = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) const second = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) // 六文件逐字节一致 expect(second.files).toStrictEqual(first.files) // canonical 形态:键递归排序、无空白(文件串 = canonicalJson(解析值) + 尾换行) for (const name of Object.keys(first.files) as Array) { const content = first.files[name] expect(content).toBe(`${canonicalJson(JSON.parse(content))}\n`) expect(content).not.toMatch(/,\s|:\s/) } expect(first.sourceHashes).toStrictEqual(second.sourceHashes) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('NOT_PUBLISHED:未 publish 站点 → 明确报错且零 build_jobs 行;SITE_NOT_FOUND 通道', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-np') try { // register+import 但不 publish const key = `sfbuild-${rand()}` const reg = await registerSource(db, { siteKey: key, wxrPath: WXR_FIXTURE, mediaDir: join(w.work, 'media'), allowedRoots: w.allowedRoots, storageRoot: w.storageRoot, reportRoot: join(w.work, 'artifacts', 'register'), }) try { await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) const err = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }).then( () => { throw new Error('不应成功') }, (e: unknown) => e, ) expect(err).toBeInstanceOf(BuildError) expect((err as BuildError).code).toBe('NOT_PUBLISHED') // 拒绝发生在任何 job 行之前(快照只读 published——空集无物化意义) expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${reg.siteId}'`)).toBe(0) } finally { await cleanupSiteRows(db, reg.siteId) } const notFound = await createBuildJob(db, { siteKey: 'no-such-site' }).then( () => { throw new Error('不应成功') }, (e: unknown) => e, ) expect(notFound).toBeInstanceOf(BuildError) expect((notFound as BuildError).code).toBe('SITE_NOT_FOUND') } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 再物化 → content.json 仍为 A(published 冻结)【materializeRenderSnapshot 直调——spawn 缩减】', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-adr010') try { const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { // 第一次物化:published=A const first = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }) // 改内容再导入:post 101 正文加 V2 标记 → 新修订 B、current=B(published 仍 A) const wxrText = await readFile(WXR_FIXTURE, 'utf8') const v2Path = join(w.work, 'mini-v2.xml') await writeFile(v2Path, wxrText.replace('Body with ', 'V2-BODY Body with '), 'utf8') await registerSource(db, { siteKey: key, wxrPath: v2Path, mediaDir: join(w.work, 'media'), allowedRoots: w.allowedRoots, storageRoot: w.storageRoot, reportRoot: join(w.work, 'artifacts', 'register'), }) const reimport = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) expect(reimport.status).toBe('succeeded_with_issues') // 双指针分叉:current=B ≠ published=A(ADR-010) const forked = plain((await db.execute(`select count(*)::int as n from entry_locales where site_id = '${siteId}' and current_revision_id is distinct from published_revision_id`)) as any)[0].n expect(Number(forked)).toBe(1) // 再物化(等价于"新 key 再 build"的输入面——job/幂等键语义在谱系测试覆盖) const second = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }) // content.json 仍为 A:字节级等于第一次物化(published 冻结语义) const contentA = first.files['content.json'] const contentB = second.files['content.json'] expect(contentB).toBe(contentA) expect(contentB).not.toContain('V2-BODY') expect(contentB).toContain('fixture-news-a') // 哈希同源:同 published 集合 → content 哈希一致(可复现性锚点) expect(second.sourceHashes.content).toBe(first.sourceHashes.content) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('media 块确定性:同 fieldPath 多资产引用 → first-wins 单值 + issue;两次物化字节一致(审查 Fix 2)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-dup') try { const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const aPub = '/wp-content/uploads/2026/10/fixture-a.png' const bPub = '/wp-content/uploads/2026/10/fixture-b.png' // 数据面异常注入:news published 修订的 featured 字段再挂 fixture-b //(media_usages PK = 资产×修订×fieldPath——跨资产同 fieldPath 可重) await db.execute(`insert into media_usages (site_id, media_asset_id, content_revision_id, field_path, source_url) select '${siteId}', (select id from media_assets where site_id = '${siteId}' and public_path = '${bPub}'), (select el.published_revision_id from entry_locales el join content_entries ce on ce.id = el.content_entry_id where el.site_id = '${siteId}' and ce.type_key = 'news_article'), 'attachmentRefs.featured', 'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png'`) const fixedAt = '2026-10-22T00:00:00.000Z' const m1 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) const m2 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) const content1 = JSON.parse(m1.files['content.json']) const news = content1.find((c: any) => c.slug === 'fixture-news-a') // first-wins:orderBy(fieldPath, publicPath) 字典序首位 = fixture-a 稳定胜出 expect(news.media['attachmentRefs.featured']).toBe(aPub) expect( m1.issues.some((i) => i.includes('同 fieldPath 多资产引用') && i.includes('attachmentRefs.featured')), ).toBe(true) // 两次物化字节一致 + issue 面一致(first-wins 确定性) expect(m2.files['content.json']).toBe(m1.files['content.json']) expect(m2.issues).toStrictEqual(m1.issues) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('job 谱系:崩溃残留 stale 宣判 → #a2 落败不复用 → #a3 重跑成功(复用只认 succeeded;审查 Fix 3 两场景并谱——spawn 缩减共享单次成功构建)', { timeout: 300_000 }, async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-lineage') try { const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { // 手构崩溃残留:上个进程中断留下的 snapshotting 悬行(默认键、attempt=1) const pubIds = plain( (await db.execute(`select published_revision_id from entry_locales where site_id = '${siteId}' and published_revision_id is not null`)) as any, ).map((r: any) => r.published_revision_id) const defKey = defaultBuildIdempotencyKey(key, pubIds) await db.execute(`insert into build_jobs (site_id, status, idempotency_key, attempt, started_at) values ('${siteId}', 'snapshotting', '${defKey}', 1, now())`) // 撞悬行跑失败:残留行宣判 stale 让位 → #a2 新行;再迫使快照落盘失败 // (artifactsRoot 指到普通文件之下 → mkdir ENOTDIR) const blocker = join(w.work, 'blocker') await writeFile(blocker, 'x', 'utf8') const err: unknown = await createBuildJob(db, { siteKey: key, artifactsRoot: join(blocker, 'artifacts'), }).then( () => { throw new Error('不应成功') }, (e: unknown) => e, ) expect(err).toBeInstanceOf(Error) // 默认键重跑(好根):default → stale(终态死行)→ #a2 → failed(终态死行) // → #a3 新行成功——failed 不复用、attempt 延续(重试谱系) const rerun = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }) expect(rerun.reused).toBe(false) expect(rerun.status).toBe('succeeded') expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(3) const rows = plain( (await db.execute(`select status, idempotency_key, attempt, error_code from build_jobs where site_id = '${siteId}' order by attempt`)) as any, ) // 谱系三行:stale(宣判)→ failed(SNAPSHOT_FAILED 保持终态)→ succeeded(#a3) expect(rows[0].status).toBe('stale') expect(rows[0].idempotency_key).toBe(defKey) expect(Number(rows[0].attempt)).toBe(1) expect(rows[1].status).toBe('failed') expect(rows[1].error_code).toBe('SNAPSHOT_FAILED') expect(String(rows[1].idempotency_key)).toBe(`${defKey}#a2`) expect(Number(rows[1].attempt)).toBe(2) expect(rows[2].status).toBe('succeeded') expect(String(rows[2].idempotency_key)).toBe(`${defKey}#a3`) expect(Number(rows[2].attempt)).toBe(3) // 新 job 物化完整 expect(existsSync(join(rerun.inputDir, 'render-manifest.json'))).toBe(true) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('media 引用非 ready 资产:引用不进 media 块 + issue 如实报告;assets 面同步收窄(审查 Fix 6)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-nonready') try { const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { // 登记后状态翻转:fixture-b(product 的 featured 引用)→ quarantined await db.execute(`update media_assets set status = 'quarantined' where site_id = '${siteId}' and public_path = '/wp-content/uploads/2026/10/fixture-b.png'`) const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }) const content = JSON.parse(m.files['content.json']) const product = content.find((c: any) => c.slug === 'fixture-product') // 引用不进 media 块(product 的 featured 是 fixture-b)+ issue 如实报告 expect(product.media).toBeUndefined() expect( m.issues.some( (i) => i.includes('非 ready') && i.includes('attachmentRefs.featured') && i.includes('quarantined'), ), ).toBe(true) // ready 集合同步收窄:assets 面只剩 fixture-a const assets = JSON.parse(m.files['assets.json']) expect(assets).toHaveLength(1) expect(assets[0].publicPath).toBe('/wp-content/uploads/2026/10/fixture-a.png') } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) }) describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双源)', () => { const A = '/wp-content/uploads/2026/10/a.png' const index = buildMediaPathIndex([ { publicPath: A, sourceUrl: 'https://old.example.com/wp-content/uploads/2026/10/a.png' }, ]) it('绝对 URL 剥 host 命中 → 根相对 public_path', () => { const r = rewriteBodyHtml('', index) expect(r.html).toBe(``) expect(r.stats).toStrictEqual({ rewritten: 1, unresolved: 0 }) }) it('协议相对与根相对变体同键命中;查询串剥离', () => { for (const url of [ '//old.example.com/wp-content/uploads/2026/10/a.png', `${A}?ver=9`, 'https://old.example.com/wp-content/uploads/2026/10/a.png?ver=9#frag', ]) { const r = rewriteBodyHtml(``, index) expect(r.html).toBe(``) expect(r.stats.rewritten).toBe(1) } }) it('source_url 路径段双源命中(public_path 之外的注册 URL 形态)', () => { const idx = buildMediaPathIndex([{ publicPath: '/files/x.jpg', sourceUrl: 'https://cdn.old.net/files/x.jpg' }]) const r = rewriteBodyHtml('x', idx) expect(r.html).toBe('x') expect(r.stats.rewritten).toBe(1) }) it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计;连字属性不卷入(审查 Fix 5)', () => { const r = rewriteBodyHtml( 'ptr', index, ) expect(r.html).toBe( 'ptr', ) expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 }) // 边界锚定:data-src 的 URL 即使命中索引也不改写不计(懒加载属性是渲染域决策) //(宽松 \b 前缀会把 data-src 的 '-s' 交界误判为词边界——此用例钉死收紧语义) const lazy = rewriteBodyHtml(``, index) expect(lazy.html).toBe(``) expect(lazy.stats).toStrictEqual({ rewritten: 0, unresolved: 0 }) }) })