2026-10-22 12:29:48 +08:00
|
|
|
|
// 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'
|
2026-10-22 15:55:43 +08:00
|
|
|
|
import { cleanupSiteRows, makeWork, rand, seedPublishedSite, withTestDb } from './helpers.js'
|
2026-10-22 12:29:48 +08:00
|
|
|
|
|
|
|
|
|
|
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('&', '&')}</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)
|
2026-10-22 13:07:01 +08:00
|
|
|
|
const canonicalIssue = r2.issues.find(
|
|
|
|
|
|
(i) => i.rule === 'seo-baseline' && i.evidence.kind === 'canonical-mismatch',
|
|
|
|
|
|
)
|
2026-10-22 12:29:48 +08:00
|
|
|
|
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 阻断/全过语义) ----
|
2026-10-22 15:55:43 +08:00
|
|
|
|
// 三件套(makeWork/seedPublishedSite/cleanupSiteRows)迁 tests/integration/helpers.ts 共享(Task 9 收敛)
|
2026-10-22 12:29:48 +08:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-22 15:55:43 +08:00
|
|
|
|
const { key, siteId } = await seedPublishedSite(db, w, 'sfgate')
|
2026-10-22 12:29:48 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-22 13:07:01 +08:00
|
|
|
|
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 {
|
2026-10-22 15:55:43 +08:00
|
|
|
|
const { key, siteId } = await seedPublishedSite(db, w, 'sfgate')
|
2026-10-22 13:07:01 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-22 12:29:48 +08:00
|
|
|
|
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 {
|
2026-10-22 15:55:43 +08:00
|
|
|
|
const { key, siteId } = await seedPublishedSite(db, w, 'sfgate')
|
2026-10-22 12:29:48 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|