5c2ee7dc5d
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>
554 lines
29 KiB
TypeScript
554 lines
29 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 {
|
||
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 })
|
||
}
|
||
})
|
||
})
|
||
})
|