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:
@@ -1,10 +1,11 @@
|
||||
// build_jobs 生命周期服务(Plan 4 Task 2,V2 spec §9、ADR-010/ADR-011):
|
||||
// build_jobs 生命周期服务(Plan 4 Task 2/5,V2 spec §9、ADR-010/ADR-011):
|
||||
// 解析站点 → publish 状态检查(快照只读 published——空发布集无物化意义)→ 幂等建
|
||||
// job(queued)→ snapshotting(render-snapshot.ts 纯物化六文件 + build_input_snapshots
|
||||
// 行,repeatable read 单事务一致性读取)→ validating(**空门禁**——Task 6 填真实
|
||||
// 门禁;Task 5 接真实构建时**新建 job 不改行**:build_input_snapshots 有不可变触发器)
|
||||
// → succeeded;报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json(temp+rename
|
||||
// 原子写、**先于终态落行**——import 同款纪律)。
|
||||
// 行,repeatable read 单事务一致性读取)→ **building(Task 5:pipeline.ts 真实构建
|
||||
// ——spawn astro → 媒体复制 → sitemap/robots → route-manifest → build_artifacts
|
||||
// dist/manifest 登记)**→ validating(**空门禁**——Task 6 填真实门禁)→ succeeded;
|
||||
// 报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json + build_artifacts report 行
|
||||
// (temp+rename 原子写、**先于终态落行**——import 同款纪律)。
|
||||
//
|
||||
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用**
|
||||
// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/
|
||||
@@ -33,9 +34,17 @@ import { fileURLToPath } from 'node:url'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import { canonicalJson } from '../../common/canonical.js'
|
||||
import { buildInputSnapshots, buildJobs, entryLocales, siteConfigRevisions, sites } from '../../db/schema.js'
|
||||
import {
|
||||
buildArtifacts,
|
||||
buildInputSnapshots,
|
||||
buildJobs,
|
||||
entryLocales,
|
||||
siteConfigRevisions,
|
||||
sites,
|
||||
} from '../../db/schema.js'
|
||||
import type * as schema from '../../db/schema.js'
|
||||
import { isUniqueViolation } from '../source/register.service.js'
|
||||
import { PipelineError, runBuildPipeline, themeDistDir, type BuildPipelineResult } from './pipeline.js'
|
||||
import { materializeRenderSnapshot, type RewriteStats } from './render-snapshot.js'
|
||||
|
||||
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
|
||||
@@ -51,6 +60,7 @@ export type BuildErrorCode =
|
||||
| 'NO_SITE_CONFIG'
|
||||
| 'SNAPSHOT_FAILED'
|
||||
| 'REPORT_WRITE_FAILED'
|
||||
| 'ARTIFACT_REGISTER_FAILED'
|
||||
|
||||
/** 构建失败(可预期错误):CLI 以 code 区分呈现;不裹程序 bug(那些原样上抛) */
|
||||
export class BuildError extends Error {
|
||||
@@ -106,6 +116,8 @@ export interface CreateBuildJobResult {
|
||||
counts?: { contents: number; taxonomies: number; routes: number; assets: number }
|
||||
urlRewrite?: RewriteStats
|
||||
issues?: string[]
|
||||
/** Task 5 产物面(dist/route-manifest 路径与哈希 + 计数)——复用路径无值(产物在原 job 报告) */
|
||||
artifacts?: BuildPipelineResult
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
@@ -135,6 +147,16 @@ export interface BuildReport {
|
||||
}
|
||||
files: Array<{ name: string; path: string; sha256: string; byteSize: number }>
|
||||
urlRewrite: RewriteStats
|
||||
/** Task 5 产物面(与 build_artifacts 行互证) */
|
||||
artifacts: {
|
||||
distPath: string
|
||||
distSha256: string
|
||||
distFileCount: number
|
||||
routeManifestPath: string
|
||||
routeManifestSha256: string
|
||||
sitemapEntries: number
|
||||
mediaCopied: number
|
||||
}
|
||||
issues: string[]
|
||||
durationMs: number
|
||||
}
|
||||
@@ -227,9 +249,11 @@ const RETRY_KEY_BOUND = 100
|
||||
* 解析(site → published 检查 → config 检查)→ 幂等建行(queued;**只有 succeeded
|
||||
* 可复用**——终态死行/崩溃残留按阶段 3 注记的策略自动恢复)→ snapshotting
|
||||
* (repeatable read 单事务:物化 + 六文件落盘 + build_input_snapshots 行)→
|
||||
* validating(空——Task 6)→ 报告落盘 → succeeded。
|
||||
* dist 产物与 build_artifacts 登记是 Task 5 职责(本任务 dist 尚空)。
|
||||
* 快照后失败 = job 行 failed 终态 + 半途 input/ 目录清场 + 原样上抛。
|
||||
* building(pipeline.ts:spawn astro → 媒体复制 → sitemap/robots → route-manifest
|
||||
* → build_artifacts dist/manifest 两行)→ validating(空——Task 6)→ 报告落盘 +
|
||||
* build_artifacts report 行 → succeeded。
|
||||
* 任一阶段失败 = job 行 failed 终态(PipelineError.code 进 error_code、stderr 标记进
|
||||
* error_summary)+ 半途产物清场(input/ 与 dist/——失败无产物语义)+ 原样上抛。
|
||||
*/
|
||||
export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): Promise<CreateBuildJobResult> {
|
||||
const startedAt = performance.now()
|
||||
@@ -387,11 +411,23 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
{ isolationLevel: 'repeatable read' },
|
||||
)
|
||||
|
||||
// 阶段 6:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
|
||||
// 阶段 6:building——真实构建(Task 5 pipeline.ts:spawn/媒体/sitemap/manifest/
|
||||
// build_artifacts dist+manifest 登记)。失败以 PipelineError 上抛(code 进 error_code,
|
||||
// stderr 标记进 error_summary——收口见下方 catch)。
|
||||
await db.update(buildJobs).set({ status: 'building' }).where(eq(buildJobs.id, jobId))
|
||||
input.onStatus?.('building', jobId)
|
||||
const built = await runBuildPipeline(db, {
|
||||
jobId: jobId!,
|
||||
siteId: site.id,
|
||||
inputDir,
|
||||
artifactsRoot,
|
||||
})
|
||||
|
||||
// 阶段 7:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
|
||||
await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId))
|
||||
input.onStatus?.('validating', jobId)
|
||||
|
||||
// 阶段 7:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
|
||||
// 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
|
||||
const report: BuildReport = {
|
||||
jobId,
|
||||
generatedAt: buildTimestamp.toISOString(),
|
||||
@@ -425,12 +461,22 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
byteSize: Buffer.byteLength(content, 'utf8'),
|
||||
})),
|
||||
urlRewrite: mat.urlRewrite,
|
||||
artifacts: {
|
||||
distPath: built.distPath,
|
||||
distSha256: built.distSha256,
|
||||
distFileCount: built.distFileCount,
|
||||
routeManifestPath: built.routeManifestPath,
|
||||
routeManifestSha256: built.routeManifestSha256,
|
||||
sitemapEntries: built.sitemapEntries,
|
||||
mediaCopied: built.mediaCopied,
|
||||
},
|
||||
issues: mat.issues,
|
||||
durationMs: performance.now() - startedAt,
|
||||
}
|
||||
const reportText = `${JSON.stringify(report, null, 2)}\n`
|
||||
try {
|
||||
await mkdir(reportDir, { recursive: true })
|
||||
await writeAtomically(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
await writeAtomically(reportPath, reportText)
|
||||
} catch (err) {
|
||||
throw new BuildError(
|
||||
'REPORT_WRITE_FAILED',
|
||||
@@ -438,7 +484,23 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
)
|
||||
}
|
||||
|
||||
// 阶段 8:succeeded 终态
|
||||
// 阶段 9:report 行登记(build_artifacts 第三行——报告文件已落盘,行内即文件在)
|
||||
try {
|
||||
await db.insert(buildArtifacts).values({
|
||||
buildJobId: jobId,
|
||||
kind: 'report',
|
||||
storagePath: reportPath,
|
||||
sha256: createHash('sha256').update(reportText, 'utf8').digest('hex'),
|
||||
byteSize: Buffer.byteLength(reportText, 'utf8'),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new BuildError(
|
||||
'ARTIFACT_REGISTER_FAILED',
|
||||
`report 产物登记失败(job=${jobId}):${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 阶段 10:succeeded 终态
|
||||
await db.update(buildJobs).set({ status: 'succeeded', finishedAt: new Date() }).where(eq(buildJobs.id, jobId))
|
||||
input.onStatus?.('succeeded', jobId)
|
||||
|
||||
@@ -454,23 +516,29 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
||||
counts: mat.counts,
|
||||
urlRewrite: mat.urlRewrite,
|
||||
issues: mat.issues,
|
||||
artifacts: built,
|
||||
durationMs: performance.now() - startedAt,
|
||||
}
|
||||
} catch (err) {
|
||||
const code = err instanceof BuildError ? err.code : 'SNAPSHOT_FAILED'
|
||||
// 阶段敏感的错误码映射:BuildError 保自身码(解析/报告/登记);PipelineError 带
|
||||
// 管线阶段码(spawn/媒体/manifest——stderr 标记在 message 内一并入 error_summary);
|
||||
// 其余(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。
|
||||
const code = err instanceof BuildError ? err.code : err instanceof PipelineError ? err.code : 'SNAPSHOT_FAILED'
|
||||
const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
await db
|
||||
.update(buildJobs)
|
||||
.set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` })
|
||||
.where(eq(buildJobs.id, jobId))
|
||||
// 失败清场(审查 Fix 3):半途落盘的 input/ 目录移除——failed 终态无产物语义,
|
||||
// 不留"行说 failed 而目录半存"的歧义现场。快照行若已插入则由不可变触发器保留为
|
||||
// 审计记录(manifest_path 悬空可接受:job 已 failed,无下游消费方)。清场失败
|
||||
// 不掩盖原始错误(残留目录由操作者处置)。
|
||||
try {
|
||||
await rm(inputDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort:保留原错误上抛
|
||||
// 失败清场(审查 Fix 3 + Task 5 扩面):半途落盘的 input/ 与 dist/(job 级产物目录
|
||||
// + 主题包暂存 dist)移除——failed 终态无产物语义,不留"行说 failed 而目录半存"
|
||||
// 的歧义现场。快照行若已插入则由不可变触发器保留为审计记录(manifest_path 悬空
|
||||
// 可接受:job 已 failed,无下游消费方)。清场失败不掩盖原始错误(残留目录由操作者处置)。
|
||||
for (const dir of [inputDir, join(artifactsRoot, 'jobs', jobId, 'dist'), themeDistDir()]) {
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort:保留原错误上抛
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
// 构建编排管线(Plan 4 Task 5,V2 spec §9.3/§12.1、快线 §8.4):
|
||||
// spawn astro 构建(SF_BUILD_INPUT 六文件输入)→ dist 定位(主题包 outDir)→
|
||||
// 媒体复制(copyPath→dist/public_path,sha256 全量校验失配即败)→ sitemap.xml/
|
||||
// robots.txt(origin 来自 site.json)→ route-manifest.json(§12.1 字段面,哈希链复用
|
||||
// render-manifest 的 route 快照哈希)→ build_artifacts 登记(dist/manifest 两行;
|
||||
// report 行是 build.service 落报告后的职责)。
|
||||
//
|
||||
// 本文件是"纯编排段":不做 job 状态机推进(building→validating→succeeded 与失败收口
|
||||
// 都在 build.service.ts),失败以 PipelineError 上抛(code 区分阶段,message 带
|
||||
// stderr 摘要——Windows 下 pnpm 对子进程失败会叠 127/EPERM/libuv 噪音,判据是
|
||||
// **非零退出 + stderr 标记**,不断言精确退出码)。
|
||||
//
|
||||
// dist 双位置纪律:主题包内 dist(astro outDir)是**暂存**——spawn 产物 + 媒体 +
|
||||
// sitemap/robots 在此汇齐后整体拷出到 <artifactsRoot>/jobs/<jobId>/dist(job 级
|
||||
// 权威产物,行内即文件在),暂存随即清空(成功失败都清——不留跨 job 共享可变态)。
|
||||
// 跨盘拷贝用 copy 不用 rename(artifacts 根常在另一卷,rename 撞 EXDEV)。
|
||||
//
|
||||
// 无网纪律(五禁③的实现侧配合):spawn env 显式 ASTRO_TELEMETRY_DISABLED=1——
|
||||
// @astrojs/telemetry 在构建期真调 global fetch POST(实测撞无网 stub);离线构建
|
||||
// 纪律下遥测必须关。NODE_OPTIONS 原样透传(spread 继承 + 本管线不自加标志的合并
|
||||
// 语义):测试可注入 `--import <stub>` 预载钩子证明构建进程树不联网。
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import {
|
||||
ROUTE_MANIFEST_VERSION,
|
||||
type AssetEntry,
|
||||
type RouteEntry,
|
||||
type RouteManifest,
|
||||
type RouteManifestRoute,
|
||||
} from '@sf/contracts'
|
||||
import { buildArtifacts } from '../../db/schema.js'
|
||||
import type * as schema from '../../db/schema.js'
|
||||
|
||||
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
|
||||
|
||||
/** Plan 4 唯一主题:包全名 filter(Task 3 备忘——勿用目录名短 filter) */
|
||||
const THEME_PACKAGE = '@sf/theme-pilot-minimal'
|
||||
const THEME_DIR = join(REPO_ROOT, 'themes', 'pilot-minimal')
|
||||
|
||||
/** 主题包内暂存 dist(astro outDir='./dist')——产物汇齐后拷出、随即清空 */
|
||||
export function themeDistDir(): string {
|
||||
return join(THEME_DIR, 'dist')
|
||||
}
|
||||
|
||||
export type PipelineDb = NodePgDatabase<typeof schema>
|
||||
|
||||
export type PipelineErrorCode = 'INPUT_INVALID' | 'THEME_BUILD_FAILED' | 'MEDIA_COPY_FAILED' | 'MANIFEST_WRITE_FAILED'
|
||||
|
||||
/** 管线阶段失败(可预期错误):code 区分阶段;stderr 摘要进 message(落 error_summary) */
|
||||
export class PipelineError extends Error {
|
||||
readonly code: PipelineErrorCode
|
||||
constructor(code: PipelineErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'PipelineError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunBuildPipelineInput {
|
||||
jobId: string
|
||||
siteId: string
|
||||
/** 六文件输入目录(SF_BUILD_INPUT——build_input_snapshots 物化产物) */
|
||||
inputDir: string
|
||||
/** 产物根:<root>/jobs/<jobId>/{input,dist,reports,route-manifest.json} */
|
||||
artifactsRoot: string
|
||||
}
|
||||
|
||||
export interface BuildPipelineResult {
|
||||
distPath: string
|
||||
/** 目录哈希(公式见 hashDirectory——递归文件清单 sha256) */
|
||||
distSha256: string
|
||||
distByteSize: number
|
||||
distFileCount: number
|
||||
routeManifestPath: string
|
||||
routeManifestSha256: string
|
||||
routeManifestByteSize: number
|
||||
sitemapEntries: number
|
||||
mediaCopied: number
|
||||
}
|
||||
|
||||
// ---- 输入读取 ----
|
||||
|
||||
async function readJsonInput(inputDir: string, file: string): Promise<unknown> {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(join(inputDir, file), 'utf8')
|
||||
} catch (err) {
|
||||
throw new PipelineError(
|
||||
'INPUT_INVALID',
|
||||
`管线输入缺失或不可读:${join(inputDir, file)}(${err instanceof Error ? err.message : String(err)})`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (err) {
|
||||
throw new PipelineError(
|
||||
'INPUT_INVALID',
|
||||
`管线输入不是合法 JSON:${file}(${err instanceof Error ? err.message : String(err)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- spawn ----
|
||||
|
||||
/** spawn 超时上界(含 pnpm 解析与 astro 冷启——Windows 实测全链 ~10s,宽限到分钟级) */
|
||||
const SPAWN_TIMEOUT_MS = 300_000
|
||||
/** stdout/stderr 各自的累积上界(超出丢弃并标记截断——防失控输出撑爆内存) */
|
||||
const STREAM_CAP = 2 * 1024 * 1024
|
||||
|
||||
function spawnEnv(inputDir: string): NodeJS.ProcessEnv {
|
||||
// NODE_OPTIONS 合并语义:spread 继承调用方(含测试注入的 --import 预载),本管线
|
||||
// 不追加不清洗;自己的覆盖只有 SF_BUILD_INPUT 与遥测关闭(见模块头无网纪律注记)。
|
||||
return {
|
||||
...process.env,
|
||||
SF_BUILD_INPUT: inputDir,
|
||||
ASTRO_TELEMETRY_DISABLED: '1',
|
||||
}
|
||||
}
|
||||
|
||||
/** astro 构建 spawn:cwd=仓库根(pnpm workspace 解析),退出非零即败(stderr 摘要入 message) */
|
||||
function spawnAstroBuild(inputDir: string): Promise<void> {
|
||||
return new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
let settled = false
|
||||
let stderr = ''
|
||||
let stderrTruncated = false
|
||||
const child = spawn('pnpm', ['--filter', THEME_PACKAGE, 'exec', 'astro', 'build'], {
|
||||
cwd: REPO_ROOT,
|
||||
shell: true, // Windows:pnpm 是 pnpm.cmd,须经 shell 解析(参数均为无空格字面量)
|
||||
env: spawnEnv(inputDir),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
if (!settled) {
|
||||
settled = true
|
||||
rejectPromise(
|
||||
new PipelineError('THEME_BUILD_FAILED', `astro 构建超时(>${SPAWN_TIMEOUT_MS}ms)——SIGKILL 终结`),
|
||||
)
|
||||
}
|
||||
}, SPAWN_TIMEOUT_MS)
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (stderr.length >= STREAM_CAP) {
|
||||
stderrTruncated = true
|
||||
return
|
||||
}
|
||||
stderr += chunk.toString('utf8')
|
||||
if (stderr.length > STREAM_CAP) {
|
||||
stderr = stderr.slice(0, STREAM_CAP)
|
||||
stderrTruncated = true
|
||||
}
|
||||
})
|
||||
// stdout 只排水不累积(页面清单对诊断无增量价值;异常路径的信号在 stderr)
|
||||
child.stdout?.on('data', () => {})
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
if (!settled) {
|
||||
settled = true
|
||||
rejectPromise(new PipelineError('THEME_BUILD_FAILED', `astro 构建 spawn 失败:${err.message}`))
|
||||
}
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (code === 0) {
|
||||
resolvePromise()
|
||||
return
|
||||
}
|
||||
// 判据 = 非零退出 + stderr 标记(Windows pnpm 会叠 127/libuv 噪音——勿匹配精确码)。
|
||||
// 摘要取"头 500 + 尾 4000":astro 的错误块(消息+栈)靠 stderr 尾部,但纯尾截会把
|
||||
// 标记行拦腰截断(消息在错误块头部)——头部保上下文,尾部保完整错误块。
|
||||
const head = stderr.length > 4500 ? `${stderr.slice(0, 500)}\n…(中段省略)\n` : ''
|
||||
const tail = stderr.length > 4000 ? stderr.slice(-4000) : stderr
|
||||
const excerpt = `${head}${tail}`
|
||||
const truncNote = stderrTruncated ? '(stderr 超 2MB 已截断)' : ''
|
||||
rejectPromise(
|
||||
new PipelineError(
|
||||
'THEME_BUILD_FAILED',
|
||||
`astro 构建失败(exit=${String(code)} signal=${String(signal)})${truncNote}——stderr 摘要:\n${excerpt}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- dist 后处理 ----
|
||||
|
||||
/**
|
||||
* 路由 → dist 相对产物路径(映射规则唯一权威——route-writer 的 html target_file 落 NULL
|
||||
* 即"由构建器落地";与主题侧 sf-literal-html-paths 折平集成对齐):
|
||||
* '/' → index.html;尾斜杠 → <path>/index.html;.html 后缀 → 字面文件。
|
||||
*/
|
||||
export function deriveTargetFile(routePath: string): string {
|
||||
if (routePath === '/') return 'index.html'
|
||||
const rel = routePath.replace(/^\//, '').replace(/\/+$/, '')
|
||||
return rel.endsWith('.html') ? rel : `${rel}/index.html`
|
||||
}
|
||||
|
||||
/** temp+rename 原子写(与 build.service 同款纪律——不留半成品) */
|
||||
async function writeAtomically(path: string, content: string): Promise<void> {
|
||||
const tmpPath = `${path}.tmp`
|
||||
try {
|
||||
await writeFile(tmpPath, content, 'utf8')
|
||||
await rename(tmpPath, path)
|
||||
} catch (err) {
|
||||
await rm(tmpPath, { force: true })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 媒体复制:copyPath→dist/public_path;**每条**落盘后 sha256 全量校验(非抽检)失配即败 */
|
||||
async function copyMedia(distDir: string, assets: AssetEntry[]): Promise<number> {
|
||||
for (const a of assets) {
|
||||
// public_path 必须是根相对安全路径(media-writer 上游已按此登记——此处守越界写)
|
||||
if (!a.publicPath.startsWith('/') || a.publicPath.split('/').includes('..')) {
|
||||
throw new PipelineError(
|
||||
'MEDIA_COPY_FAILED',
|
||||
`媒体 public_path 非根相对安全路径(${a.publicPath})——拒绝越界落盘`,
|
||||
)
|
||||
}
|
||||
const src = resolve(REPO_ROOT, a.copyPath)
|
||||
const dst = join(distDir, a.publicPath)
|
||||
try {
|
||||
await mkdir(dirname(dst), { recursive: true })
|
||||
await copyFile(src, dst)
|
||||
} catch (err) {
|
||||
throw new PipelineError(
|
||||
'MEDIA_COPY_FAILED',
|
||||
`媒体复制失败(${a.publicPath} ← ${a.copyPath}):${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
const copied = createHash('sha256').update(await readFile(dst)).digest('hex')
|
||||
if (copied !== a.sha256) {
|
||||
throw new PipelineError(
|
||||
'MEDIA_COPY_FAILED',
|
||||
`媒体 sha256 失配(${a.publicPath}):assets.json=${a.sha256} ≠ 落盘=${copied}——输入侧副本与清单破裂`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return assets.length
|
||||
}
|
||||
|
||||
/** XML 属性/文本转义(编码 URL 原样贯穿的边界:%xx 不受影响,裸 &<>" 转义) */
|
||||
function xmlEscape(s: string): string {
|
||||
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
}
|
||||
|
||||
/** UTF-16 字典序(与物化端排序同语义——钉死不依赖 locale) */
|
||||
function cmp(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
function cmpNull(a: string | null, b: string | null): number {
|
||||
if (a === null && b === null) return 0
|
||||
if (a === null) return -1
|
||||
if (b === null) return 1
|
||||
return cmp(a, b)
|
||||
}
|
||||
|
||||
function buildSitemap(origin: string, routes: RouteEntry[]): { xml: string; entries: number } {
|
||||
const rows = routes
|
||||
.filter((r) => r.routeKind === 'html' && r.indexable)
|
||||
.sort((a, b) => cmp(a.routePath, b.routePath) || cmpNull(a.queryKey, b.queryKey))
|
||||
const items = rows.map((r) => {
|
||||
const url = r.queryKey === null ? `${origin}${r.routePath}` : `${origin}${r.routePath}?${r.queryKey}`
|
||||
return ` <url><loc>${xmlEscape(url)}</loc></url>`
|
||||
})
|
||||
return {
|
||||
xml: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items.join('\n')}\n</urlset>\n`,
|
||||
entries: rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRobots(origin: string): string {
|
||||
return `User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`
|
||||
}
|
||||
|
||||
/** route-manifest 行映射:exact_key 带查询形态;target_file 派生映射;redirect 带 location */
|
||||
function toManifestRoute(r: RouteEntry, assetsByPath: Map<string, AssetEntry>): RouteManifestRoute {
|
||||
// redirect 行 redirectStatus 是权威(targetStatus 同值兜底——数据面两处冗余)
|
||||
const rawStatus = r.routeKind === 'redirect' ? (r.redirectStatus ?? Number(r.targetStatus)) : Number(r.targetStatus)
|
||||
if (!Number.isInteger(rawStatus) || rawStatus < 100 || rawStatus > 599) {
|
||||
throw new PipelineError(
|
||||
'MANIFEST_WRITE_FAILED',
|
||||
`路由 ${r.routePath} 的 HTTP 状态非法(${String(rawStatus)})——target_status/redirect_status 数据面破裂`,
|
||||
)
|
||||
}
|
||||
const row: RouteManifestRoute = {
|
||||
exact_key: r.queryKey === null ? r.routePath : `${r.routePath}?${r.queryKey}`,
|
||||
query_policy: r.queryPolicy,
|
||||
query_key: r.queryKey,
|
||||
kind: r.routeKind,
|
||||
status: rawStatus,
|
||||
indexable: r.indexable,
|
||||
}
|
||||
if (r.routeKind === 'html') {
|
||||
row.target_file = deriveTargetFile(r.routePath)
|
||||
row.content_type = 'text/html'
|
||||
} else if (r.routeKind === 'asset') {
|
||||
row.target_file = r.targetFile ?? r.routePath
|
||||
const asset = assetsByPath.get(r.routePath)
|
||||
if (asset !== undefined) row.content_type = asset.detectedMimeType
|
||||
} else if (r.routeKind === 'redirect') {
|
||||
if (r.redirectLocation !== null) row.location = r.redirectLocation
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录哈希(build_artifacts dist 行的 sha256 口径):递归文件清单(posix 相对路径
|
||||
* 字典序)每行 `rel\truntime sha256`,清单文本(含尾换行)的 sha256——文件集合与
|
||||
* 内容任一变化即变,操作者可离线复算互证。byteSize = 递归文件字节和。
|
||||
*/
|
||||
async function hashDirectory(dir: string): Promise<{ sha256: string; byteSize: number; fileCount: 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 byteSize = 0
|
||||
const lines: string[] = []
|
||||
for (const rel of rels) {
|
||||
const buf = await readFile(join(dir, rel))
|
||||
byteSize += buf.byteLength
|
||||
lines.push(`${rel}\t${createHash('sha256').update(buf).digest('hex')}`)
|
||||
}
|
||||
return { sha256: createHash('sha256').update(`${lines.join('\n')}\n`).digest('hex'), byteSize, fileCount: rels.length }
|
||||
}
|
||||
|
||||
async function copyTree(src: string, dst: string): Promise<void> {
|
||||
await mkdir(dst, { recursive: true })
|
||||
for (const e of await readdir(src, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) await copyTree(join(src, e.name), join(dst, e.name))
|
||||
else await copyFile(join(src, e.name), join(dst, e.name))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建管线主体(编排顺序见模块头)。前置不变量:inputDir 六文件齐备(service 物化
|
||||
* 已保证;直调方破坏不变量时以 INPUT_INVALID 拒绝,不产残缺产物)。
|
||||
* build_artifacts 登记 dist/manifest 两行;report 行由 build.service 落报告后补登。
|
||||
*/
|
||||
export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineInput): Promise<BuildPipelineResult> {
|
||||
const routes = (await readJsonInput(input.inputDir, 'routes.json')) as RouteEntry[]
|
||||
const assets = (await readJsonInput(input.inputDir, 'assets.json')) as AssetEntry[]
|
||||
const siteRaw = (await readJsonInput(input.inputDir, 'site.json')) as { origin?: unknown }
|
||||
const inputManifest = (await readJsonInput(input.inputDir, 'render-manifest.json')) as {
|
||||
sourceHashes?: { route?: unknown }
|
||||
}
|
||||
if (!Array.isArray(routes) || routes.length === 0) {
|
||||
throw new PipelineError('INPUT_INVALID', 'routes.json 为空或非数组——正常发布站点至少含首页路由')
|
||||
}
|
||||
if (!Array.isArray(assets)) {
|
||||
throw new PipelineError('INPUT_INVALID', 'assets.json 非数组——物化面破裂')
|
||||
}
|
||||
if (typeof siteRaw.origin !== 'string' || siteRaw.origin === '') {
|
||||
throw new PipelineError('INPUT_INVALID', 'site.json origin 缺席/为空——sitemap 与 manifest 的 origin 无来源')
|
||||
}
|
||||
const origin = siteRaw.origin
|
||||
const routeHash =
|
||||
typeof inputManifest.sourceHashes?.route === 'string' ? inputManifest.sourceHashes.route : ''
|
||||
if (routeHash === '') {
|
||||
throw new PipelineError('INPUT_INVALID', 'render-manifest.json sourceHashes.route 缺席——哈希链断链')
|
||||
}
|
||||
|
||||
// ① spawn(主题包 dist 暂存先行清空——跨 build 残留归零,astro 自身清 outDir 之外双保险)
|
||||
const scratchDist = themeDistDir()
|
||||
await rm(scratchDist, { recursive: true, force: true })
|
||||
await spawnAstroBuild(input.inputDir)
|
||||
|
||||
// ② dist 定位与完整性:每条 html 行 target_file(派生映射)在暂存 dist 存在且是文件
|
||||
// (主题侧折平集成已对齐映射——此处失配即主题/管线映射破裂,禁静默放行)
|
||||
const scratchStat = await stat(scratchDist).catch(() => null)
|
||||
if (scratchStat === null || !scratchStat.isDirectory()) {
|
||||
throw new PipelineError('THEME_BUILD_FAILED', `主题构建产物目录缺席:${scratchDist}`)
|
||||
}
|
||||
for (const r of routes) {
|
||||
if (r.routeKind !== 'html') continue
|
||||
const rel = deriveTargetFile(r.routePath)
|
||||
const st = await stat(join(scratchDist, rel)).catch(() => null)
|
||||
if (st === null || !st.isFile()) {
|
||||
throw new PipelineError(
|
||||
'THEME_BUILD_FAILED',
|
||||
`dist 产物缺失(${r.routePath} → ${rel})——主题产物与路由表映射破裂(禁静默放行)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 媒体复制(sha256 全量校验)④ sitemap/robots(写入暂存 dist——它们是站点服务面文件)
|
||||
const mediaCopied = await copyMedia(scratchDist, assets)
|
||||
const sitemap = buildSitemap(origin, routes)
|
||||
await writeAtomically(join(scratchDist, 'sitemap.xml'), sitemap.xml)
|
||||
await writeAtomically(join(scratchDist, 'robots.txt'), buildRobots(origin))
|
||||
|
||||
// ⑤ 暂存拷出到 job 级权威产物目录,暂存清空(见模块头双位置纪律)
|
||||
const distPath = join(input.artifactsRoot, 'jobs', input.jobId, 'dist')
|
||||
await rm(distPath, { recursive: true, force: true })
|
||||
await copyTree(scratchDist, distPath)
|
||||
await rm(scratchDist, { recursive: true, force: true })
|
||||
|
||||
// ⑥ route-manifest(§12.1:物化哈希链复用 + 行序 = routes.json 物化序)
|
||||
const assetsByPath = new Map(assets.map((a) => [a.publicPath, a]))
|
||||
const manifest: RouteManifest = {
|
||||
manifest_version: ROUTE_MANIFEST_VERSION,
|
||||
site_id: input.siteId,
|
||||
build_job_id: input.jobId,
|
||||
origin,
|
||||
generated_from_route_snapshot_hash: routeHash,
|
||||
routes: routes.map((r) => toManifestRoute(r, assetsByPath)),
|
||||
}
|
||||
const manifestText = `${JSON.stringify(manifest, null, 2)}\n`
|
||||
const manifestPath = join(input.artifactsRoot, 'jobs', input.jobId, 'route-manifest.json')
|
||||
try {
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeAtomically(manifestPath, manifestText)
|
||||
} catch (err) {
|
||||
throw new PipelineError(
|
||||
'MANIFEST_WRITE_FAILED',
|
||||
`route-manifest 落盘失败(${manifestPath}):${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ⑦ build_artifacts 登记(dist/manifest 两行;UNIQUE(build_job_id,kind,storage_path))
|
||||
const dirHash = await hashDirectory(distPath)
|
||||
const manifestSha256 = createHash('sha256').update(manifestText, 'utf8').digest('hex')
|
||||
try {
|
||||
await db.insert(buildArtifacts).values([
|
||||
{
|
||||
buildJobId: input.jobId,
|
||||
kind: 'dist',
|
||||
storagePath: distPath,
|
||||
sha256: dirHash.sha256,
|
||||
byteSize: dirHash.byteSize,
|
||||
},
|
||||
{
|
||||
buildJobId: input.jobId,
|
||||
kind: 'manifest',
|
||||
storagePath: manifestPath,
|
||||
sha256: manifestSha256,
|
||||
byteSize: Buffer.byteLength(manifestText, 'utf8'),
|
||||
},
|
||||
])
|
||||
} catch (err) {
|
||||
throw new PipelineError(
|
||||
'MANIFEST_WRITE_FAILED',
|
||||
`build_artifacts 登记失败(job=${input.jobId}):${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
distPath,
|
||||
distSha256: dirHash.sha256,
|
||||
distByteSize: dirHash.byteSize,
|
||||
distFileCount: dirHash.fileCount,
|
||||
routeManifestPath: manifestPath,
|
||||
routeManifestSha256: manifestSha256,
|
||||
routeManifestByteSize: Buffer.byteLength(manifestText, 'utf8'),
|
||||
sitemapEntries: sitemap.entries,
|
||||
mediaCopied,
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Command } from 'commander'
|
||||
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '@sf/adapter-wp'
|
||||
import { getDb } from '../apps/api/src/db/client.js'
|
||||
import { PipelineError } from '../apps/api/src/modules/build/pipeline.js'
|
||||
import { BuildError, createBuildJob } from '../apps/api/src/modules/build/build.service.js'
|
||||
import {
|
||||
ImportError,
|
||||
@@ -301,7 +302,7 @@ program
|
||||
|
||||
program
|
||||
.command('build:create')
|
||||
.description('冻结 Render Snapshot:物化六文件(site/content/taxonomies/routes/assets/render-manifest)+ build_input_snapshots 行;dist 产物是 Task 5')
|
||||
.description('冻结 Render Snapshot 并构建:物化六文件 + build_input_snapshots 行 → astro 构建(媒体落盘/sitemap/robots/route-manifest)→ dist 产物 + build_artifacts 登记')
|
||||
.requiredOption('--site <key>', '站点 key')
|
||||
.option('--key <key>', '幂等键(缺省 = site+published 集合哈希+主题版本 自动派生;重复提交同 key 复用既有 job 不重跑)')
|
||||
.option('--artifacts <dir>', '产物根目录(缺省 repoRoot/artifacts)')
|
||||
@@ -335,6 +336,12 @@ program
|
||||
` hashes: content=${result.sourceHashes.content.slice(0, 12)}… route=${result.sourceHashes.route.slice(0, 12)}… asset=${result.sourceHashes.asset.slice(0, 12)}…`,
|
||||
)
|
||||
console.log(` report: ${result.reportPath}`)
|
||||
if (result.artifacts !== undefined) {
|
||||
console.log(
|
||||
` dist: ${result.artifacts.distPath}(${result.artifacts.distFileCount} 文件 sha256=${result.artifacts.distSha256.slice(0, 12)}…)`,
|
||||
)
|
||||
console.log(` route-manifest: ${result.artifacts.routeManifestPath}`)
|
||||
}
|
||||
console.log(` total ${result.durationMs.toFixed(1)}ms`)
|
||||
if (result.status === 'failed') process.exitCode = 1
|
||||
} finally {
|
||||
@@ -343,7 +350,7 @@ program
|
||||
})
|
||||
|
||||
// parseAsync:action 内抛错走此 catch(commander 默认会 process.exit(1) 并打印堆栈,
|
||||
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError/PublishError/BuildError 打 code,其余原样呈现)
|
||||
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError/PublishError/BuildError/PipelineError 打 code,其余原样呈现)
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
if (err instanceof SourceRegisterError) {
|
||||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||||
@@ -356,6 +363,9 @@ program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||||
} else if (err instanceof BuildError) {
|
||||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||||
} else if (err instanceof PipelineError) {
|
||||
// 构建管线阶段失败(spawn/媒体/manifest)——code + stderr 摘要(job 行已有 error 面)
|
||||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||||
} else {
|
||||
console.error(`[sf] ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 形状断言)
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
// 前缀 /product-category/ 或 /category/ → CategoryList(term 从 taxonomies 按 slug 段查)
|
||||
// '/news/' → NewsList(主列表特判——真实数据 posts 落根路径,
|
||||
// /news/ 是 route-writer finish 期生成的主列表行)
|
||||
// '/products/' → ProductList(产品主列表特判——同 finish 期主列表行,
|
||||
// targetContentId=null 无内容锚点;Task 5 集成补面)
|
||||
// 无法分派的 html 行 → 构建期明错(§7.6 禁静默——不允许空页混过)。
|
||||
import { loadRenderData } from '../lib/load-render-data'
|
||||
import type { ContentEntry, RenderData, RouteEntry, TaxonomyTerm } from '../lib/load-render-data'
|
||||
@@ -26,6 +28,7 @@ import Home from '../templates/Home.astro'
|
||||
import NewsDetail from '../templates/NewsDetail.astro'
|
||||
import NewsList from '../templates/NewsList.astro'
|
||||
import ProductDetail from '../templates/ProductDetail.astro'
|
||||
import ProductList from '../templates/ProductList.astro'
|
||||
|
||||
/** 分类列表前缀(term-writer 前置事实 9 常量的两族 basePath;publicBasePath 尾斜杠已归一) */
|
||||
const CATEGORY_PREFIXES = ['/product-category/', '/category/']
|
||||
@@ -37,6 +40,7 @@ type View =
|
||||
| { kind: 'product'; content: ContentEntry }
|
||||
| { kind: 'news-detail'; content: ContentEntry }
|
||||
| { kind: 'news-list' }
|
||||
| { kind: 'product-list' }
|
||||
|
||||
function dispatch(route: RouteEntry, data: RenderData): View {
|
||||
if (route.routePath === '/') return { kind: 'home' }
|
||||
@@ -70,6 +74,10 @@ function dispatch(route: RouteEntry, data: RenderData): View {
|
||||
|
||||
if (route.routePath === '/news/') return { kind: 'news-list' }
|
||||
|
||||
// 产品主列表特判(Task 5 集成补面):route-writer finish() 对 productsPath 落的主列表
|
||||
// 行 targetContentId=null——真实物化形状无内容锚点,须与 /news/ 对称的列表落点
|
||||
if (route.routePath === '/products/') return { kind: 'product-list' }
|
||||
|
||||
throw new Error(
|
||||
`路由 ${route.routePath} 无法分派到任何模板(html 纯路径行必须有落点;主列表/分类/内容页规则均未命中)——拒绝静默空页`,
|
||||
)
|
||||
@@ -99,3 +107,4 @@ const view = dispatch(route, data)
|
||||
{view.kind === 'product' && <ProductDetail data={data} route={route} content={view.content} />}
|
||||
{view.kind === 'news-detail' && <NewsDetail data={data} route={route} content={view.content} />}
|
||||
{view.kind === 'news-list' && <NewsList data={data} route={route} />}
|
||||
{view.kind === 'product-list' && <ProductList data={data} route={route} />}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
// 产品主列表模板(Plan 4 Task 5 集成补面):route-writer finish() 对 productsPath(默认
|
||||
// /products/)落的主列表行 targetContentId=null——Task 4 的分派器只覆盖了 news 主列表
|
||||
// 特判,产品主列表在主题夹具里被"targetContentId 占位"绕过(见 CategoryList 头注记),
|
||||
// DB 真实物化形状(targetContentId=null)即无处落点。本模板补齐:全部 product 卡片
|
||||
// 单页(latestEntries 同款排序),无落点内容不出卡(禁虚构路径)。
|
||||
// 与 news-list 同构(主列表两兄弟);manifest 的 'product-list' 模板键声明性覆盖本页
|
||||
// (appliesTo 单字符串不承载多前缀——/products/ 主列表由分派器特判,/category/ 别名
|
||||
// 前缀同先例)。
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import Breadcrumbs from '../components/Breadcrumbs.astro'
|
||||
import ProductCard from '../components/ProductCard.astro'
|
||||
import type { RenderData, RouteEntry } from '../lib/load-render-data'
|
||||
import { contentTitle, coverRef, latestEntries, metaDescription, routeHrefFor } from '../lib/ui'
|
||||
|
||||
interface Props {
|
||||
data: RenderData
|
||||
route: RouteEntry
|
||||
}
|
||||
|
||||
const { data, route } = Astro.props
|
||||
const page = { path: route.routePath, title: 'Products' }
|
||||
const allProducts = latestEntries(data.contents, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout data={data} page={{ ...page, description: metaDescription(data.site, page) }}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Products' }]} />
|
||||
<h1>Products</h1>
|
||||
<div class="product-grid">
|
||||
{allProducts.map((c) => {
|
||||
const href = routeHrefFor(data.routes, c.contentId)
|
||||
if (href === undefined) return null
|
||||
return <ProductCard title={contentTitle(c)} href={href} cover={coverRef(c, data.assets)} />
|
||||
})}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,9 @@
|
||||
// 'route:<path>' 精确路由命中(home= '/',not-found = '/404')
|
||||
// 'type:<typeKey>' 按内容类型分派(详情页)
|
||||
// 'path-prefix:<pfx>' 按路径前缀分派(列表页;product-list 的 /category/ 别名前缀
|
||||
// 由 Task 4 分派器一并处理——单一 appliesTo 字符串不承载多前缀)
|
||||
// 由 Task 4 分派器一并处理——单一 appliesTo 字符串不承载多前缀;
|
||||
// /products/ 产品主列表同此先例:Task 5 集成补面的 ProductList
|
||||
// 模板由分派器精确路由特判,不另立模板键)
|
||||
import type { ThemeManifest } from '@sf/contracts'
|
||||
|
||||
export const themeManifest: ThemeManifest = {
|
||||
|
||||
Reference in New Issue
Block a user