feat(build): astro 构建管线与媒体落盘

Plan 4 Task 5:build.service snapshotting 后接真实构建——building 态进
pipeline.ts(spawn `pnpm --filter @sf/theme-pilot-minimal exec astro build`
(SF_BUILD_INPUT env;非零退出+stderr 标记判据)→ dist 定位与 target_file
完整性 → 媒体 copyPath→dist/public_path(sha256 全量校验)→ sitemap.xml/
robots.txt(编码 URL 原样)→ route-manifest.json(§12.1 字段面、哈希链复用)
→ build_artifacts dist/manifest 登记)→ validating(空,Task 6) → 报告+report
行 → succeeded;失败任何一步 failed+error_code/error_summary(stderr 摘要)
+input/dist 清残。

dist 双位置纪律:主题包 dist 是暂存,产物汇齐后拷出 <artifactsRoot>/jobs/
<jobId>/dist(job 级权威),暂存随即清空。无网证明:ASTRO_TELEMETRY_DISABLED=1
(实测 @astrojs/telemetry 构建期真调 fetch)+ NODE_OPTIONS 透传(测试注入
fetch/net stub 构建仍成功)。主题集成补面:ProductList 模板 + /products/
主列表分派特判(DB 物化形状 targetContentId=null,Task 4 夹具曾被占位绕过)。

测试:tests/integration/build-pipeline.test.ts 五用例(全链/夹具直跑/五禁
断言/失败路径);build-snapshot 状态机补 building + 清场补 build_artifacts。
root 359 + theme 26 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 10:52:13 +08:00
parent 107642c1b3
commit 1ed3ac062a
8 changed files with 1200 additions and 30 deletions
+561
View File
@@ -0,0 +1,561 @@
// 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
}
/** 本文件写入的行清场:构建域(build_artifacts 先于 build_jobs——FK)+ 全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
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/ 同样清场(failed 无产物语义)
expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'dist'))).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 })
}
})
})
})
+8 -5
View File
@@ -1,7 +1,7 @@
// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。
// 覆盖契约(对照计划 Task 2):
// 1. build job 状态机:queued→snapshotting→validating(空,Task 6 填)→succeeded(dist 尚空
// ——Task 5 接真实构建时新建 job 不改行,build_input_snapshots 有不可变触发器)+
// 1. build job 状态机:queued→snapshotting→building(Task 5 真实构建)→validating
// (空,Task 6 填)→succeeded +
// build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+
// 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、
// theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+
@@ -71,8 +71,10 @@ async function countOf(db: Db, sql: string): Promise<number> {
return Number(rows[0].n)
}
/** 本文件写入的行清场:构建域(不可变快照先行)+ 发布/导入/来源全链反序 */
/** 本文件写入的行清场:构建域(build_artifacts 先于 build_jobs——FK;不可变快照先行)+ 发布/导入/来源全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
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}'`)
@@ -158,8 +160,9 @@ describe('build job + Render Snapshot 物化', () => {
})
expect(result.reused).toBe(false)
expect(result.status).toBe('succeeded')
// 状态机序列(validating 空:Task 6 填真实门禁)
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'validating', 'succeeded'])
// 状态机序列(Task 5 起 snapshotting 后进 building:真实 astro 构建管线;
// validating 空:Task 6 填真实门禁)
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
// build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言;
// raw execute 的 timestamptz 回串不做 Date 形状断言)