Files
cms-website/scripts/e2e-real.mjs
T
浅念 c64f6ac48a test(evidence): 阶段4端到端预览验收
Plan 4 Task 8:真实 hesupipe 全链(register 副本复用 → import → publish
→ build → preview)临时库端到端验收,断言 21/21 PASS + 浏览器时刻 6 截图
无破版无横向溢出。新增 scripts/e2e-real.mjs(chain/serve 两子命令)与
docs/evidence/phase4-preview.md + phase4-assertions.json + screenshots/。

验收内修补(预览服务器,均有回归测试锁定):
- Location 头 ASCII 化:非 ASCII 码位按 UTF-8 百分号编码——原样 setHeader
  触发 ERR_INVALID_CHAR 崩 500 或 latin1 mojibake(1746 redirect 全量
  校验显影 2+3 例);manifest 原串保持权威,测试 2b 锁定。
- CSP 放行内联样式:Astro 5 scoped 样式内联 <style> 被 default-src 'self'
  拦截(浏览器时刻显影,HTTP 级不可见);测试 6 断言同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 14:33:55 +08:00

777 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// 真实 hesupipe 端到端编排(Plan 4 · Task 8,快线 §8.7 第 10 天验收标准):
// 临时库全链 register(副本复用)→ import → publish(dry-run→confirm)→ build
// (onStatus 分相计时)→ astro 分相探针(冻结输入重跑计时,暂存盘即清)→ 进程内
// preview(回环随机端口 + quiet)→ 断言 ①-⑤⑦⑧⑨ → docs/evidence/phase4-assertions.json。
// ⑥(双端截图)走 serve 子命令 + Playwright(浏览器域,证据入 docs/evidence/screenshots/)。
//
// 运行方式(仓库根,Git Bash):
// DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_hesupipe" \
// pnpm exec tsx scripts/e2e-real.mjs chain --site hesupipe-real
// DATABASE_URL_TEST="…" pnpm exec tsx scripts/e2e-real.mjs serve --build <jobId> --port 4571
// 与 scripts/sf.ts / smoke-real-wxr.mjs 同机制:@sf/* 与 apps/api TS 源码经根 tsconfig
// paths 由 tsx 直连(纯 node 无法解析 workspace TS 包)。
//
// 纪律(Task 5 备忘):与任何并发测试/构建互斥(themeDistDir 结构性共享暂存)——执行前
// 确认无其他 spawn 在跑;全部子步骤串行。数据面钉死(Task 7 审查备忘):sitemap 从盘读、
// redirect 校验用 route-manifest 原串(exact_key 逐字节,含 ?attachment_id= 查询形态)。
//
// 幂等安全:重跑 chain 时 register 快照复用/import job 复用/publish 已等跳过/build 仅
// succeeded 复用(failed 自动 #a 后缀重跑)——断言计数一律以 SQL/盘读为准,不依赖当次
// 返回值,重跑不产生假失败。
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import http from 'node:http'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { getDb } from '../apps/api/src/db/client.js'
import { createImportJob } from '../apps/api/src/modules/import/import.service.js'
import { createBuildJob } from '../apps/api/src/modules/build/build.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 { createPreviewServer } from '../apps/preview-server/server.mjs'
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const THEME_PACKAGE = '@sf/theme-pilot-minimal'
const ACTOR = 'e2e-phase4@cli'
// ---- 小工具 ----
function decodeEntities(s) {
return s
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}
function titleOf(html) {
const m = /<title>([\s\S]*?)<\/title>/.exec(html)
return m === null ? '' : decodeEntities(m[1]).trim()
}
function metaDescriptionOf(html) {
// SeoHead 属性序两种形态都认(属性序是实现细节,断言只认语义)
for (const re of [
/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/,
/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/,
]) {
const m = re.exec(html)
if (m !== null) return decodeEntities(m[1])
}
return ''
}
function canonicalOf(html) {
for (const re of [
/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']*)["']/,
/<link[^>]*href=["']([^"']*)["'][^>]*rel=["']canonical["']/,
]) {
const m = re.exec(html)
if (m !== null) return decodeEntities(m[1])
}
return ''
}
/**
* 页面文本命中(证据级匹配):正文文本经 Astro 实体转义(& → &amp;,源数据里 WP 侧
* 预转义的 &amp; 会二次转义)——比较前对页面做两次实体解码(覆盖预转义源)、对预期
* 文本解码一次,再做 includes。
*/
function htmlTextIncludes(body, text) {
return decodeEntities(decodeEntities(body)).includes(decodeEntities(text))
}
/** server.mjs asciiLocation 的脚本侧镜像:manifest 原串 → RFC 头域 ASCII 派生形 */
function asciiLocationMirror(value) {
let out = ''
for (const ch of value) {
const code = ch.codePointAt(0)
out += code >= 0x20 && code <= 0x7e ? ch : encodeURIComponent(ch)
}
return out
}
/** contentTitle 的脚本侧镜像(themes ui.ts 同语义:data.title ?? knownFields.title ?? slug) */
function contentTitle(c) {
const direct = c.data.title
if (typeof direct === 'string' && direct !== '') return direct
const known = c.data.knownFields?.title
if (typeof known === 'string' && known !== '') return known
return c.slug
}
function contentDate(c) {
const d = c.data.knownFields?.postDate
return typeof d === 'string' && d !== '' ? d : undefined
}
/** latestEntries 镜像(ui.ts 同语义:postDate 降序,无日期者殿后,稳定序) */
function latestEntries(contents, typeKey, limit) {
const of = contents.filter((c) => c.typeKey === typeKey)
const dated = of.filter((c) => contentDate(c) !== undefined)
const undated = of.filter((c) => contentDate(c) === undefined)
const sorted = [...dated].sort((a, b) => {
const da = contentDate(a)
const db = contentDate(b)
return da < db ? 1 : da > db ? -1 : 0
})
return [...sorted, ...undated].slice(0, limit)
}
async function walkHtml(dir, base = dir, acc = []) {
for (const e of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, e.name)
if (e.isDirectory()) await walkHtml(full, base, acc)
else if (e.name.endsWith('.html')) acc.push({ abs: full, rel: relative(base, full).replaceAll('\\', '/') })
}
return acc
}
/** 简易并发池(回环 keep-alive 冒烟全量用) */
async function pool(items, concurrency, fn) {
const results = new Array(items.length)
let next = 0
async function worker() {
for (;;) {
const i = next++
if (i >= items.length) return
results[i] = await fn(items[i], i)
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
return results
}
function makeHttpClient(port) {
const agent = new http.Agent({ keepAlive: true, maxSockets: 8 })
function get(rawPath) {
return new Promise((resolveGet, rejectGet) => {
const req = http.get({ host: '127.0.0.1', port, path: rawPath, agent }, (res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () =>
resolveGet({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }),
)
})
req.on('error', rejectGet)
})
}
return { get, agent }
}
// ---- 断言记录器(预期/实测/判定三列,双写进 JSON 与 stdout) ----
const rows = []
function record(id, expect, actual, pass, note = '') {
rows.push({ id, expect, actual, pass, note })
console.log(` ${pass ? 'PASS' : 'FAIL'} ${id}${note === '' ? '' : `(${note})`}`)
console.log(` 预期=${expect}`)
console.log(` 实测=${actual}`)
return pass
}
// ---- chain 子命令 ----
async function cmdChain(opts) {
const db = getDb()
const timings = {}
const chain = {}
try {
// ① register(副本复用——content-addressed 快照在 storage/snapshots 已有同哈希实体)
console.log('[chain] source:register …')
const tReg = performance.now()
const reg = await registerSource(db, {
siteKey: opts.site,
allowedRoots: [join(REPO_ROOT, 'storage', 'incoming')],
storageRoot: join(REPO_ROOT, 'storage'),
reportRoot: join(REPO_ROOT, 'artifacts', 'register'),
wxrPath: join(REPO_ROOT, 'storage', 'incoming', 'hesupipe-wxr.xml'),
mediaDir: join(REPO_ROOT, 'storage', 'incoming', 'hesupipe-media'),
mediaMapPath: join(REPO_ROOT, 'storage', 'incoming', 'media-map-hesupipe.json'),
siteDefaults: { defaultLocale: 'en', productionOrigin: 'https://hesupipe.com' },
})
timings.registerWallMs = performance.now() - tReg
timings.registerServiceMs = reg.durationMs
chain.register = {
runId: reg.runId,
siteId: reg.siteId,
siteCreated: reg.siteCreated,
connectionId: reg.connectionId,
snapshots: reg.snapshots.map((s) => ({
kind: s.kind,
sha256: s.sha256.slice(0, 12),
byteSize: s.byteSize,
reused: s.reused,
copied: s.copied,
durationMs: s.durationMs,
})),
}
for (const s of reg.snapshots) {
console.log(` snapshot ${s.kind}: ${s.reused ? '复用' : '新建'}${s.copied ? '' : '(副本已存在)'} ${s.durationMs.toFixed(1)}ms`)
}
// ② import(默认幂等键——干净库首跑)
console.log('[chain] import …')
const imp = await createImportJob(db, {
siteKey: opts.site,
connectionName: 'default',
productsPath: '/products/',
newsPath: '/news/',
})
timings.importMs = imp.durationMs
chain.import = { jobId: imp.jobId, status: imp.status, reused: imp.reused, reportPath: imp.reportPath }
console.log(` job=${imp.jobId} status=${imp.status} reused=${imp.reused} ${imp.durationMs.toFixed(1)}ms`)
// ③ publish(dry-run → confirm token → 执行)
console.log('[chain] content:publish(dry-run → confirm)…')
const tPubDry = performance.now()
const dry = await publishSiteContent(db, { siteKey: opts.site, dryRun: true, actor: ACTOR })
timings.publishDryRunMs = performance.now() - tPubDry
const tPub = performance.now()
const pub = await publishSiteContent(db, {
siteKey: opts.site,
dryRun: false,
confirmToken: dry.confirmToken,
actor: ACTOR,
})
timings.publishConfirmMs = performance.now() - tPub
chain.publish = {
dryRunCandidates: dry.candidates.length,
publishedCount: pub.publishedCount,
upToDateCount: pub.upToDateCount,
siteConfigRevision: pub.siteConfigRevision,
}
console.log(` dry-run=${dry.candidates.length} confirm published=${pub.publishedCount} upToDate=${pub.upToDateCount}`)
// ④ build(onStatus 分相计时;重跑命中 succeeded 复用时产物面从权威报告盘读——
// 复用返回不含 counts/artifacts,权威记录在原 job 报告)
console.log('[chain] build:create …')
const marks = []
const build = await createBuildJob(db, {
siteKey: opts.site,
onStatus: (status, jobId) => marks.push({ status, t: performance.now(), jobId }),
})
const reusedBuild = build.reused
let buildArtifacts = build.artifacts ?? null
let buildCounts = build.counts ?? null
let buildUrlRewrite = build.urlRewrite ?? null
let buildIssues = build.issues ?? null
let buildReportOnReuse = null
if (reusedBuild) {
buildReportOnReuse = JSON.parse(await readFile(build.reportPath, 'utf8'))
buildArtifacts = {
distPath: buildReportOnReuse.artifacts.distPath,
distFileCount: buildReportOnReuse.artifacts.distFileCount,
distByteSize: buildReportOnReuse.artifacts.distByteSize,
distSha256: buildReportOnReuse.artifacts.distSha256,
routeManifestPath: buildReportOnReuse.artifacts.routeManifestPath,
sitemapEntries: buildReportOnReuse.artifacts.sitemapEntries,
mediaCopied: buildReportOnReuse.artifacts.mediaCopied,
}
buildCounts = buildReportOnReuse.snapshot.counts
buildUrlRewrite = buildReportOnReuse.urlRewrite
buildIssues = buildReportOnReuse.issues
}
timings.buildTotalMs = reusedBuild ? buildReportOnReuse.durationMs : build.durationMs
if (!reusedBuild) {
const byStatus = new Map(marks.map((m) => [m.status, m.t]))
timings.buildSnapshotMs = (byStatus.get('building') ?? build.durationMs) - (byStatus.get('snapshotting') ?? 0)
timings.buildBuildingMs = (byStatus.get('validating') ?? build.durationMs) - (byStatus.get('building') ?? 0)
timings.buildValidatingReportMs = (byStatus.get('succeeded') ?? build.durationMs) - (byStatus.get('validating') ?? 0)
}
chain.build = {
jobId: build.jobId,
status: build.status,
reused: reusedBuild,
inputDir: build.inputDir,
reportPath: build.reportPath,
counts: buildCounts,
urlRewrite: buildUrlRewrite,
issues: buildIssues,
artifacts: buildArtifacts,
marks: marks.map((m) => m.status),
}
console.log(` job=${build.jobId} status=${build.status} reused=${String(reusedBuild)} ${timings.buildTotalMs.toFixed(1)}ms`)
if (!reusedBuild) {
console.log(` 分相 snapshot=${timings.buildSnapshotMs.toFixed(0)}ms building=${timings.buildBuildingMs.toFixed(0)}ms validating+report=${timings.buildValidatingReportMs.toFixed(0)}ms`)
}
if (build.status !== 'succeeded' || buildArtifacts === null) {
throw new Error(`构建未成功(status=${build.status})——观察项处置流程见计划 Task 8 Step 3`)
}
// ⑤ astro 分相探针:对冻结输入目录重跑一次 astro build 计时(暂存盘前后即清——
// 不触碰 job 权威产物;probe 仅用于耗时分解的 astro 子相)
console.log('[chain] astro 计时探针(冻结输入重跑)…')
const scratch = join(REPO_ROOT, 'themes', 'pilot-minimal', 'dist')
await rm(scratch, { recursive: true, force: true })
timings.astroProbeMs = await new Promise((resolveProbe, rejectProbe) => {
const t0 = performance.now()
const child = spawn('pnpm', ['--filter', THEME_PACKAGE, 'exec', 'astro', 'build'], {
cwd: REPO_ROOT,
shell: true,
env: { ...process.env, SF_BUILD_INPUT: build.inputDir, ASTRO_TELEMETRY_DISABLED: '1' },
stdio: ['ignore', 'ignore', 'pipe'],
})
child.stderr?.on('data', () => {})
child.on('error', rejectProbe)
child.on('close', (code) => {
if (code === 0) resolveProbe(performance.now() - t0)
else rejectProbe(new Error(`astro 探针失败(exit=${String(code)})`))
})
})
await rm(scratch, { recursive: true, force: true })
console.log(` astro probe=${timings.astroProbeMs.toFixed(0)}ms(暂存盘已清)`)
// ---- 数据面(盘读) ----
const inputDir = build.inputDir
const [routesJson, contentJson, taxonomiesJson, assetsJson, siteJson, manifestJson] = await Promise.all([
readFile(join(inputDir, 'routes.json'), 'utf8'),
readFile(join(inputDir, 'content.json'), 'utf8'),
readFile(join(inputDir, 'taxonomies.json'), 'utf8'),
readFile(join(inputDir, 'assets.json'), 'utf8'),
readFile(join(inputDir, 'site.json'), 'utf8'),
readFile(buildArtifacts.routeManifestPath, 'utf8'),
])
const routes = JSON.parse(routesJson)
const contents = JSON.parse(contentJson)
const taxonomies = JSON.parse(taxonomiesJson)
const assets = JSON.parse(assetsJson)
const site = JSON.parse(siteJson)
const manifest = JSON.parse(manifestJson)
const origin = site.origin
// ---- SQL 对拍(计数以库为权威,双写) ----
console.log('[assert] SQL 对拍 …')
const q = async (sql) => (await db.execute(sql)).map((r) => Object.values(r)[0])
const sqlCounts = {
contentEntries: Number((await q('select count(*) from content_entries'))[0]),
publishedLocales: Number(
(await q('select count(*) from entry_locales where published_revision_id is not null'))[0],
),
mediaReady: Number((await q("select count(*) from media_assets where status='ready'"))[0]),
auditPublish: Number(
(await q("select count(*) from audit_logs where action='content.publish'"))[0],
),
typeKey: await db.execute('select type_key, count(*) as n from content_entries group by type_key order by type_key'),
routeKind: await db.execute('select route_kind, count(*) as n from route_registry group by route_kind order by route_kind'),
}
console.log(` entries=${sqlCounts.contentEntries} published=${sqlCounts.publishedLocales} mediaReady=${sqlCounts.mediaReady} audit(content.publish)=${sqlCounts.auditPublish}`)
// ---- preview(进程内,随机端口,quiet)----
const preview = createPreviewServer({
distDir: buildArtifacts.distPath,
routeManifest: manifest,
host: '127.0.0.1',
port: 0,
quiet: true,
})
const addr = await preview.start()
const client = makeHttpClient(addr.port)
console.log(`[preview] http://127.0.0.1:${addr.port}/ (进程内 quiet;dist=${buildArtifacts.distPath})`)
// ===== 断言 ① 六类页面 HTTP 200 + 真实内容 =====
console.log('[assert] ① 六类页面 …')
const htmlRoutes = routes.filter((r) => r.routeKind === 'html' && r.queryKey === null)
const byContent = new Map(contents.map((c) => [c.contentId, c]))
const codedRoute = htmlRoutes.find((r) => r.routePath.includes('%') && r.targetContentId !== null)
const catRoute = htmlRoutes
.filter((r) => r.routePath.startsWith('/product-category/'))
.map((r) => {
const slug = r.routePath.slice('/product-category/'.length).replace(/\/+$/, '')
let term = null
for (const tax of taxonomies) {
if (tax.publicBasePath.replace(/\/+$/, '') !== '/product-category') continue
term = tax.terms.find((t) => t.slug === slug) ?? term
}
return { r, term, n: term === null ? -1 : term.contentEntryIds.length }
})
.sort((a, b) => b.n - a.n || (a.r.routePath < b.r.routePath ? -1 : 1))[0]
const productRoute = htmlRoutes
.filter((r) => r.targetContentId !== null && byContent.get(r.targetContentId)?.typeKey === 'product')
.map((r) => {
const c = byContent.get(r.targetContentId)
return { r, c, mediaN: Object.keys(c?.media ?? {}).length }
})
.sort((a, b) => b.mediaN - a.mediaN || (a.r.routePath < b.r.routePath ? -1 : 1))[0]
const newsRoute = htmlRoutes
.filter((r) => r.targetContentId !== null && byContent.get(r.targetContentId)?.typeKey === 'news_article')
.map((r) => {
const c = byContent.get(r.targetContentId)
const body = typeof c?.data.bodyHtml === 'string' ? c.data.bodyHtml : ''
return { r, c, hasImg: body.includes('/wp-content/uploads/'), hasBody: body !== '' }
})
.sort((a, b) => (b.hasImg ? 1 : 0) - (a.hasImg ? 1 : 0) || (a.r.routePath < b.r.routePath ? -1 : 1))[0]
const a1 = []
// 1) 首页
{
const res = await client.get('/')
const top3 = latestEntries(contents, 'news_article', 3).map(contentTitle)
const nameOk = htmlTextIncludes(res.body, site.name)
const newsOk = top3.every((t) => htmlTextIncludes(res.body, t))
a1.push({
page: 'home /',
route: '/',
http: res.status,
ok: res.status === 200 && nameOk && newsOk,
checks: { siteNameInBody: nameOk, latest3NewsTitles: newsOk, top3 },
})
}
// 2) 编码内容页
{
const res = await client.get(codedRoute.routePath)
const title = contentTitle(byContent.get(codedRoute.targetContentId))
a1.push({
page: 'coded content page',
route: codedRoute.routePath,
http: res.status,
ok: res.status === 200 && htmlTextIncludes(res.body, title),
checks: { titleInBody: htmlTextIncludes(res.body, title), title },
})
}
// 3) 产品分类列表
{
const res = await client.get(catRoute.r.routePath)
const termName = catRoute.term.name
const products = catRoute.term.contentEntryIds
.map((id) => byContent.get(id))
.filter((c) => c !== undefined && c.typeKey === 'product')
const sampleTitles = products.slice(0, 3).map(contentTitle)
a1.push({
page: 'product category list',
route: catRoute.r.routePath,
http: res.status,
ok: res.status === 200 && htmlTextIncludes(res.body, termName) && sampleTitles.every((t) => htmlTextIncludes(res.body, t)),
checks: { termNameInBody: htmlTextIncludes(res.body, termName), productTitlesInBody: sampleTitles.every((t) => htmlTextIncludes(res.body, t)), termName, termProducts: products.length },
})
}
// 4) 产品详情(真实产品无结构化参数表——Task 4 字段探测既定事实;断言标题+媒体本地化引用)
{
const res = await client.get(productRoute.r.routePath)
const c = productRoute.c
const title = contentTitle(c)
const mediaPaths = Object.values(c.media ?? {})
const mediaOk = mediaPaths.length === 0 ? true : mediaPaths.every((p) => res.body.includes(p))
a1.push({
page: 'product detail',
route: productRoute.r.routePath,
http: res.status,
ok: res.status === 200 && htmlTextIncludes(res.body, title) && mediaOk,
checks: { titleInBody: htmlTextIncludes(res.body, title), localizedMediaRefs: mediaOk, title, mediaRefs: mediaPaths.length, specTableRows: (res.body.match(/<table class="specifications"/g) ?? []).length },
})
}
// 5) 新闻列表 /news/(V1 不分页——129 条单页全列)
{
const res = await client.get('/news/')
const top5 = latestEntries(contents, 'news_article', 5).map(contentTitle)
a1.push({
page: 'news list /news/',
route: '/news/',
http: res.status,
ok: res.status === 200 && top5.every((t) => htmlTextIncludes(res.body, t)),
checks: { latest5Titles: top5.every((t) => htmlTextIncludes(res.body, t)), top5 },
})
}
// 6) 新闻详情
{
const res = await client.get(newsRoute.r.routePath)
const c = newsRoute.c
const title = contentTitle(c)
const date = contentDate(c)
a1.push({
page: 'news detail',
route: newsRoute.r.routePath,
http: res.status,
ok: res.status === 200 && htmlTextIncludes(res.body, title) && (date === undefined || res.body.includes(date)),
checks: { titleInBody: htmlTextIncludes(res.body, title), dateInBody: date === undefined ? true : res.body.includes(date), title, date },
})
}
for (const row of a1) {
record(
`①${row.page}`,
'HTTP 200 + 真实内容(标题/列表/媒体引用)',
`http=${String(row.http)} checks=${JSON.stringify(row.checks)}`,
row.ok,
)
}
// ===== 断言 ② 图片本地化抽检 10 张 =====
console.log('[assert] ② 图片本地化抽检 …')
const pages = await walkHtml(buildArtifacts.distPath)
const imgRefs = []
const seen = new Set()
for (const p of pages) {
const html = await readFile(p.abs, 'utf8')
for (const m of html.matchAll(/<img[^>]*src="(\/wp-content\/uploads\/[^"]+)"/g)) {
const src = m[1]
if (seen.has(src)) continue
seen.add(src)
imgRefs.push({ src, refPage: p.rel })
if (imgRefs.length >= 10) break
}
if (imgRefs.length >= 10) break
}
const assetsByPath = new Map(assets.map((a) => [a.publicPath, a]))
const a2 = []
for (const ref of imgRefs) {
const distFile = join(buildArtifacts.distPath, ref.src.slice(1))
const exists = existsSync(distFile)
const httpRes = await client.get(ref.src)
const asset = assetsByPath.get(ref.src)
a2.push({
src: ref.src,
refPage: ref.refPage,
rootRelative: ref.src.startsWith('/') && !ref.src.startsWith('//'),
inAssetsJson: asset !== undefined,
distFileExists: exists,
httpStatus: httpRes.status,
byteSize: asset?.byteSize ?? null,
})
}
record(
'②图片本地化抽检',
'10 张:根相对引用 + dist 文件存在 + preview 200 + assets.json 在册',
JSON.stringify(a2),
a2.length === 10 && a2.every((x) => x.rootRelative && x.inAssetsJson && x.distFileExists && x.httpStatus === 200),
)
// ===== 断言 ③ title/description/canonical 抽检 5 页(盘读) =====
console.log('[assert] ③ SEO 三件套抽检 …')
const seoPicks = [
{ label: 'home', routePath: '/' },
{ label: 'coded page', routePath: codedRoute.routePath },
{ label: 'product category', routePath: catRoute.r.routePath },
{ label: 'product detail', routePath: productRoute.r.routePath },
{ label: 'news detail', routePath: newsRoute.r.routePath },
]
const a3 = []
for (const pick of seoPicks) {
const file = pick.routePath === '/' ? 'index.html' : `${pick.routePath.replace(/^\//, '').replace(/\/+$/, '')}/index.html`
const html = await readFile(join(buildArtifacts.distPath, file), 'utf8')
const title = titleOf(html)
const desc = metaDescriptionOf(html)
const canonical = canonicalOf(html)
const expectCanonical = `${origin}${pick.routePath}`
a3.push({
page: pick.label,
routePath: pick.routePath,
title,
titleNonEmpty: title !== '',
descriptionNonEmpty: desc !== '',
canonical,
canonicalExact: canonical === expectCanonical,
expectCanonical,
})
}
record(
'③SEO 三件套×5页',
'title/description 非空 + canonical === origin+路径(逐页精确)',
JSON.stringify(a3.map((x) => ({ page: x.page, titleNonEmpty: x.titleNonEmpty, descriptionNonEmpty: x.descriptionNonEmpty, canonicalExact: x.canonicalExact, canonical: x.canonical }))),
a3.every((x) => x.titleNonEmpty && x.descriptionNonEmpty && x.canonicalExact),
)
// ===== 断言 ④ 404 与 301(?attachment_id= 按原串) =====
console.log('[assert] ④ 404/301 …')
const notFound = await client.get('/__e2e_missing_probe__')
record(
'④未命中 404',
'404 + X-Robots-Tag noindex',
`status=${String(notFound.status)} x-robots=${notFound.headers['x-robots-tag'] ?? '(无)'}`,
notFound.status === 404 && (notFound.headers['x-robots-tag'] ?? '').includes('noindex'),
)
const redirectRows = manifest.routes.filter((r) => r.kind === 'redirect')
const queryRedirects = redirectRows.filter((r) => r.query_key !== null)
const pickSpread = (arr, n) =>
arr.length <= n ? arr : Array.from({ length: n }, (_, i) => arr[Math.round((i * (arr.length - 1)) / (n - 1))])
const probeRedirects = [...pickSpread(queryRedirects, 3), ...pickSpread(redirectRows.filter((r) => r.query_key === null), 2)]
const a4 = []
for (const row of probeRedirects) {
const res = await client.get(row.exact_key)
a4.push({
request: row.exact_key,
expectLocation: row.location ?? null,
status: res.status,
location: res.headers.location ?? null,
locationMatch: res.headers.location === asciiLocationMirror(row.location ?? ''),
})
}
record(
'④301 抽检(?attachment_id= 原串 3 + 路径形态 2)',
'逐条 301 + Location === manifest 原串的 RFC 头域 ASCII 派生形(原串含非 ASCII 时 UTF-8 百分号编码)',
JSON.stringify(a4),
a4.every((x) => x.status === 301 && x.locationMatch),
)
// ===== 断言 ⑤ sitemap/robots(盘读) =====
console.log('[assert] ⑤ sitemap/robots …')
const sitemapXml = await readFile(join(buildArtifacts.distPath, 'sitemap.xml'), 'utf8')
const locCount = (sitemapXml.match(/<loc>/g) ?? []).length
const indexableCount = manifest.routes.filter((r) => r.kind === 'html' && r.indexable).length
const robotsTxt = await readFile(join(buildArtifacts.distPath, 'robots.txt'), 'utf8')
record('⑤sitemap 条目数', `= manifest indexable html 数(${String(indexableCount)})`, `loc=${String(locCount)} indexable=${String(indexableCount)}`, locCount === indexableCount)
record(
'⑤robots.txt',
'存在 + Sitemap 行指向 origin',
robotsTxt.includes(`Sitemap: ${origin}/sitemap.xml`) ? `存在(Sitemap: ${origin}/sitemap.xml)` : `异常:${JSON.stringify(robotsTxt)}`,
robotsTxt.includes(`Sitemap: ${origin}/sitemap.xml`),
)
// ===== 断言 ⑦ 全量冒烟(html 190 全 200 + redirect 1746 逐条 301+Location) =====
console.log('[assert] ⑦ 全量冒烟(190 html + 1746 redirect,keep-alive)…')
const tSmoke = performance.now()
const htmlRows = manifest.routes.filter((r) => r.kind === 'html')
const htmlResults = await pool(htmlRows, 8, async (row) => {
const res = await client.get(row.exact_key)
return { key: row.exact_key, status: res.status, ok: res.status === 200 }
})
const htmlFailures = htmlResults.filter((r) => !r.ok)
const redirectResults = await pool(redirectRows, 8, async (row) => {
const res = await client.get(row.exact_key)
const locationMatch = res.headers.location === asciiLocationMirror(row.location ?? '')
return { key: row.exact_key, status: res.status, locationMatch, expect: row.location ?? null, got: res.headers.location ?? null }
})
const redirectFailures = redirectResults.filter((r) => r.status !== 301 || !r.locationMatch)
timings.smokeMs = performance.now() - tSmoke
const sample200 = htmlResults[0]
const sampleRes = await client.get(sample200.key)
record(
'⑦html 全量 200',
`${String(htmlRows.length)} 行全 200(manifest html 行数)`,
`ok=${String(htmlResults.length - htmlFailures.length)}/${String(htmlRows.length)}${htmlFailures.length === 0 ? '' : ` 首失败=${JSON.stringify(htmlFailures.slice(0, 5))}`}`,
htmlFailures.length === 0 && htmlRows.length === 190,
`样例 ${sample200.key} content-type=${sampleRes.headers['content-type'] ?? '(无)'}`,
)
record(
'⑦redirect 全量 301+Location',
`${String(redirectRows.length)} 行逐条 301 + Location === manifest 原串(非 ASCII 头域 ASCII 化派生形)`,
`ok=${String(redirectResults.length - redirectFailures.length)}/${String(redirectRows.length)}${redirectFailures.length === 0 ? '' : ` 首失败=${JSON.stringify(redirectFailures.slice(0, 5))}`}`,
redirectFailures.length === 0 && redirectRows.length === 1746,
)
// ===== 断言 ⑨ 门禁全绿(validation_runs PASS) =====
console.log('[assert] ⑨ 门禁 …')
const vruns = await db.execute(
`select id, status, rule_version from validation_runs where build_job_id = '${build.jobId}' order by started_at`,
)
const vissues = await db.execute(
`select severity, count(*) as n from validation_issues where validation_run_id in (select id from validation_runs where build_job_id = '${build.jobId}') group by severity`,
)
record(
'⑨门禁 validation_runs',
'job succeeded + validation_run PASS(build succeeded 即含门禁通过)',
`job=${build.status} runs=${JSON.stringify(vruns)} issues=${JSON.stringify(vissues)}`,
build.status === 'succeeded' && vruns.length === 1 && vruns[0].status === 'PASS',
)
// ---- SQL 对拍断言(全链计数 vs 预期) ----
console.log('[assert] 计数对拍 …')
const routeKindMap = Object.fromEntries(sqlCounts.routeKind.map((r) => [r.route_kind, Number(r.n)]))
const typeKeyMap = Object.fromEntries(sqlCounts.typeKey.map((r) => [r.type_key, Number(r.n)]))
record('计数 content_entries', '192(129 news_article + 48 product + 15 basic_page)', `total=${String(sqlCounts.contentEntries)} ${JSON.stringify(typeKeyMap)}`, sqlCounts.contentEntries === 192 && typeKeyMap.news_article === 129 && typeKeyMap.product === 48 && typeKeyMap.basic_page === 15)
record('计数 published 指针', '192(显式发布后全量)', String(sqlCounts.publishedLocales), sqlCounts.publishedLocales === 192)
record('计数 routes', '2772(html 190 / redirect 1746 / asset 836)', JSON.stringify(routeKindMap), routeKindMap.html === 190 && routeKindMap.redirect === 1746 && routeKindMap.asset === 836)
record('计数 media ready', '836', String(sqlCounts.mediaReady), sqlCounts.mediaReady === 836)
record('计数 audit content.publish', '≥1(本链 confirm 写入)', String(sqlCounts.auditPublish), sqlCounts.auditPublish >= 1)
const buildReport = JSON.parse(await readFile(build.reportPath, 'utf8'))
record('构建产物面', `dist 文件≈${String(190 + 836 + 2 + 1)}(html+媒体+sitemap/robots+404)`, `distFileCount=${String(buildArtifacts.distFileCount)} mediaCopied=${String(buildArtifacts.mediaCopied)} sitemapEntries=${String(buildArtifacts.sitemapEntries)} urlRewrite=${JSON.stringify(buildUrlRewrite)}`, buildArtifacts.mediaCopied === 836 && buildArtifacts.sitemapEntries === indexableCount)
// ---- 汇总落盘 ----
const verdict = rows.every((r) => r.pass) ? 'PASS' : 'FAIL'
const result = {
verdict,
generatedAt: new Date().toISOString(),
env: {
node: process.version,
platform: process.platform,
site: opts.site,
origin,
database: process.env.DATABASE_URL_TEST ?? '(DATABASE_URL)',
},
chain,
sqlCounts,
timings: Object.fromEntries(Object.entries(timings).map(([k, v]) => [k, Math.round(v)])),
assertions: rows,
detail: { a1, a2, a3, a4, smoke: { htmlTotal: htmlRows.length, htmlFailures: htmlFailures.length, redirectTotal: redirectRows.length, redirectFailures: redirectFailures.length } },
buildReport: {
distPath: buildReport.artifacts.distPath,
distFileCount: buildReport.artifacts.distFileCount,
distByteSize: buildReport.artifacts.distByteSize,
mediaCopied: buildReport.artifacts.mediaCopied,
sitemapEntries: buildReport.artifacts.sitemapEntries,
validation: buildReport.validation,
urlRewrite: buildReport.urlRewrite,
durationMs: buildReport.durationMs,
},
}
await mkdir(join(REPO_ROOT, 'docs', 'evidence'), { recursive: true })
await writeFile(join(REPO_ROOT, 'docs', 'evidence', 'phase4-assertions.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8')
console.log(`\n[chain] verdict=${verdict}(${String(rows.filter((r) => r.pass).length)}/${String(rows.length)} PASS)`)
console.log(` 断言明细:docs/evidence/phase4-assertions.json`)
console.log(` preview 样例页(serve 会话用):/ 与 ${productRoute.r.routePath} 与 ${newsRoute.r.routePath}`)
await preview.stop()
client.agent.destroy()
if (verdict === 'FAIL') process.exitCode = 1
} finally {
await db.$client.end()
}
}
// ---- serve 子命令(截图会话:真实 CLI preview:start 同域的行为面) ----
async function cmdServe(opts) {
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(opts.build)) {
throw new Error(`--build 不是合法 job id(uuid 形状):${opts.build}`)
}
const db = getDb()
let distPath
let manifestPath
try {
const jobs = await db.execute(`select id, status from build_jobs where id = '${opts.build}'`)
if (jobs.length === 0) throw new Error(`构建 job 不存在:${opts.build}`)
if (jobs[0].status !== 'succeeded') throw new Error(`仅 succeeded job 可预览(当前 ${jobs[0].status})`)
const arts = await db.execute(`select kind, storage_path from build_artifacts where build_job_id = '${opts.build}'`)
distPath = arts.find((a) => a.kind === 'dist')?.storage_path
manifestPath = arts.find((a) => a.kind === 'manifest')?.storage_path
if (distPath === undefined || manifestPath === undefined) throw new Error('产物登记不全(dist/manifest 缺席)')
} finally {
await db.$client.end()
}
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
const preview = createPreviewServer({ distDir: distPath, routeManifest: manifest, host: '127.0.0.1', port: opts.port, quiet: false })
const addr = await preview.start()
console.log(`[serve] job=${opts.build}`)
console.log(` dist: ${distPath}`)
console.log(` listening: http://127.0.0.1:${String(addr.port)}/ (Ctrl-C 停止)`)
process.once('SIGINT', () => {
void preview.stop().then(() => process.exit(0))
})
process.once('SIGTERM', () => {
void preview.stop().then(() => process.exit(0))
})
await new Promise(() => {})
}
// ---- 入口 ----
const argv = process.argv.slice(2)
const sub = argv[0] ?? 'chain'
const flag = (name, fallback) => {
const i = argv.indexOf(`--${name}`)
return i !== -1 && argv[i + 1] !== undefined ? argv[i + 1] : fallback
}
if (sub === 'chain') {
await cmdChain({ site: flag('site', 'hesupipe-real') })
} else if (sub === 'serve') {
await cmdServe({ build: flag('build', ''), port: Number(flag('port', '4571')) })
} else {
console.error('用法:e2e-real.mjs chain [--site key] | serve --build <jobId> [--port n]')
process.exitCode = 2
}