feat(build): 最小门禁与状态机闭环
Task 6(Plan 4):validating 接真实门禁——五项产物检查(①内部死链 ②sitemap 一致 ③SEO 基线 ④大小写冲突 ⑤产物完整性),P0 阻断/P2 报告分级;validation_ runs/validation_issues 落库(severity/rule/url/evidence),任一 P0 → job failed (VALIDATION_FAILED + 产物清场),全过 → succeeded。HTML 解析用 htmlparser2 (sanitize-html 既有传递依赖,lock 已解析 12.0.0——apps/api 显式声明复用,零新 包版本),禁正则提标签。报告 json/html 双落盘且 HTML 先写(Plan 3 窄窗口修, html 转义模板)。顺手:build-snapshot 测试 spawn 缩减 7→2(六文件物化/bodyHtml 重写/ADR-010 改 materializeRenderSnapshot 直调;复用两场景并谱共享单次成功构建), 根套件 97.3s→75.8s;两测试文件清场补 validation 域反序(FK 链先行)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"@nestjs/core": "^10.4.22",
|
"@nestjs/core": "^10.4.22",
|
||||||
"@sf/adapter-wp": "workspace:*",
|
"@sf/adapter-wp": "workspace:*",
|
||||||
"drizzle-orm": "^0.45.4",
|
"drizzle-orm": "^0.45.4",
|
||||||
|
"htmlparser2": "12.0.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
// job(queued)→ snapshotting(render-snapshot.ts 纯物化六文件 + build_input_snapshots
|
// job(queued)→ snapshotting(render-snapshot.ts 纯物化六文件 + build_input_snapshots
|
||||||
// 行,repeatable read 单事务一致性读取)→ **building(Task 5:pipeline.ts 真实构建
|
// 行,repeatable read 单事务一致性读取)→ **building(Task 5:pipeline.ts 真实构建
|
||||||
// ——spawn astro → 媒体复制 → sitemap/robots → route-manifest → build_artifacts
|
// ——spawn astro → 媒体复制 → sitemap/robots → route-manifest → build_artifacts
|
||||||
// dist/manifest 登记)**→ validating(**空门禁**——Task 6 填真实门禁)→ succeeded;
|
// dist/manifest 登记)**→ validating(**Task 6:gates.ts 最小门禁五项**——validation_
|
||||||
// 报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json + build_artifacts report 行
|
// runs/issues 落库,P0 → failed(VALIDATION_FAILED))→ succeeded;
|
||||||
// (temp+rename 原子写、**先于终态落行**——import 同款纪律)。
|
// 报告落 <artifactsRoot>/jobs/<jobId>/reports/{build.html,build.json}(**HTML 先写**,
|
||||||
|
// json 在则 html 必在)+ build_artifacts report 行(temp+rename 原子写、**先于终态落行**
|
||||||
|
// ——import 同款纪律)。
|
||||||
//
|
//
|
||||||
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用**
|
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用**
|
||||||
// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/
|
// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/
|
||||||
@@ -28,11 +30,12 @@
|
|||||||
// 经 tsx)与集成测试直接 import 本文件;Nest 侧由 build.module.ts 薄包装。
|
// 经 tsx)与集成测试直接 import 本文件;Nest 侧由 build.module.ts 薄包装。
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { execSync } from 'node:child_process'
|
import { execSync } from 'node:child_process'
|
||||||
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { desc, eq } from 'drizzle-orm'
|
import { desc, eq } from 'drizzle-orm'
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
import { isRouteManifest, type RouteManifest } from '@sf/contracts'
|
||||||
import { canonicalJson } from '../../common/canonical.js'
|
import { canonicalJson } from '../../common/canonical.js'
|
||||||
import {
|
import {
|
||||||
buildArtifacts,
|
buildArtifacts,
|
||||||
@@ -41,11 +44,15 @@ import {
|
|||||||
entryLocales,
|
entryLocales,
|
||||||
siteConfigRevisions,
|
siteConfigRevisions,
|
||||||
sites,
|
sites,
|
||||||
|
validationIssues,
|
||||||
|
validationRuns,
|
||||||
} from '../../db/schema.js'
|
} from '../../db/schema.js'
|
||||||
import type * as schema from '../../db/schema.js'
|
import type * as schema from '../../db/schema.js'
|
||||||
import { isUniqueViolation } from '../source/register.service.js'
|
import { isUniqueViolation } from '../source/register.service.js'
|
||||||
|
import { GATE_RULE_VERSION, runGates, type RunGatesResult } from './gates.js'
|
||||||
import { PipelineError, runBuildPipeline, themeDistDir, type BuildPipelineResult } from './pipeline.js'
|
import { PipelineError, runBuildPipeline, themeDistDir, type BuildPipelineResult } from './pipeline.js'
|
||||||
import { materializeRenderSnapshot, type RewriteStats } from './render-snapshot.js'
|
import { materializeRenderSnapshot, type RewriteStats } from './render-snapshot.js'
|
||||||
|
import { renderBuildReportHtml } from './report-html.js'
|
||||||
|
|
||||||
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
|
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
|
||||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
|
||||||
@@ -60,6 +67,8 @@ export type BuildErrorCode =
|
|||||||
| 'NO_SITE_CONFIG'
|
| 'NO_SITE_CONFIG'
|
||||||
| 'SNAPSHOT_FAILED'
|
| 'SNAPSHOT_FAILED'
|
||||||
| 'PIPELINE_INTERNAL'
|
| 'PIPELINE_INTERNAL'
|
||||||
|
| 'VALIDATION_FAILED'
|
||||||
|
| 'GATE_INTERNAL'
|
||||||
| 'REPORT_WRITE_FAILED'
|
| 'REPORT_WRITE_FAILED'
|
||||||
| 'ARTIFACT_REGISTER_FAILED'
|
| 'ARTIFACT_REGISTER_FAILED'
|
||||||
|
|
||||||
@@ -111,6 +120,8 @@ export interface CreateBuildJobResult {
|
|||||||
inputDir: string
|
inputDir: string
|
||||||
/** 报告路径 <artifactsRoot>/jobs/<jobId>/reports/build.json */
|
/** 报告路径 <artifactsRoot>/jobs/<jobId>/reports/build.json */
|
||||||
reportPath: string
|
reportPath: string
|
||||||
|
/** 报告 HTML 视图(与 build.json 同目录——HTML 先写:json 在则 html 必在) */
|
||||||
|
reportHtmlPath: string
|
||||||
/** 三源哈希(新建 = 本次物化;复用 = build_input_snapshots 行值——不可变权威) */
|
/** 三源哈希(新建 = 本次物化;复用 = build_input_snapshots 行值——不可变权威) */
|
||||||
sourceHashes: { content: string; route: string; asset: string }
|
sourceHashes: { content: string; route: string; asset: string }
|
||||||
/** 以下仅新建路径填充(复用口径忠实化——权威记录在既有报告) */
|
/** 以下仅新建路径填充(复用口径忠实化——权威记录在既有报告) */
|
||||||
@@ -158,6 +169,14 @@ export interface BuildReport {
|
|||||||
sitemapEntries: number
|
sitemapEntries: number
|
||||||
mediaCopied: number
|
mediaCopied: number
|
||||||
}
|
}
|
||||||
|
/** Task 6 门禁面(报告只记 PASS——FAIL 不落报告,权威在 validation_runs/issues) */
|
||||||
|
validation: {
|
||||||
|
ruleVersion: string
|
||||||
|
status: 'PASS'
|
||||||
|
runId: string
|
||||||
|
p0Count: number
|
||||||
|
p2Count: number
|
||||||
|
}
|
||||||
issues: string[]
|
issues: string[]
|
||||||
durationMs: number
|
durationMs: number
|
||||||
}
|
}
|
||||||
@@ -331,6 +350,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
|||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
inputDir: join(artifactsRoot, 'jobs', found.jobId, 'input'),
|
inputDir: join(artifactsRoot, 'jobs', found.jobId, 'input'),
|
||||||
reportPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.json'),
|
reportPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.json'),
|
||||||
|
reportHtmlPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.html'),
|
||||||
sourceHashes: found.sourceHashes,
|
sourceHashes: found.sourceHashes,
|
||||||
durationMs: performance.now() - startedAt,
|
durationMs: performance.now() - startedAt,
|
||||||
}
|
}
|
||||||
@@ -362,6 +382,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
|||||||
const inputDir = join(artifactsRoot, 'jobs', jobId, 'input')
|
const inputDir = join(artifactsRoot, 'jobs', jobId, 'input')
|
||||||
const reportDir = join(artifactsRoot, 'jobs', jobId, 'reports')
|
const reportDir = join(artifactsRoot, 'jobs', jobId, 'reports')
|
||||||
const reportPath = join(reportDir, 'build.json')
|
const reportPath = join(reportDir, 'build.json')
|
||||||
|
const reportHtmlPath = join(reportDir, 'build.html')
|
||||||
input.onStatus?.('queued', jobId)
|
input.onStatus?.('queued', jobId)
|
||||||
|
|
||||||
// 失败收口:job 行保留(failed 终态 + error 面 + 产物清场)后原样上抛——镜像 import 纪律。
|
// 失败收口:job 行保留(failed 终态 + error 面 + 产物清场)后原样上抛——镜像 import 纪律。
|
||||||
@@ -429,12 +450,86 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
|||||||
artifactsRoot,
|
artifactsRoot,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 阶段 7:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
|
// 阶段 7:validating——最小门禁(Task 6:gates.ts 五项产物检查)。onStatus 先行
|
||||||
|
// (测试/CLI 挂钩点:产物已拷出、门禁未跑——注入坏产物的观察窗)。门禁结果先落
|
||||||
|
// validation_runs + validation_issues(库内记录先于 job 终态),任一 P0 →
|
||||||
|
// VALIDATION_FAILED 上抛(统一 catch 收口:failed 终态 + 产物清场——failed 无产物
|
||||||
|
// 语义,调试面在 validation_issues 的 evidence);全过(P2 只报告)→ 继续报告/终态。
|
||||||
await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId))
|
await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId))
|
||||||
input.onStatus?.('validating', jobId)
|
input.onStatus?.('validating', jobId)
|
||||||
phase = 'validating'
|
phase = 'validating'
|
||||||
|
|
||||||
// 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
|
let manifest: RouteManifest
|
||||||
|
try {
|
||||||
|
const manifestRaw: unknown = JSON.parse(await readFile(built.routeManifestPath, 'utf8'))
|
||||||
|
if (!isRouteManifest(manifestRaw)) {
|
||||||
|
throw new Error('isRouteManifest 不通过——manifest 数据面破裂')
|
||||||
|
}
|
||||||
|
manifest = manifestRaw
|
||||||
|
} catch (err) {
|
||||||
|
throw new BuildError(
|
||||||
|
'GATE_INTERNAL',
|
||||||
|
`门禁输入读回失败(${built.routeManifestPath}):${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let gates: RunGatesResult
|
||||||
|
const gateStartedAt = new Date()
|
||||||
|
try {
|
||||||
|
gates = await runGates({
|
||||||
|
distDir: built.distPath,
|
||||||
|
routeManifest: manifest,
|
||||||
|
sitemapPath: join(built.distPath, 'sitemap.xml'),
|
||||||
|
origin: manifest.origin,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// IO 破裂(dist 缺席/不可读)是管线产物问题不是门禁判定——GATE_INTERNAL 收口
|
||||||
|
throw new BuildError('GATE_INTERNAL', `门禁执行破裂:${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
let validationRunId: string
|
||||||
|
try {
|
||||||
|
const runRows = await db
|
||||||
|
.insert(validationRuns)
|
||||||
|
.values({
|
||||||
|
siteId: site.id,
|
||||||
|
buildJobId: jobId,
|
||||||
|
ruleVersion: GATE_RULE_VERSION,
|
||||||
|
status: gates.pass ? 'PASS' : 'FAIL',
|
||||||
|
startedAt: gateStartedAt,
|
||||||
|
finishedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning({ id: validationRuns.id })
|
||||||
|
validationRunId = runRows[0]!.id
|
||||||
|
if (gates.issues.length > 0) {
|
||||||
|
await db.insert(validationIssues).values(
|
||||||
|
gates.issues.map((i) => ({
|
||||||
|
validationRunId,
|
||||||
|
severity: i.severity,
|
||||||
|
rule: i.rule,
|
||||||
|
url: i.url,
|
||||||
|
evidence: { ...i.evidence, message: i.message },
|
||||||
|
resolutionStatus: 'open',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new BuildError(
|
||||||
|
'GATE_INTERNAL',
|
||||||
|
`validation 行落库失败(job=${jobId}):${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!gates.pass) {
|
||||||
|
const p0s = gates.issues.filter((i) => i.severity === 'P0')
|
||||||
|
throw new BuildError(
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
`门禁 P0 阻断(${p0s.length} 条)——首条:${p0s[0]!.message}${
|
||||||
|
p0s.length > 1 ? `(另 ${p0s.length - 1} 条见 validation_issues)` : ''
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在。
|
||||||
|
// HTML 先写(Plan 3 移交窄窗口修):build.json 在则 build.html 必在——JSON 落盘
|
||||||
|
// 即"双件齐备"的原子信号(html 是衍生视图,窗口内至多缺 json、不会缺 html)。
|
||||||
const report: BuildReport = {
|
const report: BuildReport = {
|
||||||
jobId,
|
jobId,
|
||||||
generatedAt: buildTimestamp.toISOString(),
|
generatedAt: buildTimestamp.toISOString(),
|
||||||
@@ -477,6 +572,13 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
|||||||
sitemapEntries: built.sitemapEntries,
|
sitemapEntries: built.sitemapEntries,
|
||||||
mediaCopied: built.mediaCopied,
|
mediaCopied: built.mediaCopied,
|
||||||
},
|
},
|
||||||
|
validation: {
|
||||||
|
ruleVersion: GATE_RULE_VERSION,
|
||||||
|
status: 'PASS',
|
||||||
|
runId: validationRunId,
|
||||||
|
p0Count: gates.issues.filter((i) => i.severity === 'P0').length,
|
||||||
|
p2Count: gates.issues.filter((i) => i.severity === 'P2').length,
|
||||||
|
},
|
||||||
issues: mat.issues,
|
issues: mat.issues,
|
||||||
durationMs: performance.now() - startedAt,
|
durationMs: performance.now() - startedAt,
|
||||||
}
|
}
|
||||||
@@ -484,6 +586,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
|||||||
phase = 'reporting'
|
phase = 'reporting'
|
||||||
try {
|
try {
|
||||||
await mkdir(reportDir, { recursive: true })
|
await mkdir(reportDir, { recursive: true })
|
||||||
|
await writeAtomically(reportHtmlPath, renderBuildReportHtml(report))
|
||||||
await writeAtomically(reportPath, reportText)
|
await writeAtomically(reportPath, reportText)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new BuildError(
|
throw new BuildError(
|
||||||
@@ -520,6 +623,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
|||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
inputDir,
|
inputDir,
|
||||||
reportPath,
|
reportPath,
|
||||||
|
reportHtmlPath,
|
||||||
sourceHashes: mat.sourceHashes,
|
sourceHashes: mat.sourceHashes,
|
||||||
counts: mat.counts,
|
counts: mat.counts,
|
||||||
urlRewrite: mat.urlRewrite,
|
urlRewrite: mat.urlRewrite,
|
||||||
@@ -536,7 +640,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
|
|||||||
? err.code
|
? err.code
|
||||||
: err instanceof PipelineError
|
: err instanceof PipelineError
|
||||||
? err.code
|
? err.code
|
||||||
: phase === 'building'
|
: phase === 'building' || phase === 'validating' || phase === 'reporting'
|
||||||
? 'PIPELINE_INTERNAL'
|
? 'PIPELINE_INTERNAL'
|
||||||
: 'SNAPSHOT_FAILED'
|
: 'SNAPSHOT_FAILED'
|
||||||
const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
// 最小门禁引擎(Plan 4 Task 6,spec §13.4/§16.1):对**已构建产物**(dist + route-manifest
|
||||||
|
// + sitemap.xml)做五项只读检查,产 P0(阻断)/P2(报告)分级 issues。调用方(build.service
|
||||||
|
// validating 段)落 validation_runs/validation_issues 行并按 P0 决定 succeeded/failed。
|
||||||
|
//
|
||||||
|
// 纯函数纪律:只读产物文件与调用方传入的 manifest 对象——不查库、不联网、不写盘;
|
||||||
|
// 同输入同输出(文件遍历与 issue 排序均确定)。
|
||||||
|
//
|
||||||
|
// HTML/XML 解析纪律(spec §13.4"禁纯正则"):htmlparser2 Parser 事件流——它是
|
||||||
|
// sanitize-html 的既有传递依赖(lock 已解析 12.0.0),apps/api 显式声明复用同一版本,
|
||||||
|
// 零新包进入依赖图;门禁是**读取**非清洗,但解析面与清洗管线同源同版本。
|
||||||
|
//
|
||||||
|
// 五门禁(V1 最小集,Plan 5 扩 redirect 环/OG/hreflang):
|
||||||
|
// ① internal-links:dist 内 a[href]/img[src] 的**根相对**目标全命中(html 产物文件、
|
||||||
|
// asset 文件或 manifest 路由——redirect 落点无文件不算死链);外域/锚点/绝对 origin/
|
||||||
|
// 协议相对/mailto:/data: 等非根相对形态豁免(V1 只检根相对——主题渲染面恒根相对)。
|
||||||
|
// ② sitemap-consistency:sitemap <loc> 集 = manifest indexable html 路由集。
|
||||||
|
// ③ seo-baseline:每 html 有非空 title/meta description/canonical/恰一 H1。
|
||||||
|
// manifest html 页 canonical 精确 = origin+自身路径;非 manifest 页(404.html——站点
|
||||||
|
// 兜底非内容路由)canonical 只要求 origin 起头。description 缺席是 P2:主题契约
|
||||||
|
// "有则合规"(SeoHead 注记——metaDescription 兜底保证真实页恒在,缺席即主题面异常,
|
||||||
|
// 报告不阻断)。
|
||||||
|
// ④ case-collision:dist 全路径 lowercase 归一无重复(Windows/NTFS 大小写不敏感防线——
|
||||||
|
// 冲突对在敏感盘上部署即互相覆写)。
|
||||||
|
// ⑤ artifact-completeness:manifest html 行 target_file 全在场(asset 行由管线媒体
|
||||||
|
// 复制 sha256 全量校验兜底,不重复查)。
|
||||||
|
import { readFile, readdir } from 'node:fs/promises'
|
||||||
|
import { join, relative } from 'node:path'
|
||||||
|
import { Parser } from 'htmlparser2'
|
||||||
|
import type { RouteManifest } from '@sf/contracts'
|
||||||
|
import { deriveTargetFile } from './pipeline.js'
|
||||||
|
|
||||||
|
/** 门禁规则版本(validation_runs.rule_version——规则集演进时递增,报告可对版本解读) */
|
||||||
|
export const GATE_RULE_VERSION = 'min-gates-v1'
|
||||||
|
|
||||||
|
/** 严重级别(P0 阻断 / P2 报告——对齐 spec §12.4 约定子集;DB 侧 text 列随规则演进) */
|
||||||
|
export type GateSeverity = 'P0' | 'P2'
|
||||||
|
|
||||||
|
export type GateRule = 'internal-links' | 'sitemap-consistency' | 'seo-baseline' | 'case-collision' | 'artifact-completeness'
|
||||||
|
|
||||||
|
export interface GateIssue {
|
||||||
|
severity: GateSeverity
|
||||||
|
rule: GateRule
|
||||||
|
/** issue 定位面:页面/路由的根相对路径(sitemap/case 类无单点 url → null) */
|
||||||
|
url: string | null
|
||||||
|
message: string
|
||||||
|
evidence: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunGatesInput {
|
||||||
|
distDir: string
|
||||||
|
routeManifest: RouteManifest
|
||||||
|
sitemapPath: string
|
||||||
|
origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunGatesResult {
|
||||||
|
/** true = 零 P0(P2 不阻断——"报告"语义) */
|
||||||
|
pass: boolean
|
||||||
|
issues: GateIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 解析(htmlparser2 事件流——单遍收集,无正则标签提取) ----
|
||||||
|
|
||||||
|
/** 一份 html 产物的门禁观测面(①③ 共用——单遍解析双消费) */
|
||||||
|
interface PageScan {
|
||||||
|
/** dist 相对 posix 路径(issue 定位与 evidence 用) */
|
||||||
|
rel: string
|
||||||
|
title: string
|
||||||
|
descriptions: string[]
|
||||||
|
canonicals: string[]
|
||||||
|
h1Count: number
|
||||||
|
/** a[href] / img[src] 原样值(解析与豁免判定在门禁层做——观测面保持零判断) */
|
||||||
|
links: Array<{ kind: 'a' | 'img'; href: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanHtmlPage(rel: string, html: string): PageScan {
|
||||||
|
const scan: PageScan = { rel, title: '', descriptions: [], canonicals: [], h1Count: 0, links: [] }
|
||||||
|
let inTitle = false
|
||||||
|
const parser = new Parser({
|
||||||
|
onopentag(name, attribs) {
|
||||||
|
const tag = name.toLowerCase()
|
||||||
|
if (tag === 'title') {
|
||||||
|
inTitle = true
|
||||||
|
} else if (tag === 'meta' && (attribs.name ?? '').toLowerCase() === 'description') {
|
||||||
|
scan.descriptions.push(attribs.content ?? '')
|
||||||
|
} else if (tag === 'link' && (attribs.rel ?? '').toLowerCase().split(' ').includes('canonical')) {
|
||||||
|
scan.canonicals.push(attribs.href ?? '')
|
||||||
|
} else if (tag === 'h1') {
|
||||||
|
scan.h1Count++
|
||||||
|
} else if (tag === 'a' && typeof attribs.href === 'string' && attribs.href !== '') {
|
||||||
|
scan.links.push({ kind: 'a', href: attribs.href })
|
||||||
|
} else if (tag === 'img' && typeof attribs.src === 'string' && attribs.src !== '') {
|
||||||
|
scan.links.push({ kind: 'img', href: attribs.src })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ontext(text) {
|
||||||
|
if (inTitle) scan.title += text
|
||||||
|
},
|
||||||
|
onclosetag(name) {
|
||||||
|
if (name.toLowerCase() === 'title') inTitle = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
parser.write(html)
|
||||||
|
parser.end()
|
||||||
|
return scan
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sitemap <loc> 文本集(xmlMode 解析——实体解码还原转义 URL,无正则提取) */
|
||||||
|
function parseSitemapLocs(xml: string): string[] {
|
||||||
|
const locs: string[] = []
|
||||||
|
let inLoc = false
|
||||||
|
let text = ''
|
||||||
|
const parser = new Parser(
|
||||||
|
{
|
||||||
|
onopentag(name) {
|
||||||
|
if (name === 'loc') {
|
||||||
|
inLoc = true
|
||||||
|
text = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ontext(t) {
|
||||||
|
if (inLoc) text += t
|
||||||
|
},
|
||||||
|
onclosetag(name) {
|
||||||
|
if (name === 'loc') {
|
||||||
|
locs.push(text)
|
||||||
|
inLoc = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ xmlMode: true },
|
||||||
|
)
|
||||||
|
parser.write(xml)
|
||||||
|
parser.end()
|
||||||
|
return locs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ① 死链 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 链接的内部目标归一:根相对路径(query/fragment 剥离)或 null(豁免)。
|
||||||
|
* 豁免面:锚点(#…)、任意 scheme(http/https 绝对 URL——含绝对 origin 形态、mailto:、
|
||||||
|
* tel:、data:、javascript:)、协议相对(//host)、纯相对形态(theme 恒根相对——不虚构
|
||||||
|
* 基准解析)。scheme 判定是 RFC 3986 形态(首段字母开头 + 单冒号),非正则标签提取。
|
||||||
|
*/
|
||||||
|
export function internalLinkTarget(href: string): string | null {
|
||||||
|
if (href === '' || href.startsWith('#')) return null
|
||||||
|
const colon = href.indexOf(':')
|
||||||
|
if (colon > 0 && /^[a-zA-Z][a-zA-Z0-9+.-]*$/.test(href.slice(0, colon))) return null
|
||||||
|
if (href.startsWith('//')) return null
|
||||||
|
if (!href.startsWith('/')) return null
|
||||||
|
const path = href.split('#')[0]!.split('?')[0]!
|
||||||
|
return path === '' ? '/' : path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ④ 大小写冲突(纯函数导出——NTFS 大小写不敏感无法物化冲突对,测试直测) ----
|
||||||
|
|
||||||
|
export interface CaseCollision {
|
||||||
|
/** 冲突组的 lowercase 归一键 */
|
||||||
|
lower: string
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lowercase 归一后重复的路径组(输入 = dist 相对 posix 路径清单;输出按 lower 字典序) */
|
||||||
|
export function findCaseCollisions(relPaths: string[]): CaseCollision[] {
|
||||||
|
const byLower = new Map<string, string[]>()
|
||||||
|
for (const p of relPaths) {
|
||||||
|
const lower = p.toLowerCase()
|
||||||
|
const group = byLower.get(lower)
|
||||||
|
if (group === undefined) byLower.set(lower, [p])
|
||||||
|
else group.push(p)
|
||||||
|
}
|
||||||
|
return [...byLower.entries()]
|
||||||
|
.filter(([, paths]) => paths.length > 1)
|
||||||
|
.map(([lower, paths]) => ({ lower, paths: paths.slice().sort() }))
|
||||||
|
.sort((a, b) => (a.lower < b.lower ? -1 : a.lower > b.lower ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 门禁主体 ----
|
||||||
|
|
||||||
|
/** evidence 数组封顶(缺集/冲突对清单防失控——计数始终完整,样本截断) */
|
||||||
|
const EVIDENCE_CAP = 20
|
||||||
|
|
||||||
|
async function walkFiles(dir: string): Promise<string[]> {
|
||||||
|
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()
|
||||||
|
return rels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 五门禁主体(编排顺序 ①→⑤,每门禁内按确定性序产 issues)。
|
||||||
|
* IO 破裂(dist 缺席/不可读)原样上抛——那是管线产物缺席,不是门禁判定失败
|
||||||
|
* (调用方按内部错误收口,不得误判 PASS/FAIL)。
|
||||||
|
*/
|
||||||
|
export async function runGates(input: RunGatesInput): Promise<RunGatesResult> {
|
||||||
|
const issues: GateIssue[] = []
|
||||||
|
const { distDir, routeManifest: manifest, sitemapPath, origin } = input
|
||||||
|
|
||||||
|
const rels = await walkFiles(distDir)
|
||||||
|
const fileSet = new Set(rels)
|
||||||
|
const htmlRels = rels.filter((r) => r.endsWith('.html'))
|
||||||
|
|
||||||
|
// 单遍解析全部 html(①③ 共用)
|
||||||
|
const pages: PageScan[] = []
|
||||||
|
for (const rel of htmlRels) {
|
||||||
|
pages.push(scanHtmlPage(rel, await readFile(join(distDir, rel), 'utf8')))
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifest html 页的文件→路由映射(③ canonical 精确期望 + ⑤ 完整性)
|
||||||
|
const routeByTargetFile = new Map<string, string>()
|
||||||
|
for (const r of manifest.routes) {
|
||||||
|
if (r.kind === 'html' && typeof r.target_file === 'string') routeByTargetFile.set(r.target_file, r.exact_key)
|
||||||
|
}
|
||||||
|
/** 文件 → 页面 URL(manifest 路由优先,缺席时按 dist 路径派生——issue 定位面) */
|
||||||
|
const urlOf = (rel: string): string => routeByTargetFile.get(rel) ?? `/${rel}`
|
||||||
|
|
||||||
|
// ① internal-links:根相对目标全命中(html/asset 文件在场,或**非文件类路由**——
|
||||||
|
// redirect/gone 落点无产物文件由服务端响应,不算死链;html/asset 行不进兜底:其
|
||||||
|
// 产物缺席时死链与完整性双报告——链接级真相不因路由行在场而掩盖)
|
||||||
|
const nonFileRoutePaths = new Set(
|
||||||
|
manifest.routes
|
||||||
|
.filter((r) => r.kind !== 'html' && r.kind !== 'asset')
|
||||||
|
.map((r) => r.exact_key.split('?')[0]!),
|
||||||
|
)
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const link of page.links) {
|
||||||
|
const target = internalLinkTarget(link.href)
|
||||||
|
if (target === null) continue
|
||||||
|
const exact = target === '/' ? 'index.html' : target.replace(/^\//, '')
|
||||||
|
const derived = deriveTargetFile(target)
|
||||||
|
if (fileSet.has(exact) || fileSet.has(derived) || nonFileRoutePaths.has(target)) continue
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'internal-links',
|
||||||
|
url: urlOf(page.rel),
|
||||||
|
message: `死链:页面 ${urlOf(page.rel)} 的 ${link.kind} 引用不存在目标 ${link.href}`,
|
||||||
|
evidence: { page: page.rel, kind: link.kind, href: link.href },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② sitemap-consistency:loc 集 = manifest indexable html 路由集
|
||||||
|
const expectedLocs = manifest.routes
|
||||||
|
.filter((r) => r.kind === 'html' && r.indexable)
|
||||||
|
.map((r) => `${origin}${r.exact_key}`)
|
||||||
|
.sort()
|
||||||
|
let sitemapText: string | null = null
|
||||||
|
try {
|
||||||
|
sitemapText = await readFile(sitemapPath, 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'sitemap-consistency',
|
||||||
|
url: null,
|
||||||
|
message: `sitemap 不可读/缺席(${sitemapPath}):${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
evidence: { sitemapPath, expectedCount: expectedLocs.length },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (sitemapText !== null) {
|
||||||
|
const actualLocs = parseSitemapLocs(sitemapText).sort()
|
||||||
|
const expectedSet = new Set(expectedLocs)
|
||||||
|
const actualSet = new Set(actualLocs)
|
||||||
|
const missing = expectedLocs.filter((l) => !actualSet.has(l))
|
||||||
|
const extra = actualLocs.filter((l) => !expectedSet.has(l))
|
||||||
|
if (missing.length > 0 || extra.length > 0) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'sitemap-consistency',
|
||||||
|
url: null,
|
||||||
|
message: `sitemap 与 indexable html 路由集不一致:缺 ${missing.length} 条、多 ${extra.length} 条`,
|
||||||
|
evidence: {
|
||||||
|
missing: missing.slice(0, EVIDENCE_CAP),
|
||||||
|
extra: extra.slice(0, EVIDENCE_CAP),
|
||||||
|
missingCount: missing.length,
|
||||||
|
extraCount: extra.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ seo-baseline:title/description/canonical/恰一 H1(逐页;页面序 = 字典序)
|
||||||
|
for (const page of pages) {
|
||||||
|
const url = urlOf(page.rel)
|
||||||
|
if (page.title.trim() === '') {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'seo-baseline',
|
||||||
|
url,
|
||||||
|
message: `SEO 基线:页面 ${url} 缺少非空 <title>`,
|
||||||
|
evidence: { page: page.rel, missing: 'title' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!page.descriptions.some((d) => d.trim() !== '')) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P2',
|
||||||
|
rule: 'seo-baseline',
|
||||||
|
url,
|
||||||
|
message: `SEO 基线:页面 ${url} 缺少 meta description(主题契约"有则合规"——报告不阻断)`,
|
||||||
|
evidence: { page: page.rel, missing: 'description' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const expectedCanonical = routeByTargetFile.get(page.rel)
|
||||||
|
const canonical = page.canonicals[0] ?? ''
|
||||||
|
if (canonical === '') {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'seo-baseline',
|
||||||
|
url,
|
||||||
|
message: `SEO 基线:页面 ${url} 缺少 canonical`,
|
||||||
|
evidence: { page: page.rel, missing: 'canonical', expected: expectedCanonical === undefined ? null : `${origin}${expectedCanonical.split('?')[0]!}`, actual: '' },
|
||||||
|
})
|
||||||
|
} else if (expectedCanonical !== undefined) {
|
||||||
|
// manifest html 页:精确 = origin+自身路径(query 剥离——canonical 指内容路径)
|
||||||
|
const expected = `${origin}${expectedCanonical.split('?')[0]!}`
|
||||||
|
if (canonical !== expected) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'seo-baseline',
|
||||||
|
url,
|
||||||
|
message: `SEO 基线:页面 ${url} canonical 指向 ${canonical}(期望 ${expected})`,
|
||||||
|
evidence: { page: page.rel, missing: 'canonical', expected, actual: canonical },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (!canonical.startsWith(origin)) {
|
||||||
|
// 非 manifest 页(404.html 等):只要求 origin 起头(无路由权威可精确对拍)
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'seo-baseline',
|
||||||
|
url,
|
||||||
|
message: `SEO 基线:页面 ${url} canonical 非 origin 起头(${canonical})`,
|
||||||
|
evidence: { page: page.rel, missing: 'canonical', expected: null, actual: canonical },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (page.h1Count !== 1) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'seo-baseline',
|
||||||
|
url,
|
||||||
|
message: `SEO 基线:页面 ${url} H1 数量 ${page.h1Count}(期望恰一)`,
|
||||||
|
evidence: { page: page.rel, h1Count: page.h1Count },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ case-collision:全路径 lowercase 归一无重复
|
||||||
|
for (const collision of findCaseCollisions(rels)) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'case-collision',
|
||||||
|
url: null,
|
||||||
|
message: `大小写冲突(大小写不敏感部署盘会互相覆写):${collision.paths.join(' ≡ ')}`,
|
||||||
|
evidence: { lower: collision.lower, paths: collision.paths.slice(0, EVIDENCE_CAP), pathCount: collision.paths.length },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ artifact-completeness:manifest html 行 target_file 全在场(walk 的 fileSet 是
|
||||||
|
// dist 全量文件清单——递归遍历与此处同源,无须二次 stat)
|
||||||
|
for (const r of manifest.routes) {
|
||||||
|
if (r.kind !== 'html') continue
|
||||||
|
const target = r.target_file
|
||||||
|
if (target === undefined) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'artifact-completeness',
|
||||||
|
url: r.exact_key,
|
||||||
|
message: `产物完整性:路由 ${r.exact_key} 的 html 行缺 target_file(manifest 数据面破裂)`,
|
||||||
|
evidence: { route: r.exact_key, target_file: null },
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!fileSet.has(target)) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'P0',
|
||||||
|
rule: 'artifact-completeness',
|
||||||
|
url: r.exact_key,
|
||||||
|
message: `产物完整性:路由 ${r.exact_key} 的 target_file ${target} 不在 dist`,
|
||||||
|
evidence: { route: r.exact_key, target_file: target },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pass: !issues.some((i) => i.severity === 'P0'), issues }
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 构建报告 HTML 渲染(Plan 4 Task 6——"报告 HTML 先写"窄窗口修的渲染面):
|
||||||
|
// 沿用 Plan 2/3 模板纪律(import 域 report-html.ts 同款):
|
||||||
|
// - 对象层(build.json)保留原文——JSON 归档/机器消费的语义层;
|
||||||
|
// - 渲染层对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告动态文本含站点键/
|
||||||
|
// 幂等键/路径等操作者可控串,未转义渲染会使报告本身成为注入载体;
|
||||||
|
// - 静态骨架无 script/事件属性;计数与清单按确定序渲染。
|
||||||
|
import type { BuildReport } from './build.service.js'
|
||||||
|
|
||||||
|
/** HTML 转义(渲染层唯一动态文本通道):<>&"' 全转义,& 最先避免二次转义陷阱 */
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/[&<>"']/g, (ch) => {
|
||||||
|
switch (ch) {
|
||||||
|
case '&':
|
||||||
|
return '&'
|
||||||
|
case '<':
|
||||||
|
return '<'
|
||||||
|
case '>':
|
||||||
|
return '>'
|
||||||
|
case '"':
|
||||||
|
return '"'
|
||||||
|
default:
|
||||||
|
return '''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = escapeHtml
|
||||||
|
|
||||||
|
/** 构建报告 → HTML 片段(结构:概览/门禁/环境/快照计数/产物/耗时/问题清单) */
|
||||||
|
export function renderBuildReportHtml(report: BuildReport): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push('<h2>构建报告</h2>')
|
||||||
|
|
||||||
|
lines.push('<h3>概览</h3>')
|
||||||
|
lines.push(
|
||||||
|
`<ul><li>job: ${esc(report.jobId)}</li><li>状态: ${esc(report.status)}</li>` +
|
||||||
|
`<li>站点: ${esc(report.siteKey)}(${esc(report.siteId)})</li>` +
|
||||||
|
`<li>幂等键: ${esc(report.idempotencyKey)}</li><li>主题: ${esc(report.theme.key)}@${esc(report.theme.version)}</li>` +
|
||||||
|
`<li>生成时间: ${esc(report.generatedAt)}</li></ul>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.push('<h3>门禁</h3>')
|
||||||
|
lines.push(
|
||||||
|
`<ul><li>规则版本: ${esc(report.validation.ruleVersion)}</li>` +
|
||||||
|
`<li>状态: ${esc(report.validation.status)}</li>` +
|
||||||
|
`<li>run: ${esc(report.validation.runId)}</li>` +
|
||||||
|
`<li>P0(阻断): ${report.validation.p0Count}</li>` +
|
||||||
|
`<li>P2(报告): ${report.validation.p2Count}</li></ul>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.push('<h3>构建环境</h3>')
|
||||||
|
lines.push(
|
||||||
|
`<ul><li>node: ${esc(report.buildEnv.node)}</li><li>pnpm: ${esc(report.buildEnv.pnpm)}</li>` +
|
||||||
|
`<li>sourceCommit: ${esc(report.buildEnv.sourceCommit)}</li>` +
|
||||||
|
`<li>locale: ${esc(report.buildEnv.locale)}</li><li>timezone: ${esc(report.buildEnv.timezone)}</li></ul>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.push('<h3>快照</h3>')
|
||||||
|
lines.push(
|
||||||
|
`<ul><li>config 修订: ${esc(report.snapshot.siteConfigRevisionId)}</li>` +
|
||||||
|
`<li>manifest: ${esc(report.snapshot.manifestPath)}</li>` +
|
||||||
|
`<li>contents: ${report.snapshot.counts.contents}</li>` +
|
||||||
|
`<li>taxonomies: ${report.snapshot.counts.taxonomies}</li>` +
|
||||||
|
`<li>routes: ${report.snapshot.counts.routes}</li>` +
|
||||||
|
`<li>assets: ${report.snapshot.counts.assets}</li>` +
|
||||||
|
`<li>URL 重写: rewritten=${report.urlRewrite.rewritten} unresolved=${report.urlRewrite.unresolved}</li></ul>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.push('<h3>产物</h3>')
|
||||||
|
lines.push(
|
||||||
|
`<ul><li>dist: ${esc(report.artifacts.distPath)}</li>` +
|
||||||
|
`<li>dist sha256: ${esc(report.artifacts.distSha256)}</li>` +
|
||||||
|
`<li>dist 文件数: ${report.artifacts.distFileCount}</li>` +
|
||||||
|
`<li>route-manifest: ${esc(report.artifacts.routeManifestPath)}</li>` +
|
||||||
|
`<li>route-manifest sha256: ${esc(report.artifacts.routeManifestSha256)}</li>` +
|
||||||
|
`<li>sitemap 条目: ${report.artifacts.sitemapEntries}</li>` +
|
||||||
|
`<li>媒体复制: ${report.artifacts.mediaCopied}</li></ul>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.push('<h3>问题清单</h3>')
|
||||||
|
if (report.issues.length === 0) {
|
||||||
|
lines.push('<p>(无)</p>')
|
||||||
|
} else {
|
||||||
|
lines.push('<ul>')
|
||||||
|
for (const issue of report.issues) {
|
||||||
|
lines.push(`<li>${esc(issue)}</li>`)
|
||||||
|
}
|
||||||
|
lines.push('</ul>')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('<h3>耗时</h3>')
|
||||||
|
lines.push(`<ul><li>total: ${report.durationMs.toFixed(1)}ms</li></ul>`)
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
Generated
+3
@@ -48,6 +48,9 @@ importers:
|
|||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.4
|
specifier: ^0.45.4
|
||||||
version: 0.45.4(postgres@3.4.9)
|
version: 0.45.4(postgres@3.4.9)
|
||||||
|
htmlparser2:
|
||||||
|
specifier: 12.0.0
|
||||||
|
version: 12.0.0
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.9
|
specifier: ^3.4.9
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
// Plan 4 Task 6 失败测试(先于实现):最小门禁 + 完整 build 状态机(spec §13.4/§16.1)。
|
||||||
|
// 覆盖契约(对照计划 Task 6):
|
||||||
|
// 1. 五门禁逐项一过一 fail(纯产物夹具直调 runGates——无 DB 无 spawn):
|
||||||
|
// ① internal-links:dist 内 a[href]/img[src] 根相对目标全命中(html 产物或 asset 文件
|
||||||
|
// 或 manifest 路由——redirect 落点无文件不算死链);外域/锚点/绝对 origin/协议相对/
|
||||||
|
// mailto: 等非根相对形态豁免——注入坏链 → P0 + issue 定位(页面 + href);
|
||||||
|
// ② sitemap-consistency:sitemap 条目 = manifest indexable html 集——多页(extra loc)/
|
||||||
|
// 删页(缺 loc)→ P0;
|
||||||
|
// ③ seo-baseline:每 html 有非空 title/meta description/canonical(manifest 页精确 =
|
||||||
|
// origin+自身路径;非 manifest 页(404.html)前缀匹配)/恰一 H1——缺 title/canonical
|
||||||
|
// 指错/双 H1 → P0;缺 description → P2(主题契约"有则合规"——报告不阻断);
|
||||||
|
// ④ case-collision:dist 全路径 lowercase 无重复(Windows 盘不敏感防线)——NTFS 大小写
|
||||||
|
// 不敏感无法物化冲突对,纯函数 findCaseCollisions 直测;
|
||||||
|
// ⑤ artifact-completeness:route-manifest html 行 target_file 全存在——缺席 → P0;
|
||||||
|
// 2. P0 阻断语义(mini 全链 1 spawn):validating 挂钩注入坏链 → createBuildJob 以
|
||||||
|
// VALIDATION_FAILED 拒绝 → job failed + validation_runs FAIL 行 + validation_issues
|
||||||
|
// 行(severity/rule/url/evidence)+ dist 清场(failed 无产物语义);
|
||||||
|
// 3. 全过语义(mini 全链 1 spawn):真实主题产物过五门禁 → validation_runs PASS 行 +
|
||||||
|
// job succeeded + 报告 json/html 双落盘(HTML 先写——build.json 在则 build.html 必在)。
|
||||||
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { RouteManifest } from '@sf/contracts'
|
||||||
|
import { createBuildJob, BuildError } from '../../apps/api/src/modules/build/build.service.js'
|
||||||
|
import {
|
||||||
|
findCaseCollisions,
|
||||||
|
runGates,
|
||||||
|
GATE_RULE_VERSION,
|
||||||
|
type GateIssue,
|
||||||
|
} from '../../apps/api/src/modules/build/gates.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 { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
|
||||||
|
|
||||||
|
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
|
||||||
|
const WXR_FIXTURE = join(FIXTURES_ROOT, 'wxr', 'mini.xml')
|
||||||
|
|
||||||
|
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
const ORIGIN = 'https://gate-fixture.example'
|
||||||
|
|
||||||
|
// ---- 纯产物夹具(手构 dist + manifest + sitemap——runGates 直调面) ----
|
||||||
|
|
||||||
|
/** 合规 html 页(title/desc/canonical/恰一 H1 + 可配链接面) */
|
||||||
|
function pageHtml(opts: { canonical: string; links?: string[] }): string {
|
||||||
|
const links = (opts.links ?? []).map((l) => ` ${l}`).join('\n')
|
||||||
|
return [
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
|
||||||
|
'<title>Fixture Page</title>',
|
||||||
|
'<meta name="description" content="fixture description"/>',
|
||||||
|
`<link rel="canonical" href="${opts.canonical}"/>`,
|
||||||
|
'</head><body>',
|
||||||
|
'<h1>Heading One</h1>',
|
||||||
|
links,
|
||||||
|
'</body></html>',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_LINKS = [
|
||||||
|
'<a href="/about/">About</a>',
|
||||||
|
'<a href="/old-path/">Redirect</a>',
|
||||||
|
'<img src="/wp-content/uploads/2026/10/a.png" alt="a"/>',
|
||||||
|
// 豁免面:外域 / 锚点 / 绝对 origin / 协议相对 / mailto / javascript / 相对形态
|
||||||
|
'<a href="https://external.example/x">ext</a>',
|
||||||
|
`<a href="${ORIGIN}/about/">abs-origin</a>`,
|
||||||
|
'<a href="#top">anchor</a>',
|
||||||
|
'<a href="//cdn.example/x.png">proto-rel</a>',
|
||||||
|
'<a href="mailto:a@b.example">mail</a>',
|
||||||
|
'<a href="javascript:void(0)">js</a>',
|
||||||
|
'<img src="data:image/png;base64,AAA" alt="data"/>',
|
||||||
|
]
|
||||||
|
|
||||||
|
function baseManifest(): RouteManifest {
|
||||||
|
return {
|
||||||
|
manifest_version: 1,
|
||||||
|
site_id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
build_job_id: '00000000-0000-0000-0000-000000000002',
|
||||||
|
origin: ORIGIN,
|
||||||
|
generated_from_route_snapshot_hash: 'ab'.repeat(32),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
exact_key: '/',
|
||||||
|
query_policy: 'ignore',
|
||||||
|
query_key: null,
|
||||||
|
kind: 'html',
|
||||||
|
target_file: 'index.html',
|
||||||
|
status: 200,
|
||||||
|
content_type: 'text/html',
|
||||||
|
indexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exact_key: '/about/',
|
||||||
|
query_policy: 'ignore',
|
||||||
|
query_key: null,
|
||||||
|
kind: 'html',
|
||||||
|
target_file: 'about/index.html',
|
||||||
|
status: 200,
|
||||||
|
content_type: 'text/html',
|
||||||
|
indexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exact_key: '/wp-content/uploads/2026/10/a.png',
|
||||||
|
query_policy: 'ignore',
|
||||||
|
query_key: null,
|
||||||
|
kind: 'asset',
|
||||||
|
target_file: '/wp-content/uploads/2026/10/a.png',
|
||||||
|
status: 200,
|
||||||
|
content_type: 'image/png',
|
||||||
|
indexable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exact_key: '/old-path/',
|
||||||
|
query_policy: 'ignore',
|
||||||
|
query_key: null,
|
||||||
|
kind: 'redirect',
|
||||||
|
status: 301,
|
||||||
|
location: '/',
|
||||||
|
indexable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseSitemapLocs(): string[] {
|
||||||
|
return [`${ORIGIN}/`, `${ORIGIN}/about/`]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitemapXml(locs: string[]): string {
|
||||||
|
const items = locs.map((l) => ` <url><loc>${l.replaceAll('&', '&')}</loc></url>`)
|
||||||
|
return `<?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`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GateFixture {
|
||||||
|
distDir: string
|
||||||
|
sitemapPath: string
|
||||||
|
work: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合规基线夹具:index + about + 404(非 manifest 页)+ asset + redirect 落点路由 */
|
||||||
|
async function makeGateFixture(prefix: string): Promise<GateFixture> {
|
||||||
|
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
|
||||||
|
const distDir = join(work, 'dist')
|
||||||
|
await mkdir(join(distDir, 'about'), { recursive: true })
|
||||||
|
await mkdir(join(distDir, 'wp-content', 'uploads', '2026', '10'), { recursive: true })
|
||||||
|
await writeFile(join(distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
|
||||||
|
await writeFile(
|
||||||
|
join(distDir, 'about', 'index.html'),
|
||||||
|
pageHtml({ canonical: `${ORIGIN}/about/`, links: ['<a href="/">Home</a>'] }),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// 404.html:非 manifest 页(站点兜底非内容路由)——canonical 前缀匹配(origin 起头)
|
||||||
|
await writeFile(
|
||||||
|
join(distDir, '404.html'),
|
||||||
|
pageHtml({ canonical: `${ORIGIN}/404`, links: ['<a href="/">Home</a>'] }),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
await writeFile(join(distDir, 'wp-content', 'uploads', '2026', '10', 'a.png'), PNG_SIG)
|
||||||
|
const sitemapPath = join(distDir, 'sitemap.xml')
|
||||||
|
await writeFile(sitemapPath, sitemapXml(baseSitemapLocs()), 'utf8')
|
||||||
|
return { distDir, sitemapPath, work }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFixture(
|
||||||
|
fx: GateFixture,
|
||||||
|
manifest: RouteManifest = baseManifest(),
|
||||||
|
): Promise<{ pass: boolean; issues: GateIssue[] }> {
|
||||||
|
return runGates({ distDir: fx.distDir, routeManifest: manifest, sitemapPath: fx.sitemapPath, origin: ORIGIN })
|
||||||
|
}
|
||||||
|
|
||||||
|
function rulesOf(issues: GateIssue[]): string[] {
|
||||||
|
return [...new Set(issues.map((i) => i.rule))]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('最小门禁(纯产物夹具直调)', () => {
|
||||||
|
it('全过基线:合规产物 → pass + 零 issue(豁免面:外域/锚点/绝对 origin/协议相对/mailto/相对形态)', async () => {
|
||||||
|
const fx = await makeGateFixture('sf-gate-base')
|
||||||
|
try {
|
||||||
|
const r = await runFixture(fx)
|
||||||
|
expect(r.pass).toBe(true)
|
||||||
|
expect(r.issues).toStrictEqual([])
|
||||||
|
} finally {
|
||||||
|
await rm(fx.work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('① internal-links fail:坏 a[href]/img[src] → P0 + 定位(页面 + href);redirect 落点路由不算死链', async () => {
|
||||||
|
const fx = await makeGateFixture('sf-gate-link')
|
||||||
|
try {
|
||||||
|
// 注入坏链页:a 指向不存在页面、img 指向不存在 asset(/old-path/ 是 redirect——命中)
|
||||||
|
await writeFile(
|
||||||
|
join(fx.distDir, 'about', 'index.html'),
|
||||||
|
pageHtml({
|
||||||
|
canonical: `${ORIGIN}/about/`,
|
||||||
|
links: ['<a href="/missing-page/">bad</a>', '<img src="/wp-content/uploads/no-such.png" alt="bad"/>', '<a href="/old-path/">ok-redirect</a>', '<a href="/missing-page/?ver=2#frag">bad-query</a>'],
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const r = await runFixture(fx)
|
||||||
|
expect(r.pass).toBe(false)
|
||||||
|
const linkIssues = r.issues.filter((i) => i.rule === 'internal-links')
|
||||||
|
expect(linkIssues).toHaveLength(3)
|
||||||
|
for (const i of linkIssues) {
|
||||||
|
expect(i.severity).toBe('P0')
|
||||||
|
expect(i.url).toBe('/about/')
|
||||||
|
}
|
||||||
|
const hrefs = linkIssues.map((i) => i.evidence.href)
|
||||||
|
expect(hrefs).toStrictEqual(['/missing-page/', '/wp-content/uploads/no-such.png', '/missing-page/?ver=2#frag'])
|
||||||
|
expect(linkIssues[0]!.evidence.page).toBe('about/index.html')
|
||||||
|
// 其余门禁不受牵连
|
||||||
|
expect(rulesOf(r.issues)).toStrictEqual(['internal-links'])
|
||||||
|
} finally {
|
||||||
|
await rm(fx.work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('② sitemap-consistency fail:多页(extra loc)与删页(缺 loc)→ P0(evidence 带 expected/actual 差异)', async () => {
|
||||||
|
const fx = await makeGateFixture('sf-gate-sitemap')
|
||||||
|
try {
|
||||||
|
// 多页:sitemap 多出一条幽灵 loc
|
||||||
|
await writeFile(fx.sitemapPath, sitemapXml([...baseSitemapLocs(), `${ORIGIN}/ghost/`]), 'utf8')
|
||||||
|
const extra = await runFixture(fx)
|
||||||
|
expect(extra.pass).toBe(false)
|
||||||
|
const extraIssues = extra.issues.filter((i) => i.rule === 'sitemap-consistency')
|
||||||
|
expect(extraIssues).toHaveLength(1)
|
||||||
|
expect(extraIssues[0]!.severity).toBe('P0')
|
||||||
|
expect(extraIssues[0]!.evidence.extra).toStrictEqual([`${ORIGIN}/ghost/`])
|
||||||
|
expect(extraIssues[0]!.evidence.missing).toStrictEqual([])
|
||||||
|
|
||||||
|
// 删页:sitemap 缺一条 indexable html
|
||||||
|
await writeFile(fx.sitemapPath, sitemapXml([`${ORIGIN}/about/`]), 'utf8')
|
||||||
|
const missing = await runFixture(fx)
|
||||||
|
expect(missing.pass).toBe(false)
|
||||||
|
const missIssues = missing.issues.filter((i) => i.rule === 'sitemap-consistency')
|
||||||
|
expect(missIssues).toHaveLength(1)
|
||||||
|
expect(missIssues[0]!.evidence.missing).toStrictEqual([`${ORIGIN}/`])
|
||||||
|
expect(missIssues[0]!.evidence.extra).toStrictEqual([])
|
||||||
|
} finally {
|
||||||
|
await rm(fx.work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('③ seo-baseline fail:缺 title/canonical 指错/双 H1 → P0;缺 description → P2 不阻断', async () => {
|
||||||
|
const fx = await makeGateFixture('sf-gate-seo')
|
||||||
|
try {
|
||||||
|
// 缺 title 页(canonical 换行注入保持其余合规)
|
||||||
|
const noTitle = pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace('<title>Fixture Page</title>\n', '')
|
||||||
|
// 双 H1 + canonical 指错 + 缺 description(一个页面对应一类——分别注入三页更清晰,
|
||||||
|
// 这里单页三错以验证同类合并多证据,另以缺 description 单独验证 P2 通道)
|
||||||
|
const messy = [
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
|
||||||
|
'<title>Messy</title>',
|
||||||
|
`<link rel="canonical" href="${ORIGIN}/wrong-path/"/>`,
|
||||||
|
'</head><body>',
|
||||||
|
'<h1>First</h1><h1>Second</h1>',
|
||||||
|
'</body></html>',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
const noDesc = pageHtml({ canonical: `${ORIGIN}/`, links: [] }).replace(
|
||||||
|
'<meta name="description" content="fixture description"/>\n',
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
await writeFile(join(fx.distDir, 'about', 'index.html'), noTitle, 'utf8')
|
||||||
|
await writeFile(join(fx.distDir, '404.html'), messy, 'utf8')
|
||||||
|
// index.html 换成缺 description(canonical 保持 /)
|
||||||
|
await writeFile(join(fx.distDir, 'index.html'), noDesc, 'utf8')
|
||||||
|
|
||||||
|
const r = await runFixture(fx)
|
||||||
|
const seo = r.issues.filter((i) => i.rule === 'seo-baseline')
|
||||||
|
// 缺 title(about)+ canonical 指错(404.html 非 manifest 页——前缀匹配通过?否:
|
||||||
|
// /wrong-path/ 仍以 origin 起头,前缀匹配放行——故 canonical 指错须用 manifest 页验证)
|
||||||
|
expect(seo.some((i) => i.severity === 'P0' && i.url === '/about/' && i.evidence.missing === 'title')).toBe(true)
|
||||||
|
// 双 H1(404.html)
|
||||||
|
expect(seo.some((i) => i.severity === 'P0' && i.evidence.page === '404.html' && i.evidence.h1Count === 2)).toBe(true)
|
||||||
|
// 缺 description → P2(不阻断——pass 只看 P0)
|
||||||
|
expect(seo.some((i) => i.severity === 'P2' && i.evidence.page === 'index.html' && i.evidence.missing === 'description')).toBe(true)
|
||||||
|
expect(r.pass).toBe(false) // 但缺 title/双 H1 的 P0 在场 → 阻断
|
||||||
|
|
||||||
|
// canonical 精确匹配验证:manifest 页指错(/about/ 页 canonical 指到 /)→ P0
|
||||||
|
await writeFile(
|
||||||
|
join(fx.distDir, 'about', 'index.html'),
|
||||||
|
pageHtml({ canonical: `${ORIGIN}/`, links: [] }),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
await writeFile(join(fx.distDir, '404.html'), pageHtml({ canonical: `${ORIGIN}/404`, links: [] }), 'utf8')
|
||||||
|
await writeFile(join(fx.distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
|
||||||
|
const r2 = await runFixture(fx)
|
||||||
|
const canonicalIssue = r2.issues.find((i) => i.rule === 'seo-baseline' && i.evidence.missing === 'canonical')
|
||||||
|
expect(canonicalIssue).toBeDefined()
|
||||||
|
expect(canonicalIssue!.severity).toBe('P0')
|
||||||
|
expect(canonicalIssue!.url).toBe('/about/')
|
||||||
|
expect(canonicalIssue!.evidence.actual).toBe(`${ORIGIN}/`)
|
||||||
|
expect(canonicalIssue!.evidence.expected).toBe(`${ORIGIN}/about/`)
|
||||||
|
expect(r2.pass).toBe(false)
|
||||||
|
|
||||||
|
// 纯 P2 面(只缺 description)不阻断:全页恢复、仅 about 缺 description
|
||||||
|
await writeFile(
|
||||||
|
join(fx.distDir, 'about', 'index.html'),
|
||||||
|
pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace(
|
||||||
|
'<meta name="description" content="fixture description"/>\n',
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const r3 = await runFixture(fx)
|
||||||
|
expect(r3.pass).toBe(true)
|
||||||
|
expect(r3.issues.every((i) => i.severity === 'P2')).toBe(true)
|
||||||
|
expect(r3.issues).toHaveLength(1)
|
||||||
|
} finally {
|
||||||
|
await rm(fx.work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('④ case-collision:lowercase 归一后重复 → 冲突对(纯函数直测——NTFS 大小写不敏感无法物化冲突对)', async () => {
|
||||||
|
// 过:无重复
|
||||||
|
expect(findCaseCollisions(['index.html', 'about/index.html', 'A.png', 'b/c.png'])).toStrictEqual([])
|
||||||
|
// fail:仅大小写不同的两个路径 → 冲突对(目录段与文件名段都参与归一)
|
||||||
|
expect(findCaseCollisions(['Docs/x.html', 'docs/x.html'])).toStrictEqual([
|
||||||
|
{ lower: 'docs/x.html', paths: ['Docs/x.html', 'docs/x.html'] },
|
||||||
|
])
|
||||||
|
expect(findCaseCollisions(['a/B.html', 'A/b.html', 'A/B.html'])).toStrictEqual([
|
||||||
|
{ lower: 'a/b.html', paths: ['A/B.html', 'A/b.html', 'a/B.html'] },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('⑤ artifact-completeness fail:manifest html 行 target_file 缺席 → P0(evidence 带 route 与 target_file)', async () => {
|
||||||
|
const fx = await makeGateFixture('sf-gate-comp')
|
||||||
|
try {
|
||||||
|
// 物理删除 about 产物(模拟主题落盘缺席)
|
||||||
|
await rm(join(fx.distDir, 'about'), { recursive: true, force: true })
|
||||||
|
const r = await runFixture(fx)
|
||||||
|
expect(r.pass).toBe(false)
|
||||||
|
const comp = r.issues.filter((i) => i.rule === 'artifact-completeness')
|
||||||
|
expect(comp).toHaveLength(1)
|
||||||
|
expect(comp[0]!.severity).toBe('P0')
|
||||||
|
expect(comp[0]!.url).toBe('/about/')
|
||||||
|
expect(comp[0]!.evidence.target_file).toBe('about/index.html')
|
||||||
|
// 死链面同时感知缺席(index→about 链接死);SEO 面只查在场文件——缺席文件不产 issue
|
||||||
|
expect(rulesOf(r.issues)).toStrictEqual(['internal-links', 'artifact-completeness'])
|
||||||
|
} finally {
|
||||||
|
await rm(fx.work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- mini 全链(DB + 真实 astro 构建——P0 阻断/全过语义) ----
|
||||||
|
|
||||||
|
interface Work {
|
||||||
|
work: string
|
||||||
|
storageRoot: string
|
||||||
|
allowedRoots: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-a-${rand()}`)]))
|
||||||
|
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-b-${rand()}`)]))
|
||||||
|
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
|
||||||
|
const key = `sfgate-${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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs)+ 全链反序 */
|
||||||
|
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
|
||||||
|
await db.execute(`delete from validation_issues where validation_run_id in
|
||||||
|
(select id from validation_runs where site_id = '${siteId}')`)
|
||||||
|
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from build_artifacts where build_job_id in
|
||||||
|
(select id from build_jobs where site_id = '${siteId}')`)
|
||||||
|
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from build_jobs where site_id = '${siteId}'`)
|
||||||
|
await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from terms where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from source_records where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
|
||||||
|
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
|
||||||
|
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
|
||||||
|
await db.execute(`delete from sites where id = '${siteId}'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () => {
|
||||||
|
it('任一 P0 → validation_runs FAIL + validation_issues 落库(severity/rule/url/evidence)+ job failed + dist 清场', { timeout: 300_000 }, async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const w = await makeWork('sf-gate-block')
|
||||||
|
try {
|
||||||
|
const { key, siteId } = await seedPublishedSite(db, w)
|
||||||
|
try {
|
||||||
|
const artifactsRoot = join(w.work, 'artifacts')
|
||||||
|
let blockedJobId = ''
|
||||||
|
const err: unknown = await createBuildJob(db, {
|
||||||
|
siteKey: key,
|
||||||
|
artifactsRoot,
|
||||||
|
idempotencyKey: `build:test:gate-block:${rand()}`,
|
||||||
|
onStatus: (s, jobId) => {
|
||||||
|
// validating 挂钩点:构建产物已拷出、门禁未跑——向 index.html 注入坏链
|
||||||
|
if (s === 'validating') {
|
||||||
|
blockedJobId = jobId
|
||||||
|
const index = join(artifactsRoot, 'jobs', jobId, 'dist', 'index.html')
|
||||||
|
const html = readFileSync(index, 'utf8')
|
||||||
|
writeFileSync(index, `${html}<a href="/no-such-page/">injected</a>`, 'utf8')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).then(
|
||||||
|
() => {
|
||||||
|
throw new Error('不应成功')
|
||||||
|
},
|
||||||
|
(e: unknown) => e,
|
||||||
|
)
|
||||||
|
expect(err).toBeInstanceOf(BuildError)
|
||||||
|
expect((err as BuildError).code).toBe('VALIDATION_FAILED')
|
||||||
|
expect((err as BuildError).message).toContain('no-such-page')
|
||||||
|
|
||||||
|
// job 行:failed 终态 + VALIDATION_FAILED 码
|
||||||
|
const job = (await db.execute(`select status, error_code, error_summary from build_jobs
|
||||||
|
where id = '${blockedJobId}'`)) as any
|
||||||
|
expect(job).toHaveLength(1)
|
||||||
|
expect(job[0].status).toBe('failed')
|
||||||
|
expect(job[0].error_code).toBe('VALIDATION_FAILED')
|
||||||
|
expect(job[0].error_summary).toContain('no-such-page')
|
||||||
|
|
||||||
|
// validation_runs:FAIL 行(site/job 关联 + rule_version + 起止时间戳)
|
||||||
|
const run = (await db.execute(`select vr.status, vr.rule_version, vr.build_job_id,
|
||||||
|
extract(epoch from vr.started_at)::float8 as s_ts,
|
||||||
|
extract(epoch from vr.finished_at)::float8 as f_ts
|
||||||
|
from validation_runs vr where vr.build_job_id = '${blockedJobId}'`)) as any
|
||||||
|
expect(run).toHaveLength(1)
|
||||||
|
expect(run[0].status).toBe('FAIL')
|
||||||
|
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
|
||||||
|
expect(run[0].s_ts).toBeGreaterThan(0)
|
||||||
|
expect(run[0].f_ts).toBeGreaterThanOrEqual(run[0].s_ts)
|
||||||
|
|
||||||
|
// validation_issues:每 issue 一行(severity/rule/url/evidence)
|
||||||
|
const issues = (await db.execute(`select vi.severity, vi.rule, vi.url, vi.evidence, vi.resolution_status
|
||||||
|
from validation_issues vi join validation_runs vr on vi.validation_run_id = vr.id
|
||||||
|
where vr.build_job_id = '${blockedJobId}' order by vi.rule, vi.url`)) as any
|
||||||
|
expect(issues.length).toBeGreaterThanOrEqual(1)
|
||||||
|
const link = issues.find((i: any) => i.rule === 'internal-links')
|
||||||
|
expect(link).toBeDefined()
|
||||||
|
expect(link.severity).toBe('P0')
|
||||||
|
expect(link.url).toBe('/')
|
||||||
|
expect(link.evidence.href).toBe('/no-such-page/')
|
||||||
|
expect(link.evidence.page).toBe('index.html')
|
||||||
|
for (const i of issues) {
|
||||||
|
expect(['P0', 'P2']).toContain(i.severity)
|
||||||
|
expect(i.resolution_status).toBe('open')
|
||||||
|
expect(typeof i.rule).toBe('string')
|
||||||
|
}
|
||||||
|
|
||||||
|
// failed 无产物语义:dist 清场(issue 证据已在库——调试面在 validation_issues)
|
||||||
|
expect(existsSync(join(artifactsRoot, 'jobs', blockedJobId, 'dist'))).toBe(false)
|
||||||
|
} finally {
|
||||||
|
await cleanupSiteRows(db, siteId)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(w.work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全过 → validation_runs PASS + 零 issue 行 + job succeeded + 报告 json/html 双落盘(HTML 先写)', { timeout: 300_000 }, async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const w = await makeWork('sf-gate-pass')
|
||||||
|
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.status).toBe('succeeded')
|
||||||
|
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
|
||||||
|
|
||||||
|
// validation_runs:PASS 行 + 零 issue
|
||||||
|
const run = (await db.execute(`select status, rule_version from validation_runs
|
||||||
|
where build_job_id = '${result.jobId}'`)) as any
|
||||||
|
expect(run).toHaveLength(1)
|
||||||
|
expect(run[0].status).toBe('PASS')
|
||||||
|
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
|
||||||
|
const issueCount = (await db.execute(`select count(*)::int as n from validation_issues vi
|
||||||
|
join validation_runs vr on vi.validation_run_id = vr.id
|
||||||
|
where vr.build_job_id = '${result.jobId}'`)) as any
|
||||||
|
expect(Number(issueCount[0].n)).toBe(0)
|
||||||
|
|
||||||
|
// 报告双落盘:build.json 在则 build.html 必在(HTML 先写窄窗口修);
|
||||||
|
// html 为转义模板渲染(含 jobId 与 PASS 面、无 script)
|
||||||
|
const reportJson = JSON.parse(await readFile(result.reportPath, 'utf8'))
|
||||||
|
expect(reportJson.validation.status).toBe('PASS')
|
||||||
|
expect(reportJson.validation.ruleVersion).toBe(GATE_RULE_VERSION)
|
||||||
|
const reportHtmlPath = join(w.work, 'artifacts', 'jobs', result.jobId, 'reports', 'build.html')
|
||||||
|
expect(statSync(reportHtmlPath).isFile()).toBe(true)
|
||||||
|
const html = await readFile(reportHtmlPath, 'utf8')
|
||||||
|
expect(html).toContain(result.jobId)
|
||||||
|
expect(html).toContain('PASS')
|
||||||
|
expect(html).not.toContain('<script')
|
||||||
|
} finally {
|
||||||
|
await cleanupSiteRows(db, siteId)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(w.work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -105,8 +105,12 @@ async function scanThemeSrc(): Promise<Map<string, string>> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本文件写入的行清场:构建域(build_artifacts 先于 build_jobs——FK)+ 全链反序 */
|
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→
|
||||||
|
* build_jobs;Task 6 起成功构建必落 validation_runs 行)+ 构建域 + 全链反序 */
|
||||||
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
|
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
|
||||||
|
await db.execute(`delete from validation_issues where validation_run_id in
|
||||||
|
(select id from validation_runs where site_id = '${siteId}')`)
|
||||||
|
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
|
||||||
await db.execute(`delete from build_artifacts where build_job_id in
|
await db.execute(`delete from build_artifacts where build_job_id in
|
||||||
(select id from build_jobs where site_id = '${siteId}')`)
|
(select id from build_jobs where site_id = '${siteId}')`)
|
||||||
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
|
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。
|
// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。
|
||||||
// 覆盖契约(对照计划 Task 2):
|
// 覆盖契约(对照计划 Task 2):
|
||||||
// 1. build job 状态机:queued→snapshotting→building(Task 5 真实构建)→validating
|
// 1. build job 状态机:queued→snapshotting→building(Task 5 真实构建)→validating
|
||||||
// (空,Task 6 填)→succeeded +
|
// (Task 6 最小门禁)→succeeded +
|
||||||
// build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+
|
// build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+
|
||||||
// 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、
|
// 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、
|
||||||
// theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+
|
// theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+
|
||||||
@@ -71,8 +71,12 @@ async function countOf(db: Db, sql: string): Promise<number> {
|
|||||||
return Number(rows[0].n)
|
return Number(rows[0].n)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本文件写入的行清场:构建域(build_artifacts 先于 build_jobs——FK;不可变快照先行)+ 发布/导入/来源全链反序 */
|
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs;
|
||||||
|
// Task 6 起成功构建必落 validation_runs 行)+ 构建域(不可变快照先行)+ 全链反序 */
|
||||||
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
|
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
|
||||||
|
await db.execute(`delete from validation_issues where validation_run_id in
|
||||||
|
(select id from validation_runs where site_id = '${siteId}')`)
|
||||||
|
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
|
||||||
await db.execute(`delete from build_artifacts where build_job_id in
|
await db.execute(`delete from build_artifacts where build_job_id in
|
||||||
(select id from build_jobs where site_id = '${siteId}')`)
|
(select id from build_jobs where site_id = '${siteId}')`)
|
||||||
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
|
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
|
||||||
@@ -135,18 +139,17 @@ async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId
|
|||||||
return { key, siteId: reg.siteId }
|
return { key, siteId: reg.siteId }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取六输入文件为解析对象 */
|
/** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */
|
||||||
async function readInputFiles(inputDir: string): Promise<Record<string, any>> {
|
function parsedFiles(m: { files: Record<string, string> }): Record<string, any> {
|
||||||
const names = ['site', 'content', 'taxonomies', 'routes', 'assets', 'render-manifest']
|
|
||||||
const out: Record<string, any> = {}
|
const out: Record<string, any> = {}
|
||||||
for (const n of names) {
|
for (const [name, content] of Object.entries(m.files)) {
|
||||||
out[n] = JSON.parse(await readFile(join(inputDir, `${n}.json`), 'utf8'))
|
out[name.replace(/\.json$/, '')] = JSON.parse(content)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('build job + Render Snapshot 物化', () => {
|
describe('build job + Render Snapshot 物化', () => {
|
||||||
it('状态机 queued→snapshotting→validating(空)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', async () => {
|
it('状态机 queued→snapshotting→building→validating(门禁)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', { timeout: 300_000 }, async () => {
|
||||||
await withTestDb(async (db) => {
|
await withTestDb(async (db) => {
|
||||||
const w = await makeWork('sf-build-sm')
|
const w = await makeWork('sf-build-sm')
|
||||||
try {
|
try {
|
||||||
@@ -160,8 +163,8 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
})
|
})
|
||||||
expect(result.reused).toBe(false)
|
expect(result.reused).toBe(false)
|
||||||
expect(result.status).toBe('succeeded')
|
expect(result.status).toBe('succeeded')
|
||||||
// 状态机序列(Task 5 起 snapshotting 后进 building:真实 astro 构建管线;
|
// 状态机序列:snapshotting 后进 building(真实 astro 构建管线)→ validating
|
||||||
// validating 空:Task 6 填真实门禁)
|
// (Task 6 最小门禁——mini 夹具产物过五门禁)→ succeeded
|
||||||
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
|
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
|
||||||
|
|
||||||
// build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言;
|
// build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言;
|
||||||
@@ -238,22 +241,18 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)', async () => {
|
it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
|
||||||
await withTestDb(async (db) => {
|
await withTestDb(async (db) => {
|
||||||
const w = await makeWork('sf-build-files')
|
const w = await makeWork('sf-build-files')
|
||||||
try {
|
try {
|
||||||
const { key, siteId } = await seedPublishedSite(db, w)
|
const { key, siteId } = await seedPublishedSite(db, w)
|
||||||
try {
|
try {
|
||||||
const result = await createBuildJob(db, {
|
const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
|
||||||
siteKey: key,
|
// 六文件面齐备(RENDER_SNAPSHOT_FILES 冻结清单——物化产物键即清单)
|
||||||
artifactsRoot: join(w.work, 'artifacts'),
|
expect(Object.keys(m.files).sort()).toStrictEqual(
|
||||||
})
|
['assets.json', 'content.json', 'render-manifest.json', 'routes.json', 'site.json', 'taxonomies.json'],
|
||||||
const inputDir = result.inputDir
|
)
|
||||||
// 六文件全在(RENDER_SNAPSHOT_FILES 冻结清单)
|
const files = parsedFiles(m)
|
||||||
for (const name of ['site.json', 'content.json', 'taxonomies.json', 'routes.json', 'assets.json', 'render-manifest.json']) {
|
|
||||||
expect(existsSync(join(inputDir, name))).toBe(true)
|
|
||||||
}
|
|
||||||
const files = await readInputFiles(inputDir)
|
|
||||||
|
|
||||||
// site.json = config v1 数据(Task 1 首版生成的 {name,origin,locale,brand})
|
// site.json = config v1 数据(Task 1 首版生成的 {name,origin,locale,brand})
|
||||||
expect(files.site).toStrictEqual({
|
expect(files.site).toStrictEqual({
|
||||||
@@ -369,7 +368,7 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
routes: 12,
|
routes: 12,
|
||||||
assets: 2,
|
assets: 2,
|
||||||
})
|
})
|
||||||
expect(files['render-manifest'].sourceHashes).toStrictEqual(result.sourceHashes)
|
expect(files['render-manifest'].sourceHashes).toStrictEqual(m.sourceHashes)
|
||||||
expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 })
|
expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 })
|
||||||
expect(typeof files['render-manifest'].generatedAt).toBe('string')
|
expect(typeof files['render-manifest'].generatedAt).toBe('string')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -381,14 +380,13 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计', async () => {
|
it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
|
||||||
await withTestDb(async (db) => {
|
await withTestDb(async (db) => {
|
||||||
const w = await makeWork('sf-build-rewrite')
|
const w = await makeWork('sf-build-rewrite')
|
||||||
try {
|
try {
|
||||||
const { key, siteId } = await seedPublishedSite(db, w)
|
const { siteId } = await seedPublishedSite(db, w)
|
||||||
try {
|
try {
|
||||||
const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
const files = parsedFiles(await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }))
|
||||||
const files = await readInputFiles(result.inputDir)
|
|
||||||
const news = files.content.find((c: any) => c.slug === 'fixture-news-a')
|
const news = files.content.find((c: any) => c.slug === 'fixture-news-a')
|
||||||
|
|
||||||
// 库内 published 修订的 bodyHtml(重写前真相):绝对 URL img + 根相对内链 a
|
// 库内 published 修订的 bodyHtml(重写前真相):绝对 URL img + 根相对内链 a
|
||||||
@@ -489,15 +487,14 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json 仍为 A(published 冻结)', async () => {
|
it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 再物化 → content.json 仍为 A(published 冻结)【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
|
||||||
await withTestDb(async (db) => {
|
await withTestDb(async (db) => {
|
||||||
const w = await makeWork('sf-build-adr010')
|
const w = await makeWork('sf-build-adr010')
|
||||||
try {
|
try {
|
||||||
const { key, siteId } = await seedPublishedSite(db, w)
|
const { key, siteId } = await seedPublishedSite(db, w)
|
||||||
try {
|
try {
|
||||||
// 第一次 build:published=A 物化
|
// 第一次物化:published=A
|
||||||
const first = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
const first = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
|
||||||
expect(first.reused).toBe(false)
|
|
||||||
|
|
||||||
// 改内容再导入:post 101 正文加 V2 标记 → 新修订 B、current=B(published 仍 A)
|
// 改内容再导入:post 101 正文加 V2 标记 → 新修订 B、current=B(published 仍 A)
|
||||||
const wxrText = await readFile(WXR_FIXTURE, 'utf8')
|
const wxrText = await readFile(WXR_FIXTURE, 'utf8')
|
||||||
@@ -518,18 +515,12 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
where site_id = '${siteId}' and current_revision_id is distinct from published_revision_id`)) as any)[0].n
|
where site_id = '${siteId}' and current_revision_id is distinct from published_revision_id`)) as any)[0].n
|
||||||
expect(Number(forked)).toBe(1)
|
expect(Number(forked)).toBe(1)
|
||||||
|
|
||||||
// 新幂等键强制新 job(默认键按 published 集合派生会复用——复用语义另测)
|
// 再物化(等价于"新 key 再 build"的输入面——job/幂等键语义在谱系测试覆盖)
|
||||||
const second = await createBuildJob(db, {
|
const second = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
|
||||||
siteKey: key,
|
|
||||||
artifactsRoot: join(w.work, 'artifacts'),
|
|
||||||
idempotencyKey: `build:test:adr010:${rand()}`,
|
|
||||||
})
|
|
||||||
expect(second.reused).toBe(false)
|
|
||||||
expect(second.jobId).not.toBe(first.jobId)
|
|
||||||
|
|
||||||
// content.json 仍为 A:字节级等于第一次物化(published 冻结语义)
|
// content.json 仍为 A:字节级等于第一次物化(published 冻结语义)
|
||||||
const contentA = await readFile(join(first.inputDir, 'content.json'), 'utf8')
|
const contentA = first.files['content.json']
|
||||||
const contentB = await readFile(join(second.inputDir, 'content.json'), 'utf8')
|
const contentB = second.files['content.json']
|
||||||
expect(contentB).toBe(contentA)
|
expect(contentB).toBe(contentA)
|
||||||
expect(contentB).not.toContain('V2-BODY')
|
expect(contentB).not.toContain('V2-BODY')
|
||||||
expect(contentB).toContain('fixture-news-a')
|
expect(contentB).toContain('fixture-news-a')
|
||||||
@@ -584,60 +575,9 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('job 复用只认 succeeded:failed job + 默认键重跑 → #a2 新行成功、旧行保持 failed(审查 Fix 3)', async () => {
|
it('job 谱系:崩溃残留 stale 宣判 → #a2 落败不复用 → #a3 重跑成功(复用只认 succeeded;审查 Fix 3 两场景并谱——spawn 缩减共享单次成功构建)', { timeout: 300_000 }, async () => {
|
||||||
await withTestDb(async (db) => {
|
await withTestDb(async (db) => {
|
||||||
const w = await makeWork('sf-build-failed')
|
const w = await makeWork('sf-build-lineage')
|
||||||
try {
|
|
||||||
const { key, siteId } = await seedPublishedSite(db, w)
|
|
||||||
try {
|
|
||||||
// 迫使快照落盘失败:artifactsRoot 指到普通文件之下 → mkdir ENOTDIR
|
|
||||||
const blocker = join(w.work, 'blocker')
|
|
||||||
await writeFile(blocker, 'x', 'utf8')
|
|
||||||
const err: unknown = await createBuildJob(db, {
|
|
||||||
siteKey: key,
|
|
||||||
artifactsRoot: join(blocker, 'artifacts'),
|
|
||||||
}).then(
|
|
||||||
() => {
|
|
||||||
throw new Error('不应成功')
|
|
||||||
},
|
|
||||||
(e: unknown) => e,
|
|
||||||
)
|
|
||||||
expect(err).toBeInstanceOf(Error)
|
|
||||||
const afterFail = plain(
|
|
||||||
(await db.execute(`select status, error_code, attempt from build_jobs
|
|
||||||
where site_id = '${siteId}'`)) as any,
|
|
||||||
)
|
|
||||||
expect(afterFail).toHaveLength(1)
|
|
||||||
expect(afterFail[0].status).toBe('failed')
|
|
||||||
expect(afterFail[0].error_code).toBe('SNAPSHOT_FAILED')
|
|
||||||
expect(Number(afterFail[0].attempt)).toBe(1)
|
|
||||||
|
|
||||||
// 默认键重跑(好根):不复用 failed 行 → 后缀键新行成功
|
|
||||||
const rerun = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
|
||||||
expect(rerun.reused).toBe(false)
|
|
||||||
expect(rerun.status).toBe('succeeded')
|
|
||||||
expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(2)
|
|
||||||
// 旧行保持 failed;新行键带 #a2 后缀、attempt 延续 = 2(重试谱系)
|
|
||||||
const rows = plain(
|
|
||||||
(await db.execute(`select status, idempotency_key, attempt from build_jobs
|
|
||||||
where site_id = '${siteId}' order by attempt`)) as any,
|
|
||||||
)
|
|
||||||
expect(rows[0].status).toBe('failed')
|
|
||||||
expect(rows[1].status).toBe('succeeded')
|
|
||||||
expect(String(rows[1].idempotency_key)).toContain('#a2')
|
|
||||||
expect(Number(rows[1].attempt)).toBe(2)
|
|
||||||
} finally {
|
|
||||||
await cleanupSiteRows(db, siteId)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await rm(w.work, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('崩溃残留回收:滞留 snapshotting 的默认键行 → stale 宣判 + 新 job 物化成功(审查 Fix 3)', async () => {
|
|
||||||
await withTestDb(async (db) => {
|
|
||||||
const w = await makeWork('sf-build-stale')
|
|
||||||
try {
|
try {
|
||||||
const { key, siteId } = await seedPublishedSite(db, w)
|
const { key, siteId } = await seedPublishedSite(db, w)
|
||||||
try {
|
try {
|
||||||
@@ -650,21 +590,44 @@ describe('build job + Render Snapshot 物化', () => {
|
|||||||
await db.execute(`insert into build_jobs (site_id, status, idempotency_key, attempt, started_at)
|
await db.execute(`insert into build_jobs (site_id, status, idempotency_key, attempt, started_at)
|
||||||
values ('${siteId}', 'snapshotting', '${defKey}', 1, now())`)
|
values ('${siteId}', 'snapshotting', '${defKey}', 1, now())`)
|
||||||
|
|
||||||
const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
// 撞悬行跑失败:残留行宣判 stale 让位 → #a2 新行;再迫使快照落盘失败
|
||||||
expect(result.reused).toBe(false)
|
// (artifactsRoot 指到普通文件之下 → mkdir ENOTDIR)
|
||||||
expect(result.status).toBe('succeeded')
|
const blocker = join(w.work, 'blocker')
|
||||||
// 旧行宣判 stale;新行成功——两行并存(键空间让位)
|
await writeFile(blocker, 'x', 'utf8')
|
||||||
|
const err: unknown = await createBuildJob(db, {
|
||||||
|
siteKey: key,
|
||||||
|
artifactsRoot: join(blocker, 'artifacts'),
|
||||||
|
}).then(
|
||||||
|
() => {
|
||||||
|
throw new Error('不应成功')
|
||||||
|
},
|
||||||
|
(e: unknown) => e,
|
||||||
|
)
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
|
||||||
|
// 默认键重跑(好根):default → stale(终态死行)→ #a2 → failed(终态死行)
|
||||||
|
// → #a3 新行成功——failed 不复用、attempt 延续(重试谱系)
|
||||||
|
const rerun = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
|
||||||
|
expect(rerun.reused).toBe(false)
|
||||||
|
expect(rerun.status).toBe('succeeded')
|
||||||
|
expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(3)
|
||||||
const rows = plain(
|
const rows = plain(
|
||||||
(await db.execute(`select status, idempotency_key from build_jobs
|
(await db.execute(`select status, idempotency_key, attempt, error_code from build_jobs
|
||||||
where site_id = '${siteId}' order by attempt`)) as any,
|
where site_id = '${siteId}' order by attempt`)) as any,
|
||||||
)
|
)
|
||||||
expect(rows).toHaveLength(2)
|
// 谱系三行:stale(宣判)→ failed(SNAPSHOT_FAILED 保持终态)→ succeeded(#a3)
|
||||||
expect(rows[0].status).toBe('stale')
|
expect(rows[0].status).toBe('stale')
|
||||||
expect(rows[0].idempotency_key).toBe(defKey)
|
expect(rows[0].idempotency_key).toBe(defKey)
|
||||||
expect(rows[1].status).toBe('succeeded')
|
expect(Number(rows[0].attempt)).toBe(1)
|
||||||
expect(rows[1].idempotency_key).toBe(`${defKey}#a2`)
|
expect(rows[1].status).toBe('failed')
|
||||||
|
expect(rows[1].error_code).toBe('SNAPSHOT_FAILED')
|
||||||
|
expect(String(rows[1].idempotency_key)).toBe(`${defKey}#a2`)
|
||||||
|
expect(Number(rows[1].attempt)).toBe(2)
|
||||||
|
expect(rows[2].status).toBe('succeeded')
|
||||||
|
expect(String(rows[2].idempotency_key)).toBe(`${defKey}#a3`)
|
||||||
|
expect(Number(rows[2].attempt)).toBe(3)
|
||||||
// 新 job 物化完整
|
// 新 job 物化完整
|
||||||
expect(existsSync(join(result.inputDir, 'render-manifest.json'))).toBe(true)
|
expect(existsSync(join(rerun.inputDir, 'render-manifest.json'))).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSiteRows(db, siteId)
|
await cleanupSiteRows(db, siteId)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user