// 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 { 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 { cleanupSiteRows, makeWork, rand, seedPublishedSite, withTestDb } from './helpers.js'
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 [
'
',
'Fixture Page',
'',
``,
'',
'Heading One
',
links,
'',
'',
].join('\n')
}
const BASE_LINKS = [
'About',
'Redirect',
'
',
// 豁免面:外域 / 锚点 / 绝对 origin / 协议相对 / mailto / javascript / 相对形态
'ext',
`abs-origin`,
'anchor',
'proto-rel',
'mail',
'js',
'
',
]
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) => ` ${l.replaceAll('&', '&')}`)
return `\n\n${items.join('\n')}\n\n`
}
interface GateFixture {
distDir: string
sitemapPath: string
work: string
}
/** 合规基线夹具:index + about + 404(非 manifest 页)+ asset + redirect 落点路由 */
async function makeGateFixture(prefix: string): Promise {
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: ['Home'] }),
'utf8',
)
// 404.html:非 manifest 页(站点兜底非内容路由)——canonical 前缀匹配(origin 起头)
await writeFile(
join(distDir, '404.html'),
pageHtml({ canonical: `${ORIGIN}/404`, links: ['Home'] }),
'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: ['bad', '
', 'ok-redirect', 'bad-query'],
}),
'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('Fixture Page\n', '')
// 双 H1 + canonical 指错 + 缺 description(一个页面对应一类——分别注入三页更清晰,
// 这里单页三错以验证同类合并多证据,另以缺 description 单独验证 P2 通道)
const messy = [
'',
'Messy',
``,
'',
'First
Second
',
'',
'',
].join('\n')
const noDesc = pageHtml({ canonical: `${ORIGIN}/`, links: [] }).replace(
'\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.kind === 'canonical-mismatch',
)
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(
'\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 阻断/全过语义) ----
// 三件套(makeWork/seedPublishedSite/cleanupSiteRows)迁 tests/integration/helpers.ts 共享(Task 9 收敛)
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, 'sfgate')
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}injected`, '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('issues 分批落库:>1100 条 P0(1101 坏链注入)→ 全量落库不 GATE_INTERNAL、job failed(VALIDATION_FAILED)', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-gate-batch')
try {
const { key, siteId } = await seedPublishedSite(db, w, 'sfgate')
try {
const artifactsRoot = join(w.work, 'artifacts')
let batchJobId = ''
// 1101 条互异坏链(超 1000 分批界——两批:1000+101)
const badLinks = Array.from({ length: 1101 }, (_, n) => `bad-${n}`).join('')
const err: unknown = await createBuildJob(db, {
siteKey: key,
artifactsRoot,
idempotencyKey: `build:test:gate-batch:${rand()}`,
onStatus: (s, jobId) => {
if (s === 'validating') {
batchJobId = jobId
const index = join(artifactsRoot, 'jobs', jobId, 'dist', 'index.html')
writeFileSync(index, `${readFileSync(index, 'utf8')}${badLinks}`, 'utf8')
}
},
}).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
// 门禁正常判定收口(分批落库破裂会变 GATE_INTERNAL——此断言即防回归)
expect(err).toBeInstanceOf(BuildError)
expect((err as BuildError).code).toBe('VALIDATION_FAILED')
const rows = (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 = '${batchJobId}'`)) as any
expect(Number(rows[0].n)).toBe(1101)
} 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, 'sfgate')
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('