// 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' /** 本文件位于 /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 { const rows = (await db.execute(sql)) as any return Number(rows[0].n) } /** 路由 → dist 相对文件路径(映射规则:尾斜杠→/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 => { 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> { const out = new Map() const walk = async (d: string): Promise => { 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 { 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 { 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>/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('')).toBe(true) expect(sitemap).toContain('') // ---- 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) 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>/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) 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 }) } }) }) })