Files
cms-website/tests/integration/build-gates.test.ts
T
浅念 fa27a3a308 fix(build): validation_issues 分批插入;import/build 报告写序对齐 HTML 先;canonical mismatch evidence 修正
- validation_issues 落库改分批(VALIDATION_ISSUES_CHUNK=1000 行/批循环):
  单 INSERT 占位符上限(pg ~65535 ÷ 6 列)会被超大 issue 集撞穿;
  run 行先落、批次间无序依赖。新增测试:1101 坏链注入 → 全量 1101 行
  落库、收口 VALIDATION_FAILED(非 GATE_INTERNAL)
- import 域报告写序换 HTML 先(与 build 域对齐——json 在则 html 必在)
- gates canonical 指错分支 evidence 改 kind:'canonical-mismatch'
  (mismatch 语义不再误用 missing 键;缺席分支保持 missing:'canonical')
- build.html 尾换行对齐 import 域
- 计划文档 Task 9 补审查遗留清单(helper 迁移/Plan 5 前置门禁面)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:07:01 +08:00

581 lines
27 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 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.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(
'<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('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)
try {
const artifactsRoot = join(w.work, 'artifacts')
let batchJobId = ''
// 1101 条互异坏链(超 1000 分批界——两批:1000+101)
const badLinks = Array.from({ length: 1101 }, (_, n) => `<a href="/no-such-${n}/">bad-${n}</a>`).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)
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 })
}
})
})
})