Files
浅念 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

554 lines
29 KiB
TypeScript
Raw Permalink 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 {
cleanupSiteRows,
createSiteFixture,
makeWork,
rand,
rejectionMessage,
seedPublishedSite,
withTestDb,
type Db,
} from './helpers.js'
/** 本文件位于 <repo>/tests/integration/ → 上两级是仓库根 */
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
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
}
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, 'sfpipe')
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 序(物化排序确定);键为线形(可打印 ASCII 原样——mini 夹具
// 全 ASCII,与非 ASCII 归一规则的同构镜像见"主题夹具直跑"用例的字面断言)
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, 'sfpipe')
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', '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()}`) },
]
// 非 ASCII 资产键回归(Task 8 spec 复审修补):真实 hesupipe 有 7 例文件名含全角
// 括号/顿号/间隔号的资产——routePath 是裸形(DB/文件系统域),而浏览器对裸非 ASCII
// 一律百分号编码后发请求,manifest exact_key 若保持裸形则永不命中(预览 404、
// redirect Location 跟随断裂)。此处注入裸形资产行,断言 manifest 键归一为编码线形。
const rawAssetPath = '/wp-content/uploads/2026/10/HDPE-carat-pipe-(1).jpg'
mediaFiles.push({ pub: rawAssetPath, buf: mediaBytes(`fx-fw-${rand()}`) })
const routes = JSON.parse(await readFile(join(THEME_FIXTURE_INPUT, 'routes.json'), 'utf8'))
routes.push({
host: '',
routePath: rawAssetPath,
routeKind: 'asset',
queryPolicy: 'ignore',
queryKey: null,
targetStatus: '200',
targetContentId: null,
targetFile: rawAssetPath,
redirectStatus: null,
redirectLocation: null,
indexable: false,
priority: 0,
verificationStatus: 'verified_asset',
})
await writeFile(join(inputDir, 'routes.json'), `${canonicalJson(routes)}\n`, 'utf8')
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 < mediaFiles.length; i++) {
const m = mediaFiles[i]!
const file = join(binDir, `media-${i}.bin`)
await writeFile(file, m.buf)
rewritten.push(
i < assets.length
? { ...assets[i], copyPath: file, sha256: sha256of(m.buf), byteSize: m.buf.byteLength }
: {
publicPath: m.pub,
storagePath: '',
copyPath: file,
sha256: sha256of(m.buf),
detectedMimeType: 'image/jpeg',
byteSize: m.buf.byteLength,
width: null,
height: null,
altText: null,
},
)
}
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(4)
expect(result.sitemapEntries).toBe(14)
const distDir = join(artifactsRoot, 'jobs', jobId, 'dist')
expect(result.distPath).toBe(distDir)
// 全量落盘映射: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)
// 4 媒体全量 sha256 校验(非抽检——夹具 3 条 + 注入裸全角名 1 条全对;
// dist 落盘用 public_path 裸形——文件系统域不解码)
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:16 行全量(html 14 + redirect 1 + asset 1)+ redirect 字段面
const manifest = JSON.parse(await readFile(result.routeManifestPath, 'utf8'))
expect(isRouteManifest(manifest)).toBe(true)
expect(manifest.routes).toHaveLength(16)
expect(manifest.site_id).toBe(siteFix.siteId)
expect(manifest.build_job_id).toBe(jobId)
expect(manifest.origin).toBe(origin)
// 键线形不变量:全 manifest exact_key 纯 ASCII(裸非 ASCII 键永不匹配浏览器请求形)
expect(manifest.routes.every((r: any) => /^[\x20-\x7e]*$/.test(r.exact_key))).toBe(true)
// 非 ASCII 资产键归一:exact_key = UTF-8 百分号编码线形(大写 hex——WHATWG URL/
// 浏览器编码形态,既有 %xx 序列原样保留不二次编码);target_file 保持裸形(盘上名)
const nonAsciiAsset = manifest.routes.find(
(r: any) => r.exact_key === '/wp-content/uploads/2026/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89.jpg',
)
expect(nonAsciiAsset).toMatchObject({
exact_key: '/wp-content/uploads/2026/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89.jpg',
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: rawAssetPath,
status: 200,
content_type: 'image/jpeg',
indexable: false,
})
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, 'sfpipe')
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 })
}
})
})
})