2026-10-22 14:33:55 +08:00
#!/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 ),
)
2026-10-22 15:31:14 +08:00
// 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/' )),
)
2026-10-22 14:33:55 +08:00
// ===== 断言 ⑤ 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 ,
)
2026-10-22 15:31:14 +08:00
// 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 ,
)
2026-10-22 14:33:55 +08:00
// ===== 断言 ⑨ 门禁全绿(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 ,
2026-10-22 15:31:14 +08:00
detail : { a1 , a2 , a3 , a4 , a4c , smoke : { htmlTotal : htmlRows . length , htmlFailures : htmlFailures . length , redirectTotal : redirectRows . length , redirectFailures : redirectFailures . length } },
2026-10-22 14:33:55 +08:00
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
}