// 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 [ '', 'Fixture Page', '', ``, '', '

Heading One

', links, '', '', ].join('\n') } const BASE_LINKS = [ 'About', 'Redirect', 'a', // 豁免面:外域 / 锚点 / 绝对 origin / 协议相对 / mailto / javascript / 相对形态 'ext', `abs-origin`, 'anchor', 'proto-rel', 'mail', 'js', '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) => ` ${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', '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 阻断/全过语义) ---- interface Work { work: string storageRoot: string allowedRoots: string[] } async function makeWork(prefix: string): Promise { 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 { 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}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) 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) 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('