8f2b9825a6
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>
568 lines
30 KiB
TypeScript
568 lines
30 KiB
TypeScript
// 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 })
|
||
}
|
||
})
|
||
})
|
||
})
|