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:
浅念
2026-10-22 12:29:48 +08:00
parent 3e436d907a
commit 8f2b9825a6
8 changed files with 1206 additions and 110 deletions
+535
View File
@@ -0,0 +1,535 @@
// Plan 4 Task 6 失败测试(先于实现):最小门禁 + 完整 build 状态机(spec §13.4/§16.1)。
// 覆盖契约(对照计划 Task 6):
// 1. 五门禁逐项一过一 fail(纯产物夹具直调 runGates——无 DB 无 spawn):
// ① internal-links:dist 内 a[href]/img[src] 根相对目标全命中(html 产物或 asset 文件
// 或 manifest 路由——redirect 落点无文件不算死链);外域/锚点/绝对 origin/协议相对/
// mailto: 等非根相对形态豁免——注入坏链 → P0 + issue 定位(页面 + href);
// ② sitemap-consistency:sitemap 条目 = manifest indexable html 集——多页(extra loc)/
// 删页(缺 loc)→ P0;
// ③ seo-baseline:每 html 有非空 title/meta description/canonical(manifest 页精确 =
// origin+自身路径;非 manifest 页(404.html)前缀匹配)/恰一 H1——缺 title/canonical
// 指错/双 H1 → P0;缺 description → P2(主题契约"有则合规"——报告不阻断);
// ④ case-collision:dist 全路径 lowercase 无重复(Windows 盘不敏感防线)——NTFS 大小写
// 不敏感无法物化冲突对,纯函数 findCaseCollisions 直测;
// ⑤ artifact-completeness:route-manifest html 行 target_file 全存在——缺席 → P0;
// 2. P0 阻断语义(mini 全链 1 spawn):validating 挂钩注入坏链 → createBuildJob 以
// VALIDATION_FAILED 拒绝 → job failed + validation_runs FAIL 行 + validation_issues
// 行(severity/rule/url/evidence)+ dist 清场(failed 无产物语义);
// 3. 全过语义(mini 全链 1 spawn):真实主题产物过五门禁 → validation_runs PASS 行 +
// job succeeded + 报告 json/html 双落盘(HTML 先写——build.json 在则 build.html 必在)。
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { RouteManifest } from '@sf/contracts'
import { createBuildJob, BuildError } from '../../apps/api/src/modules/build/build.service.js'
import {
findCaseCollisions,
runGates,
GATE_RULE_VERSION,
type GateIssue,
} from '../../apps/api/src/modules/build/gates.js'
import { createImportJob } from '../../apps/api/src/modules/import/import.service.js'
import { publishSiteContent } from '../../apps/api/src/modules/publish/publish.service.js'
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = join(FIXTURES_ROOT, 'wxr', 'mini.xml')
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const ORIGIN = 'https://gate-fixture.example'
// ---- 纯产物夹具(手构 dist + manifest + sitemap——runGates 直调面) ----
/** 合规 html 页(title/desc/canonical/恰一 H1 + 可配链接面) */
function pageHtml(opts: { canonical: string; links?: string[] }): string {
const links = (opts.links ?? []).map((l) => ` ${l}`).join('\n')
return [
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
'<title>Fixture Page</title>',
'<meta name="description" content="fixture description"/>',
`<link rel="canonical" href="${opts.canonical}"/>`,
'</head><body>',
'<h1>Heading One</h1>',
links,
'</body></html>',
'',
].join('\n')
}
const BASE_LINKS = [
'<a href="/about/">About</a>',
'<a href="/old-path/">Redirect</a>',
'<img src="/wp-content/uploads/2026/10/a.png" alt="a"/>',
// 豁免面:外域 / 锚点 / 绝对 origin / 协议相对 / mailto / javascript / 相对形态
'<a href="https://external.example/x">ext</a>',
`<a href="${ORIGIN}/about/">abs-origin</a>`,
'<a href="#top">anchor</a>',
'<a href="//cdn.example/x.png">proto-rel</a>',
'<a href="mailto:a@b.example">mail</a>',
'<a href="javascript:void(0)">js</a>',
'<img src="data:image/png;base64,AAA" alt="data"/>',
]
function baseManifest(): RouteManifest {
return {
manifest_version: 1,
site_id: '00000000-0000-0000-0000-000000000001',
build_job_id: '00000000-0000-0000-0000-000000000002',
origin: ORIGIN,
generated_from_route_snapshot_hash: 'ab'.repeat(32),
routes: [
{
exact_key: '/',
query_policy: 'ignore',
query_key: null,
kind: 'html',
target_file: 'index.html',
status: 200,
content_type: 'text/html',
indexable: true,
},
{
exact_key: '/about/',
query_policy: 'ignore',
query_key: null,
kind: 'html',
target_file: 'about/index.html',
status: 200,
content_type: 'text/html',
indexable: true,
},
{
exact_key: '/wp-content/uploads/2026/10/a.png',
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: '/wp-content/uploads/2026/10/a.png',
status: 200,
content_type: 'image/png',
indexable: false,
},
{
exact_key: '/old-path/',
query_policy: 'ignore',
query_key: null,
kind: 'redirect',
status: 301,
location: '/',
indexable: false,
},
],
}
}
function baseSitemapLocs(): string[] {
return [`${ORIGIN}/`, `${ORIGIN}/about/`]
}
function sitemapXml(locs: string[]): string {
const items = locs.map((l) => ` <url><loc>${l.replaceAll('&', '&amp;')}</loc></url>`)
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items.join('\n')}\n</urlset>\n`
}
interface GateFixture {
distDir: string
sitemapPath: string
work: string
}
/** 合规基线夹具:index + about + 404(非 manifest 页)+ asset + redirect 落点路由 */
async function makeGateFixture(prefix: string): Promise<GateFixture> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
const distDir = join(work, 'dist')
await mkdir(join(distDir, 'about'), { recursive: true })
await mkdir(join(distDir, 'wp-content', 'uploads', '2026', '10'), { recursive: true })
await writeFile(join(distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
await writeFile(
join(distDir, 'about', 'index.html'),
pageHtml({ canonical: `${ORIGIN}/about/`, links: ['<a href="/">Home</a>'] }),
'utf8',
)
// 404.html:非 manifest 页(站点兜底非内容路由)——canonical 前缀匹配(origin 起头)
await writeFile(
join(distDir, '404.html'),
pageHtml({ canonical: `${ORIGIN}/404`, links: ['<a href="/">Home</a>'] }),
'utf8',
)
await writeFile(join(distDir, 'wp-content', 'uploads', '2026', '10', 'a.png'), PNG_SIG)
const sitemapPath = join(distDir, 'sitemap.xml')
await writeFile(sitemapPath, sitemapXml(baseSitemapLocs()), 'utf8')
return { distDir, sitemapPath, work }
}
async function runFixture(
fx: GateFixture,
manifest: RouteManifest = baseManifest(),
): Promise<{ pass: boolean; issues: GateIssue[] }> {
return runGates({ distDir: fx.distDir, routeManifest: manifest, sitemapPath: fx.sitemapPath, origin: ORIGIN })
}
function rulesOf(issues: GateIssue[]): string[] {
return [...new Set(issues.map((i) => i.rule))]
}
describe('最小门禁(纯产物夹具直调)', () => {
it('全过基线:合规产物 → pass + 零 issue(豁免面:外域/锚点/绝对 origin/协议相对/mailto/相对形态)', async () => {
const fx = await makeGateFixture('sf-gate-base')
try {
const r = await runFixture(fx)
expect(r.pass).toBe(true)
expect(r.issues).toStrictEqual([])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('① internal-links fail:坏 a[href]/img[src] → P0 + 定位(页面 + href);redirect 落点路由不算死链', async () => {
const fx = await makeGateFixture('sf-gate-link')
try {
// 注入坏链页:a 指向不存在页面、img 指向不存在 asset(/old-path/ 是 redirect——命中)
await writeFile(
join(fx.distDir, 'about', 'index.html'),
pageHtml({
canonical: `${ORIGIN}/about/`,
links: ['<a href="/missing-page/">bad</a>', '<img src="/wp-content/uploads/no-such.png" alt="bad"/>', '<a href="/old-path/">ok-redirect</a>', '<a href="/missing-page/?ver=2#frag">bad-query</a>'],
}),
'utf8',
)
const r = await runFixture(fx)
expect(r.pass).toBe(false)
const linkIssues = r.issues.filter((i) => i.rule === 'internal-links')
expect(linkIssues).toHaveLength(3)
for (const i of linkIssues) {
expect(i.severity).toBe('P0')
expect(i.url).toBe('/about/')
}
const hrefs = linkIssues.map((i) => i.evidence.href)
expect(hrefs).toStrictEqual(['/missing-page/', '/wp-content/uploads/no-such.png', '/missing-page/?ver=2#frag'])
expect(linkIssues[0]!.evidence.page).toBe('about/index.html')
// 其余门禁不受牵连
expect(rulesOf(r.issues)).toStrictEqual(['internal-links'])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('② sitemap-consistency fail:多页(extra loc)与删页(缺 loc)→ P0(evidence 带 expected/actual 差异)', async () => {
const fx = await makeGateFixture('sf-gate-sitemap')
try {
// 多页:sitemap 多出一条幽灵 loc
await writeFile(fx.sitemapPath, sitemapXml([...baseSitemapLocs(), `${ORIGIN}/ghost/`]), 'utf8')
const extra = await runFixture(fx)
expect(extra.pass).toBe(false)
const extraIssues = extra.issues.filter((i) => i.rule === 'sitemap-consistency')
expect(extraIssues).toHaveLength(1)
expect(extraIssues[0]!.severity).toBe('P0')
expect(extraIssues[0]!.evidence.extra).toStrictEqual([`${ORIGIN}/ghost/`])
expect(extraIssues[0]!.evidence.missing).toStrictEqual([])
// 删页:sitemap 缺一条 indexable html
await writeFile(fx.sitemapPath, sitemapXml([`${ORIGIN}/about/`]), 'utf8')
const missing = await runFixture(fx)
expect(missing.pass).toBe(false)
const missIssues = missing.issues.filter((i) => i.rule === 'sitemap-consistency')
expect(missIssues).toHaveLength(1)
expect(missIssues[0]!.evidence.missing).toStrictEqual([`${ORIGIN}/`])
expect(missIssues[0]!.evidence.extra).toStrictEqual([])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('③ seo-baseline fail:缺 title/canonical 指错/双 H1 → P0;缺 description → P2 不阻断', async () => {
const fx = await makeGateFixture('sf-gate-seo')
try {
// 缺 title 页(canonical 换行注入保持其余合规)
const noTitle = pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace('<title>Fixture Page</title>\n', '')
// 双 H1 + canonical 指错 + 缺 description(一个页面对应一类——分别注入三页更清晰,
// 这里单页三错以验证同类合并多证据,另以缺 description 单独验证 P2 通道)
const messy = [
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
'<title>Messy</title>',
`<link rel="canonical" href="${ORIGIN}/wrong-path/"/>`,
'</head><body>',
'<h1>First</h1><h1>Second</h1>',
'</body></html>',
'',
].join('\n')
const noDesc = pageHtml({ canonical: `${ORIGIN}/`, links: [] }).replace(
'<meta name="description" content="fixture description"/>\n',
'',
)
await writeFile(join(fx.distDir, 'about', 'index.html'), noTitle, 'utf8')
await writeFile(join(fx.distDir, '404.html'), messy, 'utf8')
// index.html 换成缺 description(canonical 保持 /)
await writeFile(join(fx.distDir, 'index.html'), noDesc, 'utf8')
const r = await runFixture(fx)
const seo = r.issues.filter((i) => i.rule === 'seo-baseline')
// 缺 title(about)+ canonical 指错(404.html 非 manifest 页——前缀匹配通过?否:
// /wrong-path/ 仍以 origin 起头,前缀匹配放行——故 canonical 指错须用 manifest 页验证)
expect(seo.some((i) => i.severity === 'P0' && i.url === '/about/' && i.evidence.missing === 'title')).toBe(true)
// 双 H1(404.html)
expect(seo.some((i) => i.severity === 'P0' && i.evidence.page === '404.html' && i.evidence.h1Count === 2)).toBe(true)
// 缺 description → P2(不阻断——pass 只看 P0)
expect(seo.some((i) => i.severity === 'P2' && i.evidence.page === 'index.html' && i.evidence.missing === 'description')).toBe(true)
expect(r.pass).toBe(false) // 但缺 title/双 H1 的 P0 在场 → 阻断
// canonical 精确匹配验证:manifest 页指错(/about/ 页 canonical 指到 /)→ P0
await writeFile(
join(fx.distDir, 'about', 'index.html'),
pageHtml({ canonical: `${ORIGIN}/`, links: [] }),
'utf8',
)
await writeFile(join(fx.distDir, '404.html'), pageHtml({ canonical: `${ORIGIN}/404`, links: [] }), 'utf8')
await writeFile(join(fx.distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
const r2 = await runFixture(fx)
const canonicalIssue = r2.issues.find((i) => i.rule === 'seo-baseline' && i.evidence.missing === 'canonical')
expect(canonicalIssue).toBeDefined()
expect(canonicalIssue!.severity).toBe('P0')
expect(canonicalIssue!.url).toBe('/about/')
expect(canonicalIssue!.evidence.actual).toBe(`${ORIGIN}/`)
expect(canonicalIssue!.evidence.expected).toBe(`${ORIGIN}/about/`)
expect(r2.pass).toBe(false)
// 纯 P2 面(只缺 description)不阻断:全页恢复、仅 about 缺 description
await writeFile(
join(fx.distDir, 'about', 'index.html'),
pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace(
'<meta name="description" content="fixture description"/>\n',
'',
),
'utf8',
)
const r3 = await runFixture(fx)
expect(r3.pass).toBe(true)
expect(r3.issues.every((i) => i.severity === 'P2')).toBe(true)
expect(r3.issues).toHaveLength(1)
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('④ case-collision:lowercase 归一后重复 → 冲突对(纯函数直测——NTFS 大小写不敏感无法物化冲突对)', async () => {
// 过:无重复
expect(findCaseCollisions(['index.html', 'about/index.html', 'A.png', 'b/c.png'])).toStrictEqual([])
// fail:仅大小写不同的两个路径 → 冲突对(目录段与文件名段都参与归一)
expect(findCaseCollisions(['Docs/x.html', 'docs/x.html'])).toStrictEqual([
{ lower: 'docs/x.html', paths: ['Docs/x.html', 'docs/x.html'] },
])
expect(findCaseCollisions(['a/B.html', 'A/b.html', 'A/B.html'])).toStrictEqual([
{ lower: 'a/b.html', paths: ['A/B.html', 'A/b.html', 'a/B.html'] },
])
})
it('⑤ artifact-completeness fail:manifest html 行 target_file 缺席 → P0(evidence 带 route 与 target_file)', async () => {
const fx = await makeGateFixture('sf-gate-comp')
try {
// 物理删除 about 产物(模拟主题落盘缺席)
await rm(join(fx.distDir, 'about'), { recursive: true, force: true })
const r = await runFixture(fx)
expect(r.pass).toBe(false)
const comp = r.issues.filter((i) => i.rule === 'artifact-completeness')
expect(comp).toHaveLength(1)
expect(comp[0]!.severity).toBe('P0')
expect(comp[0]!.url).toBe('/about/')
expect(comp[0]!.evidence.target_file).toBe('about/index.html')
// 死链面同时感知缺席(index→about 链接死);SEO 面只查在场文件——缺席文件不产 issue
expect(rulesOf(r.issues)).toStrictEqual(['internal-links', 'artifact-completeness'])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
})
// ---- mini 全链(DB + 真实 astro 构建——P0 阻断/全过语义) ----
interface Work {
work: string
storageRoot: string
allowedRoots: string[]
}
async function makeWork(prefix: string): Promise<Work> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
const mediaDir = join(work, 'media')
await mkdir(join(mediaDir, '2026', '10'), { recursive: true })
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-a-${rand()}`)]))
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-b-${rand()}`)]))
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work] }
}
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
const key = `sfgate-${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'),
})
const result = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') })
expect(result.status).toBe('succeeded_with_issues')
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
expect(run.publishedCount).toBe(3)
return { key, siteId: reg.siteId }
}
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs)+ 全链反序 */
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}'`)
await db.execute(`delete from build_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`)
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
await db.execute(`delete from terms where site_id = '${siteId}'`)
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
await db.execute(`delete from source_records where site_id = '${siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
await db.execute(`delete from sites where id = '${siteId}'`)
}
describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () => {
it('任一 P0 → validation_runs FAIL + validation_issues 落库(severity/rule/url/evidence)+ job failed + dist 清场', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-gate-block')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
const artifactsRoot = join(w.work, 'artifacts')
let blockedJobId = ''
const err: unknown = await createBuildJob(db, {
siteKey: key,
artifactsRoot,
idempotencyKey: `build:test:gate-block:${rand()}`,
onStatus: (s, jobId) => {
// validating 挂钩点:构建产物已拷出、门禁未跑——向 index.html 注入坏链
if (s === 'validating') {
blockedJobId = jobId
const index = join(artifactsRoot, 'jobs', jobId, 'dist', 'index.html')
const html = readFileSync(index, 'utf8')
writeFileSync(index, `${html}<a href="/no-such-page/">injected</a>`, 'utf8')
}
},
}).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(BuildError)
expect((err as BuildError).code).toBe('VALIDATION_FAILED')
expect((err as BuildError).message).toContain('no-such-page')
// job 行:failed 终态 + VALIDATION_FAILED 码
const job = (await db.execute(`select status, error_code, error_summary from build_jobs
where id = '${blockedJobId}'`)) as any
expect(job).toHaveLength(1)
expect(job[0].status).toBe('failed')
expect(job[0].error_code).toBe('VALIDATION_FAILED')
expect(job[0].error_summary).toContain('no-such-page')
// validation_runs:FAIL 行(site/job 关联 + rule_version + 起止时间戳)
const run = (await db.execute(`select vr.status, vr.rule_version, vr.build_job_id,
extract(epoch from vr.started_at)::float8 as s_ts,
extract(epoch from vr.finished_at)::float8 as f_ts
from validation_runs vr where vr.build_job_id = '${blockedJobId}'`)) as any
expect(run).toHaveLength(1)
expect(run[0].status).toBe('FAIL')
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
expect(run[0].s_ts).toBeGreaterThan(0)
expect(run[0].f_ts).toBeGreaterThanOrEqual(run[0].s_ts)
// validation_issues:每 issue 一行(severity/rule/url/evidence)
const issues = (await db.execute(`select vi.severity, vi.rule, vi.url, vi.evidence, vi.resolution_status
from validation_issues vi join validation_runs vr on vi.validation_run_id = vr.id
where vr.build_job_id = '${blockedJobId}' order by vi.rule, vi.url`)) as any
expect(issues.length).toBeGreaterThanOrEqual(1)
const link = issues.find((i: any) => i.rule === 'internal-links')
expect(link).toBeDefined()
expect(link.severity).toBe('P0')
expect(link.url).toBe('/')
expect(link.evidence.href).toBe('/no-such-page/')
expect(link.evidence.page).toBe('index.html')
for (const i of issues) {
expect(['P0', 'P2']).toContain(i.severity)
expect(i.resolution_status).toBe('open')
expect(typeof i.rule).toBe('string')
}
// failed 无产物语义:dist 清场(issue 证据已在库——调试面在 validation_issues)
expect(existsSync(join(artifactsRoot, 'jobs', blockedJobId, 'dist'))).toBe(false)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('全过 → validation_runs PASS + 零 issue 行 + job succeeded + 报告 json/html 双落盘(HTML 先写)', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-gate-pass')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
const statuses: string[] = []
const result = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(w.work, 'artifacts'),
onStatus: (s) => statuses.push(s),
})
expect(result.status).toBe('succeeded')
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
// validation_runs:PASS 行 + 零 issue
const run = (await db.execute(`select status, rule_version from validation_runs
where build_job_id = '${result.jobId}'`)) as any
expect(run).toHaveLength(1)
expect(run[0].status).toBe('PASS')
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
const issueCount = (await db.execute(`select count(*)::int as n from validation_issues vi
join validation_runs vr on vi.validation_run_id = vr.id
where vr.build_job_id = '${result.jobId}'`)) as any
expect(Number(issueCount[0].n)).toBe(0)
// 报告双落盘:build.json 在则 build.html 必在(HTML 先写窄窗口修);
// html 为转义模板渲染(含 jobId 与 PASS 面、无 script)
const reportJson = JSON.parse(await readFile(result.reportPath, 'utf8'))
expect(reportJson.validation.status).toBe('PASS')
expect(reportJson.validation.ruleVersion).toBe(GATE_RULE_VERSION)
const reportHtmlPath = join(w.work, 'artifacts', 'jobs', result.jobId, 'reports', 'build.html')
expect(statSync(reportHtmlPath).isFile()).toBe(true)
const html = await readFile(reportHtmlPath, 'utf8')
expect(html).toContain(result.jobId)
expect(html).toContain('PASS')
expect(html).not.toContain('<script')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})
+5 -1
View File
@@ -105,8 +105,12 @@ async function scanThemeSrc(): Promise<Map<string, string>> {
return out
}
/** 本文件写入的行清场:构建域(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}'`)
+65 -102
View File
@@ -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)
}