Files
cms-website/tests/integration/build-pipeline.test.ts
T
浅念 8f2b9825a6 feat(build): 最小门禁与状态机闭环
Task 6(Plan 4):validating 接真实门禁——五项产物检查(①内部死链 ②sitemap
一致 ③SEO 基线 ④大小写冲突 ⑤产物完整性),P0 阻断/P2 报告分级;validation_
runs/validation_issues 落库(severity/rule/url/evidence),任一 P0 → job failed
(VALIDATION_FAILED + 产物清场),全过 → succeeded。HTML 解析用 htmlparser2
(sanitize-html 既有传递依赖,lock 已解析 12.0.0——apps/api 显式声明复用,零新
包版本),禁正则提标签。报告 json/html 双落盘且 HTML 先写(Plan 3 窄窗口修,
html 转义模板)。顺手:build-snapshot 测试 spawn 缩减 7→2(六文件物化/bodyHtml
重写/ADR-010 改 materializeRenderSnapshot 直调;复用两场景并谱共享单次成功构建),
根套件 97.3s→75.8s;两测试文件清场补 validation 域反序(FK 链先行)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 12:29:48 +08:00

568 lines
30 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 5 失败测试(先于实现):构建管线(astro spawn → 媒体落盘 → sitemap/robots
// → route-manifest → build_artifacts 登记)。覆盖契约(对照计划 Task 5):
// 1. 全链(mini 夹具 DB 物化输入):状态机 queued→snapshotting→**building**→validating
// →succeeded;dist 按映射规则落盘(manifest target_file 权威:每 html 行存在且
// isFile);媒体 copyPath→dist/public_path(sha256 全量校验);sitemap.xml(origin +
// indexable html 行、条目数=indexable html 数);robots.txt;route-manifest §12.1
// 字段面(含 redirect 行与 ?attachment_id= 查询形态);build_artifacts 三行
// (dist=目录哈希/manifest/report)+ UNIQUE(build_job_id,kind,storage_path);
// 2. 主题夹具直跑管线(Task 3 render-input 作 SF_BUILD_INPUT):编码 URL sitemap 原样
// (%e5… 字节不变、无解码孪生)、.html 字面文件、3 条媒体全量 sha256、redirect 行;
// 3. 五禁断言:① loader 单一入口(主题源码数据访问面只在 load-render-data)
// ② DATABASE_URL 置空构建成功 ③ 无网证明(NODE_OPTIONS 注入 stub:global fetch /
// net.connect 抛错——构建仍成功)④ 无 seed(fixtures 引用只在 test/)⑤ 不读源快照
// (storage/snapshots 零命中);
// 4. 失败路径:SF_BUILD_INPUT 六文件面破裂(抽走 content.json)→ spawn 非零退出 +
// stderr 标记 → job failed(error_summary 含标记)+ dist 残留清场。
// Windows 判据纪律(Task 3 备忘):astro 失败 = 非零退出 + stderr 标记,不断言精确退出码。
import { createHash } from 'node:crypto'
import { existsSync, rmSync, statSync } from 'node:fs'
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
import { isRouteManifest, ROUTE_MANIFEST_VERSION } from '@sf/contracts'
import { canonicalJson } from '../../apps/api/src/common/canonical.js'
import { runBuildPipeline, themeDistDir } from '../../apps/api/src/modules/build/pipeline.js'
import { createBuildJob } from '../../apps/api/src/modules/build/build.service.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 {
createSiteFixture,
rand,
rejectionMessage,
withImmutableCleanup,
withTestDb,
type Db,
} from './helpers.js'
/** 本文件位于 <repo>/tests/integration/ → 上两级是仓库根 */
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml')
const THEME_SRC = join(REPO_ROOT, 'themes', 'pilot-minimal', 'src')
const THEME_FIXTURE_INPUT = join(REPO_ROOT, 'themes', 'pilot-minimal', 'test', 'fixtures', 'render-input')
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/** 伪媒体体(签名 + 唯一种子载荷——内容唯一避免跨测试撞哈希) */
function mediaBytes(seed: string): Buffer {
return Buffer.concat([PNG_SIG, Buffer.from(`media-body:${seed}`, 'latin1')])
}
function sha256of(buf: Buffer | string): string {
return createHash('sha256').update(buf).digest('hex')
}
function plain(rows: any[]): any[] {
return rows.map((r) => ({ ...r }))
}
async function countOf(db: Db, sql: string): Promise<number> {
const rows = (await db.execute(sql)) as any
return Number(rows[0].n)
}
/** 路由 → dist 相对文件路径(映射规则:尾斜杠→<path>/index.html、.html 后缀→字面文件) */
function distRel(routePath: string): string {
if (routePath === '/') return 'index.html'
const rel = routePath.replace(/^\//, '').replace(/\/+$/, '')
return rel.endsWith('.html') ? rel : `${rel}/index.html`
}
/** 目录哈希独立复算(与管线同公式:排序相对路径\t文件 sha256 行清单的 sha256) */
async function dirSha256(dir: string): Promise<{ hash: string; bytes: number; files: number }> {
const rels: string[] = []
const walk = async (d: string): Promise<void> => {
for (const e of await readdir(d, { withFileTypes: true })) {
const full = join(d, e.name)
if (e.isDirectory()) await walk(full)
else rels.push(relative(dir, full).replaceAll('\\', '/'))
}
}
await walk(dir)
rels.sort()
let bytes = 0
const lines: string[] = []
for (const r of rels) {
const buf = await readFile(join(dir, r))
bytes += buf.byteLength
lines.push(`${r}\t${sha256of(buf)}`)
}
return { hash: sha256of(`${lines.join('\n')}\n`), bytes, files: rels.length }
}
/** 主题 src 全文件面(posix 相对路径 → 内容) */
async function scanThemeSrc(): Promise<Map<string, string>> {
const out = new Map<string, string>()
const walk = async (d: string): Promise<void> => {
for (const e of await readdir(d, { withFileTypes: true })) {
const full = join(d, e.name)
if (e.isDirectory()) await walk(full)
else out.set(relative(THEME_SRC, full).replaceAll('\\', '/'), await readFile(full, 'utf8'))
}
}
await walk(THEME_SRC)
return out
}
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→
* build_jobs;Task 6 起成功构建必落 validation_runs 行)+ 构建域 + 全链反序 */
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}'`)
}
interface Work {
work: string
storageRoot: string
allowedRoots: string[]
pngA: Buffer
pngB: Buffer
}
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 })
const pngA = mediaBytes(`pipe-a-${rand()}`)
const pngB = mediaBytes(`pipe-b-${rand()}`)
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), pngA)
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), pngB)
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work], pngA, pngB }
}
/** mini 全链(register wxr+media → import → publish):3 条 published + 2 ready 媒体 */
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
const key = `sfpipe-${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 }
}
describe('构建管线全链(mini 夹具 DB 物化输入)', () => {
it('状态机含 building;dist target_file 权威落盘 + 媒体 sha256 全量 + sitemap/robots + route-manifest §12.1 + build_artifacts 三行', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pipe-full')
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.reused).toBe(false)
expect(result.status).toBe('succeeded')
// 状态机:Task 5 起 snapshotting 后进 building(真实 astro 构建)再 validating
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
const inputDir = result.inputDir
const distDir = join(w.work, 'artifacts', 'jobs', result.jobId, 'dist')
const routes = JSON.parse(await readFile(join(inputDir, 'routes.json'), 'utf8'))
const assets = JSON.parse(await readFile(join(inputDir, 'assets.json'), 'utf8'))
const site = JSON.parse(await readFile(join(inputDir, 'site.json'), 'utf8'))
const inputManifest = JSON.parse(await readFile(join(inputDir, 'render-manifest.json'), 'utf8'))
const htmlRoutes = routes.filter((r: any) => r.routeKind === 'html')
// ---- dist 落盘:manifest target_file 权威(每 html 行存在且 isFile)----
expect(distDir).toBe(result.artifacts!.distPath)
for (const r of htmlRoutes) {
const rel = distRel(r.routePath)
const f = join(distDir, rel)
// isFile 而非 exists:.html 字面路径若被折成同名目录,existsSync 仍真——形态必须钉死
expect(statSync(f).isFile(), `${r.routePath} → ${rel}(须为文件)`).toBe(true)
}
// 尾斜杠→index.html / .html 字面 两形态都有(mini 夹具两族齐备)
expect(statSync(join(distDir, 'index.html')).isFile()).toBe(true)
expect(statSync(join(distDir, 'news', 'fixture-news-a.html')).isFile()).toBe(true)
// ---- 媒体复制:assets.json 逐条 copyPath→dist/public_path,sha256 全量校验 ----
expect(assets).toHaveLength(2)
for (const a of assets) {
const copied = await readFile(join(distDir, a.publicPath))
expect(sha256of(copied)).toBe(a.sha256)
expect(copied.byteLength).toBe(Number(a.byteSize))
}
expect(sha256of(await readFile(join(distDir, '/wp-content/uploads/2026/10/fixture-a.png')))).toBe(sha256of(w.pngA))
expect(sha256of(await readFile(join(distDir, '/wp-content/uploads/2026/10/fixture-b.png')))).toBe(sha256of(w.pngB))
// ---- sitemap.xml:origin + indexable html 行;条目数=indexable html 数 ----
const sitemap = await readFile(join(distDir, 'sitemap.xml'), 'utf8')
const indexable = htmlRoutes.filter((r: any) => r.indexable)
const locs = [...sitemap.matchAll(/<loc>(.*?)<\/loc>/g)].map((m) => m[1]!)
expect(locs).toHaveLength(indexable.length)
expect(locs).toStrictEqual(indexable.map((r: any) => `${site.origin}${r.routePath}`).sort())
expect(sitemap.startsWith('<?xml version="1.0" encoding="UTF-8"?>')).toBe(true)
expect(sitemap).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')
// ---- robots.txt ----
expect(await readFile(join(distDir, 'robots.txt'), 'utf8')).toBe(
`User-agent: *\nAllow: /\n\nSitemap: ${site.origin}/sitemap.xml\n`,
)
// ---- route-manifest.json:§12.1 字段面全断言 ----
const manifestPath = join(w.work, 'artifacts', 'jobs', result.jobId, 'route-manifest.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
expect(isRouteManifest(manifest)).toBe(true)
expect(manifest.manifest_version).toBe(ROUTE_MANIFEST_VERSION)
expect(manifest.site_id).toBe(siteId)
expect(manifest.build_job_id).toBe(result.jobId)
expect(manifest.origin).toBe(site.origin)
// 哈希链复用:generated_from = 输入 render-manifest 的 route 快照哈希
expect(manifest.generated_from_route_snapshot_hash).toBe(inputManifest.sourceHashes.route)
expect(manifest.routes).toHaveLength(routes.length)
// 序 = routes.json 序(物化排序确定)
expect(manifest.routes.map((r: any) => r.exact_key)).toStrictEqual(
routes.map((r: any) => (r.queryKey === null ? r.routePath : `${r.routePath}?${r.queryKey}`)),
)
// html 行:target_file 派生映射 + 200 + text/html + indexable
const home = manifest.routes.find((r: any) => r.exact_key === '/')
expect(home).toStrictEqual({
exact_key: '/',
query_policy: 'ignore',
query_key: null,
kind: 'html',
target_file: 'index.html',
status: 200,
content_type: 'text/html',
indexable: true,
})
const literal = manifest.routes.find((r: any) => r.exact_key === '/news/fixture-news-a.html')
expect(literal.target_file).toBe('news/fixture-news-a.html')
// redirect 查询形态:?attachment_id= 查询键 → exact_key 带查询串
const redirectA = manifest.routes.find((r: any) => r.exact_key === '/?attachment_id=201')
expect(redirectA).toStrictEqual({
exact_key: '/?attachment_id=201',
query_policy: 'exact',
query_key: 'attachment_id=201',
kind: 'redirect',
status: 301,
location: '/wp-content/uploads/2026/10/fixture-a.png',
indexable: false,
})
// asset 行:target_file=public_path + content_type=mime + 200
const assetRow = manifest.routes.find(
(r: any) => r.exact_key === '/wp-content/uploads/2026/10/fixture-a.png',
)
expect(assetRow).toStrictEqual({
exact_key: '/wp-content/uploads/2026/10/fixture-a.png',
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: '/wp-content/uploads/2026/10/fixture-a.png',
status: 200,
content_type: 'image/png',
indexable: false,
})
// ---- build_artifacts 三行(dist/manifest/report)----
const rows = plain(
(await db.execute(`select kind, storage_path, sha256, byte_size from build_artifacts
where build_job_id = '${result.jobId}' order by kind`)) as any,
)
expect(rows.map((r) => r.kind)).toStrictEqual(['dist', 'manifest', 'report'])
const distInfo = await dirSha256(distDir)
const distRow = rows.find((r) => r.kind === 'dist')!
expect(distRow.storage_path).toBe(distDir)
expect(distRow.sha256).toBe(distInfo.hash)
expect(Number(distRow.byte_size)).toBe(distInfo.bytes)
const manifestRow = rows.find((r) => r.kind === 'manifest')!
expect(manifestRow.storage_path).toBe(manifestPath)
expect(manifestRow.sha256).toBe(sha256of(await readFile(manifestPath, 'utf8')))
expect(Number(manifestRow.byte_size)).toBe((await readFile(manifestPath)).byteLength)
const reportRow = rows.find((r) => r.kind === 'report')!
expect(reportRow.storage_path).toBe(result.reportPath)
expect(reportRow.sha256).toBe(sha256of(await readFile(result.reportPath, 'utf8')))
// UNIQUE(build_job_id, kind, storage_path):同三元组重复登记被拒
const dupMsg = await rejectionMessage(
db,
`insert into build_artifacts (build_job_id, kind, storage_path, sha256, byte_size)
values ('${result.jobId}', 'dist', '${distDir}', 'x', 1)`,
)
expect(dupMsg).toContain('build_artifacts_uq')
// 报告含产物面(dist/manifest 路径与哈希)
const report = JSON.parse(await readFile(result.reportPath, 'utf8'))
expect(report.artifacts.distPath).toBe(distDir)
expect(report.artifacts.distSha256).toBe(distInfo.hash)
expect(report.artifacts.routeManifestPath).toBe(manifestPath)
expect(report.artifacts.mediaCopied).toBe(2)
// 主题包内 dist 是管线暂存(spawn 后处理完已拷出并清空)——产物权威在 artifacts 根
expect(existsSync(themeDistDir())).toBe(false)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('失败路径:SF_BUILD_INPUT 六文件面破裂 → spawn 非零退出 + stderr 标记 → job failed + error_summary 含标记 + dist 清残', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pipe-fail')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
const artifactsRoot = join(w.work, 'artifacts')
let failingJobId = ''
const err: unknown = await createBuildJob(db, {
siteKey: key,
artifactsRoot,
onStatus: (s, jobId) => {
// building 挂钩点:物化已完成、spawn 未起——抽走 content.json 迫使主题构建
// 失败(管线预读 routes/assets/site/render-manifest 四文件,content.json 是
// 六文件面里"只有主题读"的成员——失败确定落在 spawn 而非管线预检)
if (s === 'building') {
failingJobId = jobId
rmSync(join(artifactsRoot, 'jobs', jobId, 'input', 'content.json'), { force: true })
}
},
}).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(Error)
expect((err as Error).message).toContain('astro')
// job 行:failed 终态 + 错误面(Windows 判据:非零退出 + stderr 标记,不断言精确码)
const job = plain(
(await db.execute(`select status, error_code, error_summary from build_jobs
where id = '${failingJobId}'`)) as any,
)
expect(job).toHaveLength(1)
expect(job[0].status).toBe('failed')
expect(job[0].error_code).toBe('THEME_BUILD_FAILED')
expect(job[0].error_summary).toContain('Render Snapshot 文件缺失或不可读')
expect(job[0].error_summary).toContain('content.json')
// dist 清残:产物目录与主题暂存 dist 均无残留;input/ 与 route-manifest.json
// 同样清场(failed 无产物语义——复审 Fix 3:清残面覆盖 manifest 文件)
expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'dist'))).toBe(false)
expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'route-manifest.json'))).toBe(false)
expect(existsSync(themeDistDir())).toBe(false)
expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'input'))).toBe(false)
// 半途失败无 build_artifacts 行
expect(
await countOf(db, `select count(*)::int as n from build_artifacts where build_job_id = '${failingJobId}'`),
).toBe(0)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})
describe('主题夹具直跑管线(Task 3 render-input 作 SF_BUILD_INPUT)', () => {
it('编码 URL sitemap 原样 + .html 字面 + 3 媒体全量 sha256 + redirect 行;暂存 dist 清空', { timeout: 240_000 }, async () => {
await withTestDb(async (db) => {
const w = await mkdtemp(join(tmpdir(), 'sf-pipe-fixture-'))
const siteFix = await createSiteFixture(db, 'sfpipe')
try {
// 手构 job 行(building 态——管线直跑不经过 service 状态机)
const job = (await db.execute(`insert into build_jobs (id, site_id, status, idempotency_key, attempt)
values (gen_random_uuid(), '${siteFix.siteId}', 'building', 'build-pipe-${siteFix.rand}', 1)
returning id`)) as any
const jobId = job[0].id
// 输入 = 主题夹具六文件 + 真媒体注入(copyPath/sha256/byteSize 换真值——夹具占位哈希无对应实体)
const inputDir = join(w, 'input')
await mkdir(inputDir, { recursive: true })
for (const name of ['site.json', 'content.json', 'taxonomies.json', 'routes.json', 'render-manifest.json']) {
await copyFile(join(THEME_FIXTURE_INPUT, name), join(inputDir, name))
}
const mediaFiles: Array<{ pub: string; buf: Buffer }> = [
{ pub: '/wp-content/uploads/2026/10/fixture-a.png', buf: mediaBytes(`fx-a-${rand()}`) },
{ pub: '/wp-content/uploads/2026/10/fixture-b.jpg', buf: mediaBytes(`fx-b-${rand()}`) },
{ pub: '/wp-content/uploads/2026/10/fixture-c.jpg', buf: mediaBytes(`fx-c-${rand()}`) },
]
const binDir = join(w, 'media-bin')
await mkdir(binDir, { recursive: true })
const assets = JSON.parse(await readFile(join(THEME_FIXTURE_INPUT, 'assets.json'), 'utf8'))
expect(assets).toHaveLength(3)
const rewritten: any[] = []
for (let i = 0; i < assets.length; i++) {
const m = mediaFiles[i]!
const file = join(binDir, `media-${i}.bin`)
await writeFile(file, m.buf)
rewritten.push({ ...assets[i], copyPath: file, sha256: sha256of(m.buf), byteSize: m.buf.byteLength })
}
await writeFile(join(inputDir, 'assets.json'), `${canonicalJson(rewritten)}\n`, 'utf8')
const artifactsRoot = join(w, 'artifacts')
const result = await runBuildPipeline(db, { jobId, siteId: siteFix.siteId, inputDir, artifactsRoot })
expect(result.mediaCopied).toBe(3)
expect(result.sitemapEntries).toBe(14)
const distDir = join(artifactsRoot, 'jobs', jobId, 'dist')
expect(result.distPath).toBe(distDir)
const routes = JSON.parse(await readFile(join(inputDir, 'routes.json'), 'utf8'))
// 全量落盘映射:14 html 行 target_file 存在且 isFile(含编码目录与 .html 字面)
const htmlRoutes = routes.filter((r: any) => r.routeKind === 'html' && r.queryKey === null)
expect(htmlRoutes).toHaveLength(14)
for (const r of htmlRoutes) {
expect(statSync(join(distDir, distRel(r.routePath))).isFile(), r.routePath).toBe(true)
}
const encoded = '%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac'
expect(statSync(join(distDir, encoded, 'index.html')).isFile()).toBe(true)
expect(statSync(join(distDir, 'news', 'fixture-news-a.html')).isFile()).toBe(true)
// 3 媒体全量 sha256 校验(非抽检——夹具 3 条全对)
for (const m of mediaFiles) {
expect(sha256of(await readFile(join(distDir, m.pub)))).toBe(sha256of(m.buf))
}
// sitemap:编码 URL 原样(%xx 字节不变、无解码孪生、无二次编码)+ 条目数断言
const origin = 'https://demo.example'
const sitemap = await readFile(join(distDir, 'sitemap.xml'), 'utf8')
const locs = [...sitemap.matchAll(/<loc>(.*?)<\/loc>/g)].map((m) => m[1]!)
expect(locs).toHaveLength(14)
expect(locs).toContain(`${origin}/${encoded}/`)
expect(sitemap).not.toContain('关于我们')
expect(sitemap).not.toContain('%25e5')
expect(await readFile(join(distDir, 'robots.txt'), 'utf8')).toBe(
`User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`,
)
// route-manifest:15 行全量(html 14 + redirect 1)+ redirect 字段面
const manifest = JSON.parse(await readFile(result.routeManifestPath, 'utf8'))
expect(isRouteManifest(manifest)).toBe(true)
expect(manifest.routes).toHaveLength(15)
expect(manifest.site_id).toBe(siteFix.siteId)
expect(manifest.build_job_id).toBe(jobId)
expect(manifest.origin).toBe(origin)
const redirect = manifest.routes.find((r: any) => r.exact_key === '/old-path/')
expect(redirect).toStrictEqual({
exact_key: '/old-path/',
query_policy: 'ignore',
query_key: null,
kind: 'redirect',
status: 301,
location: '/',
indexable: false,
})
// build_artifacts 两行(dist/manifest——report 行是 service 落报告后的职责)
const rows = plain(
(await db.execute(`select kind from build_artifacts where build_job_id = '${jobId}' order by kind`)) as any,
)
expect(rows.map((r) => r.kind)).toStrictEqual(['dist', 'manifest'])
expect(rows.length).toBe(2)
// 暂存 dist 清空(主题包目录回零——产物权威在 artifacts 根)
expect(existsSync(themeDistDir())).toBe(false)
} finally {
await db.execute(`delete from build_artifacts where build_job_id in
(select id from build_jobs where site_id = '${siteFix.siteId}')`)
await db.execute(`delete from build_jobs where site_id = '${siteFix.siteId}'`)
await siteFix.cleanup()
await rm(w, { recursive: true, force: true })
}
})
})
})
describe('五禁断言(主题构建期纪律)', () => {
it('① loader 单一入口:主题 src 数据访问面只在 load-render-data;④ 无 seed;⑤ 不读源快照', async () => {
const src = await scanThemeSrc()
expect(src.size).toBeGreaterThan(0)
// ① fs/DB/env 数据访问只准出现在 lib/load-render-data.ts(其余模块经它取数)
const dataAccess = /node:fs|readFileSync|DATABASE_URL|postgres\b|from\s+'pg'/
const offenders = [...src.entries()].filter(([, c]) => dataAccess.test(c)).map(([p]) => p)
expect(offenders).toStrictEqual(['lib/load-render-data.ts'])
// 动态分派页确实经唯一入口取数(结构保证不是纸面声明)
expect(src.get('pages/[...slug].astro')).toContain("from '../lib/load-render-data'")
// ④ fixtures 引用只在 test/(src 零命中——无站点专属 seed)
expect([...src.entries()].filter(([, c]) => /fixtures/i.test(c))).toStrictEqual([])
// ⑤ 源快照路径零命中(storage/snapshots 是 register 域布局,主题不读原始输入)
expect([...src.entries()].filter(([, c]) => /storage[\\/](snapshots|incoming)/.test(c))).toStrictEqual([])
})
it('② DATABASE_URL 置空 + ③ 无网 stub(global fetch/net.connect 抛错)→ 构建仍成功', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-pipe-nonet')
// 无网 stub:--import 预载——构建进程树内任何 fetch/net.connect 即抛错
const stub = join(w.work, 'no-net-stub.mjs')
await writeFile(
stub,
[
'// SiteForge 无网证明 stub(Plan 4 Task 5 五禁⑤):构建进程树不得联网。',
'// 只改 net.default.*(CJS module.exports——require 消费方所见);ESM 命名空间导出',
'// 是只读绑定,直接赋值 net.connect 会在预载期崩溃(实测 TypeError)。',
"const boom = () => { throw new Error('sf-no-network: 构建进程树出现 fetch/net.connect 调用') }",
'globalThis.fetch = () => boom()',
"const net = await import('node:net')",
'net.default.connect = () => boom()',
'net.default.createConnection = () => boom()',
].join('\n'),
'utf8',
)
const savedUrl = process.env.DATABASE_URL
const savedNodeOptions = process.env.NODE_OPTIONS
process.env.DATABASE_URL = ''
process.env.NODE_OPTIONS = `--import ${pathToFileURL(stub).href}`
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
const result = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(w.work, 'artifacts'),
})
expect(result.status).toBe('succeeded')
// 构建真实发生且产物落盘(spawn 树在 stub 钳制下完成——主题/astro 构建期不联网)
const distDir = join(w.work, 'artifacts', 'jobs', result.jobId, 'dist')
expect(statSync(join(distDir, 'index.html')).isFile()).toBe(true)
expect(statSync(join(distDir, 'sitemap.xml')).isFile()).toBe(true)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
process.env.DATABASE_URL = savedUrl
process.env.NODE_OPTIONS = savedNodeOptions
await rm(w.work, { recursive: true, force: true })
}
})
})
})