fix(build): 哈希链含尾换行;media 块 first-wins 确定性;job 复用只认 succeeded+崩溃回收;重写器边界锚定
审查六修(Fix 2/3 先于 Task 3 消费六文件落形): - Fix 1 三哈希 = sha256(文件字节含尾换行)——哈希链与落盘文件互证;测试改按 原始文件字节独立复算对拍 - Fix 2 media_usages orderBy(fieldPath, publicPath) + first-wins:同 fieldPath 多资产引用取字典序首位、其余丢弃记 issue;两次物化字节一致有测试钉死 - Fix 3 job 复用只认 succeeded;failed/cancelled/stale 死行与非终态崩溃残留 (旧行置 stale 宣判)都以 <key>#a<attempt+1> 后缀键建新行自动重跑(attempt 延续谱系);失败终态化时清走半途 input/ 目录;两路径各有测试 - Fix 4 git rev-parse 等环境探测移出快照事务(单次探测传入) - Fix 5 属性正则边界锚定 (^|\s):data-src 等连字属性不卷入(\b 会误判 '-s' 交界)——data-src 命中索引也不改写的用例钉死 - Fix 6 media 引用面只 join ready 资产;非 ready 残留引用单独查出记 issue、 不进 content.json media 块(quarantined 翻转测试) 354/354(350+4);typecheck 四段、lint 全绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
// 覆盖契约(对照计划 Task 2):
|
||||
// 1. build job 状态机:queued→snapshotting→validating(空,Task 6 填)→succeeded(dist 尚空
|
||||
// ——Task 5 接真实构建时新建 job 不改行,build_input_snapshots 有不可变触发器)+
|
||||
// build_input_snapshots 行(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256、
|
||||
// site_config_revision_id 指向 config v1、theme_key='pilot-minimal'、node_version 等版本
|
||||
// 信息列真实读 process.versions)+ 幂等键复用(同 published 集合 → reused 不重跑);
|
||||
// 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/
|
||||
@@ -38,6 +40,7 @@ import { registerSource } from '../../apps/api/src/modules/source/register.servi
|
||||
import {
|
||||
BuildError,
|
||||
createBuildJob,
|
||||
defaultBuildIdempotencyKey,
|
||||
} from '../../apps/api/src/modules/build/build.service.js'
|
||||
import {
|
||||
buildMediaPathIndex,
|
||||
@@ -185,14 +188,14 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
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)
|
||||
// 三哈希独立可复算:各文件 canonicalJson 的 sha256(测试独立重算对拍)
|
||||
// 三哈希独立可复算: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 payload = JSON.parse(await readFile(join(result.inputDir, file), 'utf8'))
|
||||
expect(s[col]).toBe(sha256of(canonicalJson(payload)))
|
||||
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
|
||||
@@ -537,6 +540,168 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
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 复用只认 succeeded:failed job + 默认键重跑 → #a2 新行成功、旧行保持 failed(审查 Fix 3)', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const w = await makeWork('sf-build-failed')
|
||||
try {
|
||||
const { key, siteId } = await seedPublishedSite(db, w)
|
||||
try {
|
||||
// 迫使快照落盘失败: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)
|
||||
const afterFail = plain(
|
||||
(await db.execute(`select status, error_code, attempt from build_jobs
|
||||
where site_id = '${siteId}'`)) as any,
|
||||
)
|
||||
expect(afterFail).toHaveLength(1)
|
||||
expect(afterFail[0].status).toBe('failed')
|
||||
expect(afterFail[0].error_code).toBe('SNAPSHOT_FAILED')
|
||||
expect(Number(afterFail[0].attempt)).toBe(1)
|
||||
|
||||
// 默认键重跑(好根):不复用 failed 行 → 后缀键新行成功
|
||||
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(2)
|
||||
// 旧行保持 failed;新行键带 #a2 后缀、attempt 延续 = 2(重试谱系)
|
||||
const rows = plain(
|
||||
(await db.execute(`select status, idempotency_key, attempt from build_jobs
|
||||
where site_id = '${siteId}' order by attempt`)) as any,
|
||||
)
|
||||
expect(rows[0].status).toBe('failed')
|
||||
expect(rows[1].status).toBe('succeeded')
|
||||
expect(String(rows[1].idempotency_key)).toContain('#a2')
|
||||
expect(Number(rows[1].attempt)).toBe(2)
|
||||
} finally {
|
||||
await cleanupSiteRows(db, siteId)
|
||||
}
|
||||
} finally {
|
||||
await rm(w.work, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('崩溃残留回收:滞留 snapshotting 的默认键行 → stale 宣判 + 新 job 物化成功(审查 Fix 3)', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const w = await makeWork('sf-build-stale')
|
||||
try {
|
||||
const { key, siteId } = await seedPublishedSite(db, w)
|
||||
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())`)
|
||||
|
||||
const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
||||
expect(result.reused).toBe(false)
|
||||
expect(result.status).toBe('succeeded')
|
||||
// 旧行宣判 stale;新行成功——两行并存(键空间让位)
|
||||
const rows = plain(
|
||||
(await db.execute(`select status, idempotency_key from build_jobs
|
||||
where site_id = '${siteId}' order by attempt`)) as any,
|
||||
)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].status).toBe('stale')
|
||||
expect(rows[0].idempotency_key).toBe(defKey)
|
||||
expect(rows[1].status).toBe('succeeded')
|
||||
expect(rows[1].idempotency_key).toBe(`${defKey}#a2`)
|
||||
// 新 job 物化完整
|
||||
expect(existsSync(join(result.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)
|
||||
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 双源)', () => {
|
||||
@@ -570,7 +735,7 @@ describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双
|
||||
expect(r.stats.rewritten).toBe(1)
|
||||
})
|
||||
|
||||
it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计', () => {
|
||||
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,
|
||||
@@ -579,5 +744,10 @@ describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双
|
||||
'<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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user