test(evidence): 阶段4端到端预览验收
Plan 4 Task 8:真实 hesupipe 全链(register 副本复用 → import → publish → build → preview)临时库端到端验收,断言 21/21 PASS + 浏览器时刻 6 截图 无破版无横向溢出。新增 scripts/e2e-real.mjs(chain/serve 两子命令)与 docs/evidence/phase4-preview.md + phase4-assertions.json + screenshots/。 验收内修补(预览服务器,均有回归测试锁定): - Location 头 ASCII 化:非 ASCII 码位按 UTF-8 百分号编码——原样 setHeader 触发 ERR_INVALID_CHAR 崩 500 或 latin1 mojibake(1746 redirect 全量 校验显影 2+3 例);manifest 原串保持权威,测试 2b 锁定。 - CSP 放行内联样式:Astro 5 scoped 样式内联 <style> 被 default-src 'self' 拦截(浏览器时刻显影,HTTP 级不可见);测试 6 断言同步。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,776 @@
|
||||
#!/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),
|
||||
)
|
||||
|
||||
// ===== 断言 ⑤ sitemap/robots(盘读) =====
|
||||
console.log('[assert] ⑤ sitemap/robots …')
|
||||
const sitemapXml = await readFile(join(buildArtifacts.distPath, 'sitemap.xml'), 'utf8')
|
||||
const locCount = (sitemapXml.match(/<loc>/g) ?? []).length
|
||||
const indexableCount = manifest.routes.filter((r) => r.kind === 'html' && r.indexable).length
|
||||
const robotsTxt = await readFile(join(buildArtifacts.distPath, 'robots.txt'), 'utf8')
|
||||
record('⑤sitemap 条目数', `= manifest indexable html 数(${String(indexableCount)})`, `loc=${String(locCount)} indexable=${String(indexableCount)}`, locCount === indexableCount)
|
||||
record(
|
||||
'⑤robots.txt',
|
||||
'存在 + Sitemap 行指向 origin',
|
||||
robotsTxt.includes(`Sitemap: ${origin}/sitemap.xml`) ? `存在(Sitemap: ${origin}/sitemap.xml)` : `异常:${JSON.stringify(robotsTxt)}`,
|
||||
robotsTxt.includes(`Sitemap: ${origin}/sitemap.xml`),
|
||||
)
|
||||
|
||||
// ===== 断言 ⑦ 全量冒烟(html 190 全 200 + redirect 1746 逐条 301+Location) =====
|
||||
console.log('[assert] ⑦ 全量冒烟(190 html + 1746 redirect,keep-alive)…')
|
||||
const tSmoke = performance.now()
|
||||
const htmlRows = manifest.routes.filter((r) => r.kind === 'html')
|
||||
const htmlResults = await pool(htmlRows, 8, async (row) => {
|
||||
const res = await client.get(row.exact_key)
|
||||
return { key: row.exact_key, status: res.status, ok: res.status === 200 }
|
||||
})
|
||||
const htmlFailures = htmlResults.filter((r) => !r.ok)
|
||||
const redirectResults = await pool(redirectRows, 8, async (row) => {
|
||||
const res = await client.get(row.exact_key)
|
||||
const locationMatch = res.headers.location === asciiLocationMirror(row.location ?? '')
|
||||
return { key: row.exact_key, status: res.status, locationMatch, expect: row.location ?? null, got: res.headers.location ?? null }
|
||||
})
|
||||
const redirectFailures = redirectResults.filter((r) => r.status !== 301 || !r.locationMatch)
|
||||
timings.smokeMs = performance.now() - tSmoke
|
||||
const sample200 = htmlResults[0]
|
||||
const sampleRes = await client.get(sample200.key)
|
||||
record(
|
||||
'⑦html 全量 200',
|
||||
`${String(htmlRows.length)} 行全 200(manifest html 行数)`,
|
||||
`ok=${String(htmlResults.length - htmlFailures.length)}/${String(htmlRows.length)}${htmlFailures.length === 0 ? '' : ` 首失败=${JSON.stringify(htmlFailures.slice(0, 5))}`}`,
|
||||
htmlFailures.length === 0 && htmlRows.length === 190,
|
||||
`样例 ${sample200.key} content-type=${sampleRes.headers['content-type'] ?? '(无)'}`,
|
||||
)
|
||||
record(
|
||||
'⑦redirect 全量 301+Location',
|
||||
`${String(redirectRows.length)} 行逐条 301 + Location === manifest 原串(非 ASCII 头域 ASCII 化派生形)`,
|
||||
`ok=${String(redirectResults.length - redirectFailures.length)}/${String(redirectRows.length)}${redirectFailures.length === 0 ? '' : ` 首失败=${JSON.stringify(redirectFailures.slice(0, 5))}`}`,
|
||||
redirectFailures.length === 0 && redirectRows.length === 1746,
|
||||
)
|
||||
|
||||
// ===== 断言 ⑨ 门禁全绿(validation_runs PASS) =====
|
||||
console.log('[assert] ⑨ 门禁 …')
|
||||
const vruns = await db.execute(
|
||||
`select id, status, rule_version from validation_runs where build_job_id = '${build.jobId}' order by started_at`,
|
||||
)
|
||||
const vissues = await db.execute(
|
||||
`select severity, count(*) as n from validation_issues where validation_run_id in (select id from validation_runs where build_job_id = '${build.jobId}') group by severity`,
|
||||
)
|
||||
record(
|
||||
'⑨门禁 validation_runs',
|
||||
'job succeeded + validation_run PASS(build succeeded 即含门禁通过)',
|
||||
`job=${build.status} runs=${JSON.stringify(vruns)} issues=${JSON.stringify(vissues)}`,
|
||||
build.status === 'succeeded' && vruns.length === 1 && vruns[0].status === 'PASS',
|
||||
)
|
||||
|
||||
// ---- SQL 对拍断言(全链计数 vs 预期) ----
|
||||
console.log('[assert] 计数对拍 …')
|
||||
const routeKindMap = Object.fromEntries(sqlCounts.routeKind.map((r) => [r.route_kind, Number(r.n)]))
|
||||
const typeKeyMap = Object.fromEntries(sqlCounts.typeKey.map((r) => [r.type_key, Number(r.n)]))
|
||||
record('计数 content_entries', '192(129 news_article + 48 product + 15 basic_page)', `total=${String(sqlCounts.contentEntries)} ${JSON.stringify(typeKeyMap)}`, sqlCounts.contentEntries === 192 && typeKeyMap.news_article === 129 && typeKeyMap.product === 48 && typeKeyMap.basic_page === 15)
|
||||
record('计数 published 指针', '192(显式发布后全量)', String(sqlCounts.publishedLocales), sqlCounts.publishedLocales === 192)
|
||||
record('计数 routes', '2772(html 190 / redirect 1746 / asset 836)', JSON.stringify(routeKindMap), routeKindMap.html === 190 && routeKindMap.redirect === 1746 && routeKindMap.asset === 836)
|
||||
record('计数 media ready', '836', String(sqlCounts.mediaReady), sqlCounts.mediaReady === 836)
|
||||
record('计数 audit content.publish', '≥1(本链 confirm 写入)', String(sqlCounts.auditPublish), sqlCounts.auditPublish >= 1)
|
||||
const buildReport = JSON.parse(await readFile(build.reportPath, 'utf8'))
|
||||
record('构建产物面', `dist 文件≈${String(190 + 836 + 2 + 1)}(html+媒体+sitemap/robots+404)`, `distFileCount=${String(buildArtifacts.distFileCount)} mediaCopied=${String(buildArtifacts.mediaCopied)} sitemapEntries=${String(buildArtifacts.sitemapEntries)} urlRewrite=${JSON.stringify(buildUrlRewrite)}`, buildArtifacts.mediaCopied === 836 && buildArtifacts.sitemapEntries === indexableCount)
|
||||
|
||||
// ---- 汇总落盘 ----
|
||||
const verdict = rows.every((r) => r.pass) ? 'PASS' : 'FAIL'
|
||||
const result = {
|
||||
verdict,
|
||||
generatedAt: new Date().toISOString(),
|
||||
env: {
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
site: opts.site,
|
||||
origin,
|
||||
database: process.env.DATABASE_URL_TEST ?? '(DATABASE_URL)',
|
||||
},
|
||||
chain,
|
||||
sqlCounts,
|
||||
timings: Object.fromEntries(Object.entries(timings).map(([k, v]) => [k, Math.round(v)])),
|
||||
assertions: rows,
|
||||
detail: { a1, a2, a3, a4, smoke: { htmlTotal: htmlRows.length, htmlFailures: htmlFailures.length, redirectTotal: redirectRows.length, redirectFailures: redirectFailures.length } },
|
||||
buildReport: {
|
||||
distPath: buildReport.artifacts.distPath,
|
||||
distFileCount: buildReport.artifacts.distFileCount,
|
||||
distByteSize: buildReport.artifacts.distByteSize,
|
||||
mediaCopied: buildReport.artifacts.mediaCopied,
|
||||
sitemapEntries: buildReport.artifacts.sitemapEntries,
|
||||
validation: buildReport.validation,
|
||||
urlRewrite: buildReport.urlRewrite,
|
||||
durationMs: buildReport.durationMs,
|
||||
},
|
||||
}
|
||||
await mkdir(join(REPO_ROOT, 'docs', 'evidence'), { recursive: true })
|
||||
await writeFile(join(REPO_ROOT, 'docs', 'evidence', 'phase4-assertions.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8')
|
||||
console.log(`\n[chain] verdict=${verdict}(${String(rows.filter((r) => r.pass).length)}/${String(rows.length)} PASS)`)
|
||||
console.log(` 断言明细:docs/evidence/phase4-assertions.json`)
|
||||
console.log(` preview 样例页(serve 会话用):/ 与 ${productRoute.r.routePath} 与 ${newsRoute.r.routePath}`)
|
||||
await preview.stop()
|
||||
client.agent.destroy()
|
||||
if (verdict === 'FAIL') process.exitCode = 1
|
||||
} finally {
|
||||
await db.$client.end()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serve 子命令(截图会话:真实 CLI preview:start 同域的行为面) ----
|
||||
|
||||
async function cmdServe(opts) {
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(opts.build)) {
|
||||
throw new Error(`--build 不是合法 job id(uuid 形状):${opts.build}`)
|
||||
}
|
||||
const db = getDb()
|
||||
let distPath
|
||||
let manifestPath
|
||||
try {
|
||||
const jobs = await db.execute(`select id, status from build_jobs where id = '${opts.build}'`)
|
||||
if (jobs.length === 0) throw new Error(`构建 job 不存在:${opts.build}`)
|
||||
if (jobs[0].status !== 'succeeded') throw new Error(`仅 succeeded job 可预览(当前 ${jobs[0].status})`)
|
||||
const arts = await db.execute(`select kind, storage_path from build_artifacts where build_job_id = '${opts.build}'`)
|
||||
distPath = arts.find((a) => a.kind === 'dist')?.storage_path
|
||||
manifestPath = arts.find((a) => a.kind === 'manifest')?.storage_path
|
||||
if (distPath === undefined || manifestPath === undefined) throw new Error('产物登记不全(dist/manifest 缺席)')
|
||||
} finally {
|
||||
await db.$client.end()
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
const preview = createPreviewServer({ distDir: distPath, routeManifest: manifest, host: '127.0.0.1', port: opts.port, quiet: false })
|
||||
const addr = await preview.start()
|
||||
console.log(`[serve] job=${opts.build}`)
|
||||
console.log(` dist: ${distPath}`)
|
||||
console.log(` listening: http://127.0.0.1:${String(addr.port)}/ (Ctrl-C 停止)`)
|
||||
process.once('SIGINT', () => {
|
||||
void preview.stop().then(() => process.exit(0))
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void preview.stop().then(() => process.exit(0))
|
||||
})
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
// ---- 入口 ----
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const sub = argv[0] ?? 'chain'
|
||||
const flag = (name, fallback) => {
|
||||
const i = argv.indexOf(`--${name}`)
|
||||
return i !== -1 && argv[i + 1] !== undefined ? argv[i + 1] : fallback
|
||||
}
|
||||
if (sub === 'chain') {
|
||||
await cmdChain({ site: flag('site', 'hesupipe-real') })
|
||||
} else if (sub === 'serve') {
|
||||
await cmdServe({ build: flag('build', ''), port: Number(flag('port', '4571')) })
|
||||
} else {
|
||||
console.error('用法:e2e-real.mjs chain [--site key] | serve --build <jobId> [--port n]')
|
||||
process.exitCode = 2
|
||||
}
|
||||
Reference in New Issue
Block a user