Files
浅念 236f3dc4d3 fix(preview+pipeline): 资产键编码归一——7 张全角名图片预览 404 修复;证据文档叙述修正
spec 复审发现(浏览器可见缺陷):manifest 资产行 exact_key 有 7 例为裸形
(文件名含全角括号/顿号/间隔号)——html/redirect 键源数据侧已编码、asset
键来自文件名本体是裸形;浏览器对裸非 ASCII 一律百分号编码后发请求,预览
按请求字节逐字匹配 → 裸键永不命中 → 两页 3 张图破图(dist 文件在盘;
生产面 nginx 先解码不受影响)。修补 pipeline.toManifestRoute:exact_key
归一为 URL 线形(可打印 ASCII 原样、其余 UTF-8 百分号编码大写 hex),
target_file 仍盘上裸名、DB/渲染快照域不变。

回归三层:build-pipeline 注入裸全角资产行(红验证);preview-server 2c
非 ASCII redirect → Location → 资产终端 200;e2e 断言 ④c(7 条全查
跟随到终端 200)+ ⑦c(manifest 2772 键全 ASCII 不变量)。spec 复审复跑
(新库全链 job e059bf41)23/23 PASS,浏览器实测 3 张原破图全部加载。

证据文档:§5.2 失败计数修正(5×500+2×mojibake=7 例)、§2 ①c 措辞对齐
断言实义(抽前 3 标题)、§5.5 修补记录、计时/产物/job 面更新至复跑值。
验证:vitest 382 + 主题 26 全绿(408);typecheck 过;lint 裸跑零告警。

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

808 lines
38 KiB
JavaScript
Raw Permalink 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),
)
// 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
}