Files

808 lines
38 KiB
JavaScript
Raw Permalink Normal View History

#!/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),
)
// spec 复审修补回归:非 ASCII redirect 跟随到资产终端——Location 编码形请求须命中
// manifest 资产行(exact_key 已归一为同一编码线形)→ 200 + 图片 MIME(浏览器真实路径)
const nonAsciiRedirects = redirectRows.filter((r) => /\P{ASCII}/u.test(r.location ?? ''))
const a4c = []
for (const row of nonAsciiRedirects) {
const hop = await client.get(row.exact_key)
const terminalKey = asciiLocationMirror(row.location ?? '')
const terminal = await client.get(terminalKey)
a4c.push({
request: row.exact_key,
location: terminalKey,
hopStatus: hop.status,
terminalStatus: terminal.status,
terminalContentType: terminal.headers['content-type'] ?? null,
})
}
record(
`④c 非 ASCII redirect → 资产终端(${String(a4c.length)} 条全查)`,
'301 → 跟随 Location 编码形 → 终端 200 + 图片 MIME(exact_key 编码线形命中)',
JSON.stringify(a4c),
a4c.length > 0 && a4c.every((x) => x.hopStatus === 301 && x.terminalStatus === 200 && String(x.terminalContentType).startsWith('image/')),
)
// ===== 断言 ⑤ 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,
)
// spec 复审修补不变量:manifest 全键编码线形(纯 ASCII)——裸非 ASCII 键永不匹配
// 浏览器请求形(浏览器对裸非 ASCII 一律百分号编码后发送)
const nonAsciiKeys = manifest.routes.filter((r) => /\P{ASCII}/u.test(r.exact_key)).map((r) => r.exact_key)
record(
'⑦manifest 键线形',
`全 ${String(manifest.routes.length)} 键纯 ASCII(编码线形——裸非 ASCII 键 = 预览永不命中)`,
nonAsciiKeys.length === 0 ? 'nonAsciiKeys=0' : `nonAsciiKeys=${JSON.stringify(nonAsciiKeys.slice(0, 5))}`,
nonAsciiKeys.length === 0,
)
// ===== 断言 ⑨ 门禁全绿(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, a4c, 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
}