Files
cms-website/tests/integration/build-snapshot.test.ts
T
浅念 5c2ee7dc5d refactor(test): 三份 helper 迁移共享
Task 6 审查遗留①(schema 再演进防漂移):makeWork / seedPublishedSite /
cleanupSiteRows 三件套自 build-snapshot / build-pipeline / build-gates /
preview-server 四文件(Task 6 审查时三份,Task 7 又落一份)迁
tests/integration/helpers.ts 共享——清场清单(21 语句 FK 反序 + 不可变
触发器开关)此后只改一处。

- makeWork 保持签名;媒体种子载荷改由 prefix 派生(bytes 仍随机唯一,
  魔数校验只看 PNG 签名——行为等价);shared Work 统一携带 pngA/pngB
- seedPublishedSite 增 keyPrefix 参数(各文件沿用原站点 key 派生前缀
  sfbuild/sfpipe/sfgate/sfpreview);succeeded_with_issues 与
  publishedCount=3 断言随迁(gallery 203/204 悬空语义文件无关)
- import 域短清单清场与 publish 域各自形状不同,不共用——保留原处

零行为变更由 408 测试守护(全绿:根 382 + 主题 26);lint/typecheck 五段过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 15:55:43 +08:00

643 lines
34 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.
// 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/<jobId>/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<number> {
const rows = (await db.execute(sql)) as any
return Number(rows[0].n)
}
/** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */
function parsedFiles(m: { files: Record<string, string> }): Record<string, any> {
const out: Record<string, any> = {}
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<keyof typeof first.files>) {
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('<img src="https://old.example.com/wp-content/uploads/2026/10/a.png"/>', index)
expect(r.html).toBe(`<img src="${A}"/>`)
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(`<img src="${url}"/>`, index)
expect(r.html).toBe(`<img src="${A}"/>`)
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('<a href="https://cdn.old.net/files/x.jpg">x</a>', idx)
expect(r.html).toBe('<a href="/files/x.jpg">x</a>')
expect(r.stats.rewritten).toBe(1)
})
it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计;连字属性不卷入(审查 Fix 5)', () => {
const r = rewriteBodyHtml(
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
index,
)
expect(r.html).toBe(
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
)
expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 })
// 边界锚定:data-src 的 URL 即使命中索引也不改写不计(懒加载属性是渲染域决策)
//(宽松 \b 前缀会把 data-src 的 '-s' 交界误判为词边界——此用例钉死收紧语义)
const lazy = rewriteBodyHtml(`<img data-src="https://old.example.com${A}"/>`, index)
expect(lazy.html).toBe(`<img data-src="https://old.example.com${A}"/>`)
expect(lazy.stats).toStrictEqual({ rewritten: 0, unresolved: 0 })
})
})