236f3dc4d3
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>
808 lines
38 KiB
JavaScript
808 lines
38 KiB
JavaScript
#!/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(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/'/g, "'")
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/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 实体转义(& → &,源数据里 WP 侧
|
||
* 预转义的 & 会二次转义)——比较前对页面做两次实体解码(覆盖预转义源)、对预期
|
||
* 文本解码一次,再做 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
|
||
}
|