feat(build): 最小门禁与状态机闭环
Task 6(Plan 4):validating 接真实门禁——五项产物检查(①内部死链 ②sitemap 一致 ③SEO 基线 ④大小写冲突 ⑤产物完整性),P0 阻断/P2 报告分级;validation_ runs/validation_issues 落库(severity/rule/url/evidence),任一 P0 → job failed (VALIDATION_FAILED + 产物清场),全过 → succeeded。HTML 解析用 htmlparser2 (sanitize-html 既有传递依赖,lock 已解析 12.0.0——apps/api 显式声明复用,零新 包版本),禁正则提标签。报告 json/html 双落盘且 HTML 先写(Plan 3 窄窗口修, html 转义模板)。顺手:build-snapshot 测试 spawn 缩减 7→2(六文件物化/bodyHtml 重写/ADR-010 改 materializeRenderSnapshot 直调;复用两场景并谱共享单次成功构建), 根套件 97.3s→75.8s;两测试文件清场补 validation 域反序(FK 链先行)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// 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 +
|
||||
// (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)+
|
||||
@@ -71,8 +71,12 @@ async function countOf(db: Db, sql: string): Promise<number> {
|
||||
return Number(rows[0].n)
|
||||
}
|
||||
|
||||
/** 本文件写入的行清场:构建域(build_artifacts 先于 build_jobs——FK;不可变快照先行)+ 发布/导入/来源全链反序 */
|
||||
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs;
|
||||
// Task 6 起成功构建必落 validation_runs 行)+ 构建域(不可变快照先行)+ 全链反序 */
|
||||
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
|
||||
await db.execute(`delete from validation_issues where validation_run_id in
|
||||
(select id from validation_runs where site_id = '${siteId}')`)
|
||||
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from build_artifacts where build_job_id in
|
||||
(select id from build_jobs where site_id = '${siteId}')`)
|
||||
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
|
||||
@@ -135,18 +139,17 @@ async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId
|
||||
return { key, siteId: reg.siteId }
|
||||
}
|
||||
|
||||
/** 读取六输入文件为解析对象 */
|
||||
async function readInputFiles(inputDir: string): Promise<Record<string, any>> {
|
||||
const names = ['site', 'content', 'taxonomies', 'routes', 'assets', 'render-manifest']
|
||||
/** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */
|
||||
function parsedFiles(m: { files: Record<string, string> }): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const n of names) {
|
||||
out[n] = JSON.parse(await readFile(join(inputDir, `${n}.json`), 'utf8'))
|
||||
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→validating(空)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', async () => {
|
||||
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 {
|
||||
@@ -160,8 +163,8 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
})
|
||||
expect(result.reused).toBe(false)
|
||||
expect(result.status).toBe('succeeded')
|
||||
// 状态机序列(Task 5 起 snapshotting 后进 building:真实 astro 构建管线;
|
||||
// validating 空:Task 6 填真实门禁)
|
||||
// 状态机序列:snapshotting 后进 building(真实 astro 构建管线)→ validating
|
||||
// (Task 6 最小门禁——mini 夹具产物过五门禁)→ succeeded
|
||||
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
|
||||
|
||||
// build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言;
|
||||
@@ -238,22 +241,18 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)', async () => {
|
||||
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)
|
||||
try {
|
||||
const result = await createBuildJob(db, {
|
||||
siteKey: key,
|
||||
artifactsRoot: join(w.work, 'artifacts'),
|
||||
})
|
||||
const inputDir = result.inputDir
|
||||
// 六文件全在(RENDER_SNAPSHOT_FILES 冻结清单)
|
||||
for (const name of ['site.json', 'content.json', 'taxonomies.json', 'routes.json', 'assets.json', 'render-manifest.json']) {
|
||||
expect(existsSync(join(inputDir, name))).toBe(true)
|
||||
}
|
||||
const files = await readInputFiles(inputDir)
|
||||
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({
|
||||
@@ -369,7 +368,7 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
routes: 12,
|
||||
assets: 2,
|
||||
})
|
||||
expect(files['render-manifest'].sourceHashes).toStrictEqual(result.sourceHashes)
|
||||
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 {
|
||||
@@ -381,14 +380,13 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计', async () => {
|
||||
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 { key, siteId } = await seedPublishedSite(db, w)
|
||||
const { siteId } = await seedPublishedSite(db, w)
|
||||
try {
|
||||
const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
||||
const files = await readInputFiles(result.inputDir)
|
||||
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
|
||||
@@ -489,15 +487,14 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json 仍为 A(published 冻结)', async () => {
|
||||
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)
|
||||
try {
|
||||
// 第一次 build:published=A 物化
|
||||
const first = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
||||
expect(first.reused).toBe(false)
|
||||
// 第一次物化: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')
|
||||
@@ -518,18 +515,12 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
where site_id = '${siteId}' and current_revision_id is distinct from published_revision_id`)) as any)[0].n
|
||||
expect(Number(forked)).toBe(1)
|
||||
|
||||
// 新幂等键强制新 job(默认键按 published 集合派生会复用——复用语义另测)
|
||||
const second = await createBuildJob(db, {
|
||||
siteKey: key,
|
||||
artifactsRoot: join(w.work, 'artifacts'),
|
||||
idempotencyKey: `build:test:adr010:${rand()}`,
|
||||
})
|
||||
expect(second.reused).toBe(false)
|
||||
expect(second.jobId).not.toBe(first.jobId)
|
||||
// 再物化(等价于"新 key 再 build"的输入面——job/幂等键语义在谱系测试覆盖)
|
||||
const second = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
|
||||
|
||||
// content.json 仍为 A:字节级等于第一次物化(published 冻结语义)
|
||||
const contentA = await readFile(join(first.inputDir, 'content.json'), 'utf8')
|
||||
const contentB = await readFile(join(second.inputDir, 'content.json'), 'utf8')
|
||||
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')
|
||||
@@ -584,60 +575,9 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('job 复用只认 succeeded:failed job + 默认键重跑 → #a2 新行成功、旧行保持 failed(审查 Fix 3)', async () => {
|
||||
it('job 谱系:崩溃残留 stale 宣判 → #a2 落败不复用 → #a3 重跑成功(复用只认 succeeded;审查 Fix 3 两场景并谱——spawn 缩减共享单次成功构建)', { timeout: 300_000 }, 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')
|
||||
const w = await makeWork('sf-build-lineage')
|
||||
try {
|
||||
const { key, siteId } = await seedPublishedSite(db, w)
|
||||
try {
|
||||
@@ -650,21 +590,44 @@ describe('build job + Render Snapshot 物化', () => {
|
||||
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;新行成功——两行并存(键空间让位)
|
||||
// 撞悬行跑失败:残留行宣判 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 from build_jobs
|
||||
(await db.execute(`select status, idempotency_key, attempt, error_code from build_jobs
|
||||
where site_id = '${siteId}' order by attempt`)) as any,
|
||||
)
|
||||
expect(rows).toHaveLength(2)
|
||||
// 谱系三行:stale(宣判)→ failed(SNAPSHOT_FAILED 保持终态)→ succeeded(#a3)
|
||||
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`)
|
||||
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(result.inputDir, 'render-manifest.json'))).toBe(true)
|
||||
expect(existsSync(join(rerun.inputDir, 'render-manifest.json'))).toBe(true)
|
||||
} finally {
|
||||
await cleanupSiteRows(db, siteId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user