Files
cms-website/tests/integration/build-gates.test.ts
T
浅念 5c2ee7dc5d refactor(test): 三份 helper 迁移共享
Task 6 审查遗留①(schema 再演进防漂移):makeWork / seedPublishedSite /
cleanupSiteRows 三件套自 build-snapshot / build-pipeline / build-gates /
preview-server 四文件(Task 6 审查时三份,Task 7 又落一份)迁
tests/integration/helpers.ts 共享——清场清单(21 语句 FK 反序 + 不可变
触发器开关)此后只改一处。

- makeWork 保持签名;媒体种子载荷改由 prefix 派生(bytes 仍随机唯一,
  魔数校验只看 PNG 签名——行为等价);shared Work 统一携带 pngA/pngB
- seedPublishedSite 增 keyPrefix 参数(各文件沿用原站点 key 派生前缀
  sfbuild/sfpipe/sfgate/sfpreview);succeeded_with_issues 与
  publishedCount=3 断言随迁(gallery 203/204 悬空语义文件无关)
- import 域短清单清场与 publish 域各自形状不同,不共用——保留原处

零行为变更由 408 测试守护(全绿:根 382 + 主题 26);lint/typecheck 五段过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 15:55:43 +08:00

515 lines
24 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 { 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 [
'<!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 阻断/全过语义) ----
// 三件套(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}<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, 'sfgate')
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, '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('<script')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})