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:
@@ -5,14 +5,18 @@
|
||||
// 以 `pathname+search` 全串匹配且优先于 ignore 行;ignore 行剥 query 按 path 匹配。
|
||||
// html/asset → target_file 文件服务(**流式**:createReadStream 直pipe,Content-Length
|
||||
// 取自先行 stat——大文件不驻留内存;HEAD 只发头零读盘;manifest content_type 优先,
|
||||
// 缺席走扩展名 MIME 表,未知扩展 application/octet-stream);redirect → status+Location;
|
||||
// 缺席走扩展名 MIME 表,未知扩展 application/octet-stream);redirect → status+Location
|
||||
// (**头域 ASCII 化**:manifest location 含非 ASCII 码位(如全角括号/间隔号)时按
|
||||
// UTF-8 百分号编码后发头——RFC 9110 头域是 ASCII,原样 setHeader 会 ERR_INVALID_CHAR
|
||||
// 崩成 500 或被对端按 latin1 误解码;manifest 原串保持权威,线上形态是派生面);
|
||||
// gone → 410;dynamic/unresolved → 404(静态产物无对应文件);无匹配 → 404(dist/
|
||||
// 404.html 自定义页)。
|
||||
// - 安全:回环绑定(host 仅 127.0.0.1/localhost,库级即拒);路径穿越预拒(反斜杠/
|
||||
// 编码形态/.. 段)+ target_file resolve 后必须在 dist 内(lexical + realpath 双检);
|
||||
// 目录即 404(禁目录列表);仅 GET/HEAD(其余 405)。
|
||||
// - 头纪律:全响应 X-Robots-Tag: noindex, nofollow;text/html 响应附基础 CSP
|
||||
// (default-src 'self')。请求日志一行(method/path/status/耗时)到 stdout
|
||||
// (default-src 'self'; style-src 'self' 'unsafe-inline'——Astro 内联 scoped 样式放行,
|
||||
// 见常量处注记)。请求日志一行(method/path/status/耗时)到 stdout
|
||||
// (quiet: true 静默)。
|
||||
//
|
||||
// 本文件刻意保持 .mjs 纯 JavaScript(不进 TS 链);TS 消费方(scripts/sf.ts 与集成
|
||||
@@ -49,7 +53,11 @@ const MIME_BY_EXT = new Map([
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost'])
|
||||
|
||||
const X_ROBOTS_TAG = 'noindex, nofollow'
|
||||
const CSP = "default-src 'self'"
|
||||
// CSP(Task 8 浏览器验收实证修补):Astro 5 静态产物把 scoped 样式内联为 <style> 元素
|
||||
// (无外部样式表)——default-src 'self' 会拦掉全部内联样式,页面在浏览器里裸奔(HTTP
|
||||
// 级测试看不到,浏览器时刻才显影)。style-src 放开 'unsafe-inline'(仅样式面;脚本/
|
||||
// 连接/图片等仍走 default-src 'self')。
|
||||
const CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
|
||||
/**
|
||||
* 请求路径安全预检(**先于 URL 解析**——new URL 会把 '/../' 规范化到 '/',裸点段
|
||||
@@ -76,11 +84,26 @@ function contentTypeForExt(file) {
|
||||
return MIME_BY_EXT.get(extname(file).toLowerCase()) ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
/** HTML 文本转义(redirect 迁移页内嵌 location 用——Location 头本身保持原样) */
|
||||
/** HTML 文本转义(redirect 迁移页内嵌 location 用——页面体内嵌原文,头走 asciiLocation) */
|
||||
function escapeHtml(s) {
|
||||
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* Location 头 ASCII 化(Task 8 全量 1746 redirect 校验实证修补):非 ASCII 码位按
|
||||
* UTF-8 百分号编码(encodeURIComponent 单码位即该码位的 UTF-8 八位组,hex 大写——
|
||||
* 与 WP/nginx 的百分号编码形态一致);可打印 ASCII(含既有 %xx 序列的字面字符)原样。
|
||||
* 控制字符/CR/LF 同路编码——头注入面封死(值内裸 % 后非 hex 也只是保留字面,不解码)。
|
||||
*/
|
||||
function asciiLocation(value) {
|
||||
let out = ''
|
||||
for (const ch of value) {
|
||||
const code = ch.codePointAt(0)
|
||||
out += code >= 0x20 && code <= 0x7e ? ch : encodeURIComponent(ch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 头纪律唯一出口:全响应 noindex;text/html 附 CSP;扩展头(Location/Allow 等) */
|
||||
function applyHeaders(res, status, contentType, extraHeaders = undefined) {
|
||||
res.statusCode = status
|
||||
@@ -243,10 +266,11 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1'
|
||||
if (row.kind === 'redirect') {
|
||||
const location = typeof row.location === 'string' ? row.location : '/'
|
||||
const status = row.status >= 300 && row.status <= 399 ? row.status : 301
|
||||
// Location 头原样;HTML 迁移页内嵌时转义(</&/" ——防 manifest location 注入标记)
|
||||
// Location 头 ASCII 化后发(manifest 原串含全角括号/间隔号等非 ASCII 时原样发头会
|
||||
// ERR_INVALID_CHAR 崩 500——Task 8 全量 redirect 校验实证);HTML 迁移页内嵌原文转义
|
||||
const safeLocation = escapeHtml(location)
|
||||
send(res, status, 'text/html; charset=utf-8', htmlDocument('Moved', `本页已迁移:<a href="${safeLocation}">${safeLocation}</a>`), isHead, {
|
||||
Location: location,
|
||||
Location: asciiLocation(location),
|
||||
})
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"verdict": "PASS",
|
||||
"generatedAt": "2026-10-22T06:21:14.951Z",
|
||||
"env": {
|
||||
"node": "v24.13.0",
|
||||
"platform": "win32",
|
||||
"site": "hesupipe-real",
|
||||
"origin": "https://hesupipe.com",
|
||||
"database": "postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_hesupipe"
|
||||
},
|
||||
"chain": {
|
||||
"register": {
|
||||
"runId": "20261022T142105-hiapw6",
|
||||
"siteId": "599b435b-0778-4676-8a95-b134546a1eb8",
|
||||
"siteCreated": false,
|
||||
"connectionId": "3c6b9a48-0426-4f01-b822-56c20b5e8208",
|
||||
"snapshots": [
|
||||
{
|
||||
"kind": "wxr",
|
||||
"sha256": "f9bc5d4497a1",
|
||||
"byteSize": 5490399,
|
||||
"reused": true,
|
||||
"copied": false,
|
||||
"durationMs": 15.189599999999928
|
||||
},
|
||||
{
|
||||
"kind": "media_bundle",
|
||||
"sha256": "8b674261c104",
|
||||
"byteSize": 172532000,
|
||||
"reused": true,
|
||||
"copied": false,
|
||||
"durationMs": 1288.2132000000001
|
||||
},
|
||||
{
|
||||
"kind": "manual_url_list",
|
||||
"sha256": "c935d444f52b",
|
||||
"byteSize": 143945,
|
||||
"reused": true,
|
||||
"copied": false,
|
||||
"durationMs": 20.976500000000215
|
||||
}
|
||||
]
|
||||
},
|
||||
"import": {
|
||||
"jobId": "9d82df35-5c8a-4345-8156-3319c7ac9d67",
|
||||
"status": "succeeded_with_issues",
|
||||
"reused": true,
|
||||
"reportPath": "D:\\project\\cms-one\\artifacts\\import\\9d82df35-5c8a-4345-8156-3319c7ac9d67\\reports\\import.json"
|
||||
},
|
||||
"publish": {
|
||||
"dryRunCandidates": 0,
|
||||
"publishedCount": 0,
|
||||
"upToDateCount": 192,
|
||||
"siteConfigRevision": null
|
||||
},
|
||||
"build": {
|
||||
"jobId": "619c440c-f579-4647-99dc-2840dcd36f19",
|
||||
"status": "succeeded",
|
||||
"reused": true,
|
||||
"inputDir": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\input",
|
||||
"reportPath": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\reports\\build.json",
|
||||
"counts": {
|
||||
"contents": 192,
|
||||
"taxonomies": 6,
|
||||
"routes": 2772,
|
||||
"assets": 836
|
||||
},
|
||||
"urlRewrite": {
|
||||
"rewritten": 386,
|
||||
"unresolved": 317
|
||||
},
|
||||
"issues": [],
|
||||
"artifacts": {
|
||||
"distPath": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\dist",
|
||||
"distFileCount": 1029,
|
||||
"distSha256": "6883e70a1ae31f1f5244fef3315bd57a6c46eb08f078ac84d99373f21c621832",
|
||||
"routeManifestPath": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\route-manifest.json",
|
||||
"sitemapEntries": 190,
|
||||
"mediaCopied": 836
|
||||
},
|
||||
"marks": []
|
||||
}
|
||||
},
|
||||
"sqlCounts": {
|
||||
"contentEntries": 192,
|
||||
"publishedLocales": 192,
|
||||
"mediaReady": 836,
|
||||
"auditPublish": 1,
|
||||
"typeKey": [
|
||||
{
|
||||
"type_key": "basic_page",
|
||||
"n": "15"
|
||||
},
|
||||
{
|
||||
"type_key": "news_article",
|
||||
"n": "129"
|
||||
},
|
||||
{
|
||||
"type_key": "product",
|
||||
"n": "48"
|
||||
}
|
||||
],
|
||||
"routeKind": [
|
||||
{
|
||||
"route_kind": "html",
|
||||
"n": "190"
|
||||
},
|
||||
{
|
||||
"route_kind": "asset",
|
||||
"n": "836"
|
||||
},
|
||||
{
|
||||
"route_kind": "redirect",
|
||||
"n": "1746"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timings": {
|
||||
"registerWallMs": 1771,
|
||||
"registerServiceMs": 1750,
|
||||
"importMs": 94,
|
||||
"publishDryRunMs": 87,
|
||||
"publishConfirmMs": 77,
|
||||
"buildTotalMs": 11875,
|
||||
"astroProbeMs": 7486,
|
||||
"smokeMs": 1225
|
||||
},
|
||||
"assertions": [
|
||||
{
|
||||
"id": "①home /",
|
||||
"expect": "HTTP 200 + 真实内容(标题/列表/媒体引用)",
|
||||
"actual": "http=200 checks={\"siteNameInBody\":true,\"latest3NewsTitles\":true,\"top3\":[\"HDPE Water Pipe Coils shipped to Chile (South America)\",\"HDPE Slurry&Sand Dredging Pipeline System Shipped to Tanzania (East Africa)\",\"HDPE pipe for Urban water network in Burkina Faso ( West Africa)\"]}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "①coded content page",
|
||||
"expect": "HTTP 200 + 真实内容(标题/列表/媒体引用)",
|
||||
"actual": "http=200 checks={\"titleInBody\":true,\"title\":\"Before choosing a PE pipe, be sure to confirm the purpose\"}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "①product category list",
|
||||
"expect": "HTTP 200 + 真实内容(标题/列表/媒体引用)",
|
||||
"actual": "http=200 checks={\"termNameInBody\":true,\"productTitlesInBody\":true,\"termName\":\"HDPE PIPE & FITTINGS\",\"termProducts\":14}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "①product detail",
|
||||
"expect": "HTTP 200 + 真实内容(标题/列表/媒体引用)",
|
||||
"actual": "http=200 checks={\"titleInBody\":true,\"localizedMediaRefs\":true,\"title\":\"ECOLOGICAL WOOD DOORS\",\"mediaRefs\":12,\"specTableRows\":0}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "①news list /news/",
|
||||
"expect": "HTTP 200 + 真实内容(标题/列表/媒体引用)",
|
||||
"actual": "http=200 checks={\"latest5Titles\":true,\"top5\":[\"HDPE Water Pipe Coils shipped to Chile (South America)\",\"HDPE Slurry&Sand Dredging Pipeline System Shipped to Tanzania (East Africa)\",\"HDPE pipe for Urban water network in Burkina Faso ( West Africa)\",\"HDPE Corrugated Pipe for Agricultural Drainage in EI Salvador ( Central America)\",\"HDPE Pipe for Underground Sewerage System in Mongolia\"]}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "①news detail",
|
||||
"expect": "HTTP 200 + 真实内容(标题/列表/媒体引用)",
|
||||
"actual": "http=200 checks={\"titleInBody\":true,\"dateInBody\":true,\"title\":\"2023 Pipe Market Analysis\",\"date\":\"2023-11-19 13:32:01\"}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "②图片本地化抽检",
|
||||
"expect": "10 张:根相对引用 + dist 文件存在 + preview 200 + assets.json 在册",
|
||||
"actual": "[{\"src\":\"/wp-content/uploads/2023/12/Pipe-Market-Analysis.jpg\",\"refPage\":\"2023-pipe-market-analysis/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":78330},{\"src\":\"/wp-content/uploads/2024/05/2024-Indonesia-Jakarta-International-Building-Materials-Exhibition-MEGA-BUILD.jpg\",\"refPage\":\"2024-indonesia-jakarta-international-building-materials-exhibition-mega-build/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":144970},{\"src\":\"/wp-content/uploads/2024/02/2024-Philippine-International-Construction-and-Building-Materials-Exhibition.jpg\",\"refPage\":\"2024-philippine-international-construction-and-building-materials-exhibition/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":1077233},{\"src\":\"/wp-content/uploads/2024/02/electrofusion.jpg\",\"refPage\":\"a-revolutionary-technique-for-hdpe-pipe-welding/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":76511},{\"src\":\"/wp-content/uploads/2023/12/HDPE-pipes.jpeg\",\"refPage\":\"about-us/blog/hdpe-pipes-are-an-asset-in-pumping-chamber-upgrade/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":81253},{\"src\":\"/wp-content/uploads/2023/12/1HDPE-Water-Supply-Pipe-64.jpg\",\"refPage\":\"about-us/blog/hdpe-pipes-are-an-asset-in-pumping-chamber-upgrade/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":61930},{\"src\":\"/wp-content/uploads/2023/12/CERTIFICATE1.png\",\"refPage\":\"about-us/certificates/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":233910},{\"src\":\"/wp-content/uploads/2024/10/d1afc2d40d1b42dfc65042.webp\",\"refPage\":\"about-us/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":174774},{\"src\":\"/wp-content/uploads/2023/12/about-hesu.jpg\",\"refPage\":\"about-us/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":124448},{\"src\":\"/wp-content/uploads/2023/12/25b2916b5c49db617f5224.jpg\",\"refPage\":\"about-us/index.html\",\"rootRelative\":true,\"inAssetsJson\":true,\"distFileExists\":true,\"httpStatus\":200,\"byteSize\":11482}]",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "③SEO 三件套×5页",
|
||||
"expect": "title/description 非空 + canonical === origin+路径(逐页精确)",
|
||||
"actual": "[{\"page\":\"home\",\"titleNonEmpty\":true,\"descriptionNonEmpty\":true,\"canonicalExact\":true,\"canonical\":\"https://hesupipe.com/\"},{\"page\":\"coded page\",\"titleNonEmpty\":true,\"descriptionNonEmpty\":true,\"canonicalExact\":true,\"canonical\":\"https://hesupipe.com/how-to-choose-pe-pipe%ef%bc%9f/\"},{\"page\":\"product category\",\"titleNonEmpty\":true,\"descriptionNonEmpty\":true,\"canonicalExact\":true,\"canonical\":\"https://hesupipe.com/product-category/hdpe-pipe-fittings/\"},{\"page\":\"product detail\",\"titleNonEmpty\":true,\"descriptionNonEmpty\":true,\"canonicalExact\":true,\"canonical\":\"https://hesupipe.com/product/ecological-wooden-door/\"},{\"page\":\"news detail\",\"titleNonEmpty\":true,\"descriptionNonEmpty\":true,\"canonicalExact\":true,\"canonical\":\"https://hesupipe.com/2023-pipe-market-analysis/\"}]",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "④未命中 404",
|
||||
"expect": "404 + X-Robots-Tag noindex",
|
||||
"actual": "status=404 x-robots=noindex, nofollow",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "④301 抽检(?attachment_id= 原串 3 + 路径形态 2)",
|
||||
"expect": "逐条 301 + Location === manifest 原串的 RFC 头域 ASCII 派生形(原串含非 ASCII 时 UTF-8 百分号编码)",
|
||||
"actual": "[{\"request\":\"/?attachment_id=1559\",\"expectLocation\":\"/wp-content/uploads/2019/12/file-pdf.png\",\"status\":301,\"location\":\"/wp-content/uploads/2019/12/file-pdf.png\",\"locationMatch\":true},{\"request\":\"/?attachment_id=3128\",\"expectLocation\":\"/wp-content/uploads/2024/03/hesu-pipe-showcase-scaled.jpg\",\"status\":301,\"location\":\"/wp-content/uploads/2024/03/hesu-pipe-showcase-scaled.jpg\",\"locationMatch\":true},{\"request\":\"/?attachment_id=3921\",\"expectLocation\":\"/wp-content/uploads/2024/11/Delburg-system-windows-add-color-to-Saudi-Arabia1.jpg\",\"status\":301,\"location\":\"/wp-content/uploads/2024/11/Delburg-system-windows-add-color-to-Saudi-Arabia1.jpg\",\"locationMatch\":true},{\"request\":\"/%e4%ba%a7%e5%93%811-2/\",\"expectLocation\":\"/wp-content/uploads/2024/10/4353831cc9a575fd90e45.webp\",\"status\":301,\"location\":\"/wp-content/uploads/2024/10/4353831cc9a575fd90e45.webp\",\"locationMatch\":true},{\"request\":\"/why-pvc-water-supply-pipes-are-the-ideal-choice-for-modern-infrastructure/pvc-water-supply-pipes/\",\"expectLocation\":\"/wp-content/uploads/2024/12/PVC-Water-Supply-Pipes.jpg\",\"status\":301,\"location\":\"/wp-content/uploads/2024/12/PVC-Water-Supply-Pipes.jpg\",\"locationMatch\":true}]",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "⑤sitemap 条目数",
|
||||
"expect": "= manifest indexable html 数(190)",
|
||||
"actual": "loc=190 indexable=190",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "⑤robots.txt",
|
||||
"expect": "存在 + Sitemap 行指向 origin",
|
||||
"actual": "存在(Sitemap: https://hesupipe.com/sitemap.xml)",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "⑦html 全量 200",
|
||||
"expect": "190 行全 200(manifest html 行数)",
|
||||
"actual": "ok=190/190",
|
||||
"pass": true,
|
||||
"note": "样例 / content-type=text/html; charset=utf-8"
|
||||
},
|
||||
{
|
||||
"id": "⑦redirect 全量 301+Location",
|
||||
"expect": "1746 行逐条 301 + Location === manifest 原串(非 ASCII 头域 ASCII 化派生形)",
|
||||
"actual": "ok=1746/1746",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "⑨门禁 validation_runs",
|
||||
"expect": "job succeeded + validation_run PASS(build succeeded 即含门禁通过)",
|
||||
"actual": "job=succeeded runs=[{\"id\":\"6dc3776b-7bd5-4199-aa95-2db8e8a5ea41\",\"status\":\"PASS\",\"rule_version\":\"min-gates-v1\"}] issues=[]",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "计数 content_entries",
|
||||
"expect": "192(129 news_article + 48 product + 15 basic_page)",
|
||||
"actual": "total=192 {\"basic_page\":15,\"news_article\":129,\"product\":48}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "计数 published 指针",
|
||||
"expect": "192(显式发布后全量)",
|
||||
"actual": "192",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "计数 routes",
|
||||
"expect": "2772(html 190 / redirect 1746 / asset 836)",
|
||||
"actual": "{\"html\":190,\"asset\":836,\"redirect\":1746}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "计数 media ready",
|
||||
"expect": "836",
|
||||
"actual": "836",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "计数 audit content.publish",
|
||||
"expect": "≥1(本链 confirm 写入)",
|
||||
"actual": "1",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "构建产物面",
|
||||
"expect": "dist 文件≈1029(html+媒体+sitemap/robots+404)",
|
||||
"actual": "distFileCount=1029 mediaCopied=836 sitemapEntries=190 urlRewrite={\"rewritten\":386,\"unresolved\":317}",
|
||||
"pass": true,
|
||||
"note": ""
|
||||
}
|
||||
],
|
||||
"detail": {
|
||||
"a1": [
|
||||
{
|
||||
"page": "home /",
|
||||
"route": "/",
|
||||
"http": 200,
|
||||
"ok": true,
|
||||
"checks": {
|
||||
"siteNameInBody": true,
|
||||
"latest3NewsTitles": true,
|
||||
"top3": [
|
||||
"HDPE Water Pipe Coils shipped to Chile (South America)",
|
||||
"HDPE Slurry&Sand Dredging Pipeline System Shipped to Tanzania (East Africa)",
|
||||
"HDPE pipe for Urban water network in Burkina Faso ( West Africa)"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"page": "coded content page",
|
||||
"route": "/how-to-choose-pe-pipe%ef%bc%9f/",
|
||||
"http": 200,
|
||||
"ok": true,
|
||||
"checks": {
|
||||
"titleInBody": true,
|
||||
"title": "Before choosing a PE pipe, be sure to confirm the purpose"
|
||||
}
|
||||
},
|
||||
{
|
||||
"page": "product category list",
|
||||
"route": "/product-category/hdpe-pipe-fittings/",
|
||||
"http": 200,
|
||||
"ok": true,
|
||||
"checks": {
|
||||
"termNameInBody": true,
|
||||
"productTitlesInBody": true,
|
||||
"termName": "HDPE PIPE & FITTINGS",
|
||||
"termProducts": 14
|
||||
}
|
||||
},
|
||||
{
|
||||
"page": "product detail",
|
||||
"route": "/product/ecological-wooden-door/",
|
||||
"http": 200,
|
||||
"ok": true,
|
||||
"checks": {
|
||||
"titleInBody": true,
|
||||
"localizedMediaRefs": true,
|
||||
"title": "ECOLOGICAL WOOD DOORS",
|
||||
"mediaRefs": 12,
|
||||
"specTableRows": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"page": "news list /news/",
|
||||
"route": "/news/",
|
||||
"http": 200,
|
||||
"ok": true,
|
||||
"checks": {
|
||||
"latest5Titles": true,
|
||||
"top5": [
|
||||
"HDPE Water Pipe Coils shipped to Chile (South America)",
|
||||
"HDPE Slurry&Sand Dredging Pipeline System Shipped to Tanzania (East Africa)",
|
||||
"HDPE pipe for Urban water network in Burkina Faso ( West Africa)",
|
||||
"HDPE Corrugated Pipe for Agricultural Drainage in EI Salvador ( Central America)",
|
||||
"HDPE Pipe for Underground Sewerage System in Mongolia"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"page": "news detail",
|
||||
"route": "/2023-pipe-market-analysis/",
|
||||
"http": 200,
|
||||
"ok": true,
|
||||
"checks": {
|
||||
"titleInBody": true,
|
||||
"dateInBody": true,
|
||||
"title": "2023 Pipe Market Analysis",
|
||||
"date": "2023-11-19 13:32:01"
|
||||
}
|
||||
}
|
||||
],
|
||||
"a2": [
|
||||
{
|
||||
"src": "/wp-content/uploads/2023/12/Pipe-Market-Analysis.jpg",
|
||||
"refPage": "2023-pipe-market-analysis/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 78330
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2024/05/2024-Indonesia-Jakarta-International-Building-Materials-Exhibition-MEGA-BUILD.jpg",
|
||||
"refPage": "2024-indonesia-jakarta-international-building-materials-exhibition-mega-build/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 144970
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2024/02/2024-Philippine-International-Construction-and-Building-Materials-Exhibition.jpg",
|
||||
"refPage": "2024-philippine-international-construction-and-building-materials-exhibition/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 1077233
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2024/02/electrofusion.jpg",
|
||||
"refPage": "a-revolutionary-technique-for-hdpe-pipe-welding/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 76511
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2023/12/HDPE-pipes.jpeg",
|
||||
"refPage": "about-us/blog/hdpe-pipes-are-an-asset-in-pumping-chamber-upgrade/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 81253
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2023/12/1HDPE-Water-Supply-Pipe-64.jpg",
|
||||
"refPage": "about-us/blog/hdpe-pipes-are-an-asset-in-pumping-chamber-upgrade/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 61930
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2023/12/CERTIFICATE1.png",
|
||||
"refPage": "about-us/certificates/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 233910
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2024/10/d1afc2d40d1b42dfc65042.webp",
|
||||
"refPage": "about-us/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 174774
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2023/12/about-hesu.jpg",
|
||||
"refPage": "about-us/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 124448
|
||||
},
|
||||
{
|
||||
"src": "/wp-content/uploads/2023/12/25b2916b5c49db617f5224.jpg",
|
||||
"refPage": "about-us/index.html",
|
||||
"rootRelative": true,
|
||||
"inAssetsJson": true,
|
||||
"distFileExists": true,
|
||||
"httpStatus": 200,
|
||||
"byteSize": 11482
|
||||
}
|
||||
],
|
||||
"a3": [
|
||||
{
|
||||
"page": "home",
|
||||
"routePath": "/",
|
||||
"title": "hesupipe-real",
|
||||
"titleNonEmpty": true,
|
||||
"descriptionNonEmpty": true,
|
||||
"canonical": "https://hesupipe.com/",
|
||||
"canonicalExact": true,
|
||||
"expectCanonical": "https://hesupipe.com/"
|
||||
},
|
||||
{
|
||||
"page": "coded page",
|
||||
"routePath": "/how-to-choose-pe-pipe%ef%bc%9f/",
|
||||
"title": "Before choosing a PE pipe, be sure to confirm the purpose",
|
||||
"titleNonEmpty": true,
|
||||
"descriptionNonEmpty": true,
|
||||
"canonical": "https://hesupipe.com/how-to-choose-pe-pipe%ef%bc%9f/",
|
||||
"canonicalExact": true,
|
||||
"expectCanonical": "https://hesupipe.com/how-to-choose-pe-pipe%ef%bc%9f/"
|
||||
},
|
||||
{
|
||||
"page": "product category",
|
||||
"routePath": "/product-category/hdpe-pipe-fittings/",
|
||||
"title": "HDPE PIPE & FITTINGS",
|
||||
"titleNonEmpty": true,
|
||||
"descriptionNonEmpty": true,
|
||||
"canonical": "https://hesupipe.com/product-category/hdpe-pipe-fittings/",
|
||||
"canonicalExact": true,
|
||||
"expectCanonical": "https://hesupipe.com/product-category/hdpe-pipe-fittings/"
|
||||
},
|
||||
{
|
||||
"page": "product detail",
|
||||
"routePath": "/product/ecological-wooden-door/",
|
||||
"title": "ECOLOGICAL WOOD DOORS",
|
||||
"titleNonEmpty": true,
|
||||
"descriptionNonEmpty": true,
|
||||
"canonical": "https://hesupipe.com/product/ecological-wooden-door/",
|
||||
"canonicalExact": true,
|
||||
"expectCanonical": "https://hesupipe.com/product/ecological-wooden-door/"
|
||||
},
|
||||
{
|
||||
"page": "news detail",
|
||||
"routePath": "/2023-pipe-market-analysis/",
|
||||
"title": "2023 Pipe Market Analysis",
|
||||
"titleNonEmpty": true,
|
||||
"descriptionNonEmpty": true,
|
||||
"canonical": "https://hesupipe.com/2023-pipe-market-analysis/",
|
||||
"canonicalExact": true,
|
||||
"expectCanonical": "https://hesupipe.com/2023-pipe-market-analysis/"
|
||||
}
|
||||
],
|
||||
"a4": [
|
||||
{
|
||||
"request": "/?attachment_id=1559",
|
||||
"expectLocation": "/wp-content/uploads/2019/12/file-pdf.png",
|
||||
"status": 301,
|
||||
"location": "/wp-content/uploads/2019/12/file-pdf.png",
|
||||
"locationMatch": true
|
||||
},
|
||||
{
|
||||
"request": "/?attachment_id=3128",
|
||||
"expectLocation": "/wp-content/uploads/2024/03/hesu-pipe-showcase-scaled.jpg",
|
||||
"status": 301,
|
||||
"location": "/wp-content/uploads/2024/03/hesu-pipe-showcase-scaled.jpg",
|
||||
"locationMatch": true
|
||||
},
|
||||
{
|
||||
"request": "/?attachment_id=3921",
|
||||
"expectLocation": "/wp-content/uploads/2024/11/Delburg-system-windows-add-color-to-Saudi-Arabia1.jpg",
|
||||
"status": 301,
|
||||
"location": "/wp-content/uploads/2024/11/Delburg-system-windows-add-color-to-Saudi-Arabia1.jpg",
|
||||
"locationMatch": true
|
||||
},
|
||||
{
|
||||
"request": "/%e4%ba%a7%e5%93%811-2/",
|
||||
"expectLocation": "/wp-content/uploads/2024/10/4353831cc9a575fd90e45.webp",
|
||||
"status": 301,
|
||||
"location": "/wp-content/uploads/2024/10/4353831cc9a575fd90e45.webp",
|
||||
"locationMatch": true
|
||||
},
|
||||
{
|
||||
"request": "/why-pvc-water-supply-pipes-are-the-ideal-choice-for-modern-infrastructure/pvc-water-supply-pipes/",
|
||||
"expectLocation": "/wp-content/uploads/2024/12/PVC-Water-Supply-Pipes.jpg",
|
||||
"status": 301,
|
||||
"location": "/wp-content/uploads/2024/12/PVC-Water-Supply-Pipes.jpg",
|
||||
"locationMatch": true
|
||||
}
|
||||
],
|
||||
"smoke": {
|
||||
"htmlTotal": 190,
|
||||
"htmlFailures": 0,
|
||||
"redirectTotal": 1746,
|
||||
"redirectFailures": 0
|
||||
}
|
||||
},
|
||||
"buildReport": {
|
||||
"distPath": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\dist",
|
||||
"distFileCount": 1029,
|
||||
"mediaCopied": 836,
|
||||
"sitemapEntries": 190,
|
||||
"validation": {
|
||||
"ruleVersion": "min-gates-v1",
|
||||
"status": "PASS",
|
||||
"runId": "6dc3776b-7bd5-4199-aa95-2db8e8a5ea41",
|
||||
"p0Count": 0,
|
||||
"p2Count": 0
|
||||
},
|
||||
"urlRewrite": {
|
||||
"rewritten": 386,
|
||||
"unresolved": 317
|
||||
},
|
||||
"durationMs": 11875.096499999992
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# 阶段 4 真实 hesupipe 端到端验收证据(Plan 4 · Task 8 —— 浏览器时刻)
|
||||
|
||||
**verdict:PASS**(断言总表 21 项脚本断言全过 + 浏览器时刻 6 截图无破版无横向溢出;两处验收内修补(预览服务器 Location 头 ASCII 化、CSP 放行内联样式)均有回归测试锁定,见 §5;观察项未触发主题补规则,见 §5.1)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 日期 | 2026-10-22 |
|
||||
| 环境 | Windows 11 · Node v24.13.0 · pnpm 10.7.1 · tsx 直吃 `.mjs` 编排脚本(`@sf/*` 经根 tsconfig paths 解析,与 sf CLI 同机制) |
|
||||
| 数据库 | 本项目 PG(docker `sf-postgres`,postgres:16,localhost:5434)临时库 `sf_phase4_hesupipe`(验后已 DROP,见 §7) |
|
||||
| 站点/输入 | 复用阶段 3 同源副本(`storage/incoming/` 的 WXR + 媒体 + 侧车;storage/ 不入库)——site `hesupipe-real`、origin=https://hesupipe.com、locale=en |
|
||||
| 全链 job | import `9d82df35-5c8a-4345-8156-3319c7ac9d67`(succeeded_with_issues,51,123.8ms 首跑)· publish 192 → upToDate · build `619c440c-f579-4647-99dc-2840dcd36f19`(succeeded,11,885.4ms 首跑)· validation run `6dc3776b-7bd5-4199-aa95-2db8e8a5ea41` PASS(min-gates-v1,p0=0 p2=0) |
|
||||
| 产物面 | dist 1029 文件(190 html + 836 媒体 + sitemap.xml + robots.txt + 404.html)· route-manifest 2772 行(html 190 / redirect 1746 / asset 836)· dist SHA-256 `6883e70a…621832` |
|
||||
| 断言明细 | `docs/evidence/phase4-assertions.json`(脚本逐断言落盘,verdict=PASS 21/21) |
|
||||
| 截图 | `docs/evidence/screenshots/phase4-{mobile-375,desktop-1440}-{home,product-detail,news-detail}.png` ×6(§3) |
|
||||
| 编排脚本 | `scripts/e2e-real.mjs`(`chain` 全链+断言 / `serve` 截图会话常驻预览,两个子命令) |
|
||||
|
||||
## 1. 全链命令原文(仓库根 `D:\project\cms-one`,Git Bash)
|
||||
|
||||
```bash
|
||||
# 临时库(阶段 3 的 sf_phase3_hesupipe 已在其任务末 DROP;本任务新建同构库)
|
||||
docker exec sf-postgres psql -U siteforge -d postgres -c "CREATE DATABASE sf_phase4_hesupipe;"
|
||||
DATABASE_URL="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_hesupipe" pnpm --filter @sf/api db:migrate
|
||||
|
||||
# 全链 + 断言(register 副本复用 → import → publish dry-run/confirm → build → 进程内预览
|
||||
# 断言 ①-⑤⑦⑧⑨ + SQL/产物计数对拍 → docs/evidence/phase4-assertions.json)
|
||||
DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_hesupipe" \
|
||||
pnpm exec tsx scripts/e2e-real.mjs chain --site hesupipe-real
|
||||
|
||||
# 截图会话(serve:对既有 succeeded build 起常驻预览 127.0.0.1:4571,Playwright MCP 对其取证)
|
||||
DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_hesupipe" \
|
||||
pnpm exec tsx scripts/e2e-real.mjs serve --build 619c440c-f579-4647-99dc-2840dcd36f19 --port 4571
|
||||
```
|
||||
|
||||
互斥纪律(Task 5 备忘):链路执行期间零并发测试/零并发构建(vitest 全套在链路完成后单独跑,见 §5.3);astro 计时探针冻结输入重跑(`SF_BUILD_INPUT` 指向 job input 目录,前后清主题暂存 dist)。
|
||||
|
||||
## 2. 断言总表(预期 vs 实测 vs 判定)
|
||||
|
||||
预期来源:Plan 4 Task 8 节 + 快线 §8.7。①-⑤⑦⑨ 与计数对拍由 `chain` 子命令逐项断言(明细 JSON 同步落盘);⑥⑧ 在浏览器时刻/计时节单独取证(§3/§4)。
|
||||
|
||||
| # | 断言项 | 预期 | 实测 | 判定 |
|
||||
|---|---|---|---|---|
|
||||
| ①a | 首页 `/` | HTTP 200 + 真实内容 | 200;站点名在体 + 最新 3 条新闻标题在体(Chile/Tanzania/Burkina Faso 三篇实题) | PASS |
|
||||
| ①b | 编码内容页 `/how-to-choose-pe-pipe%ef%bc%9f/` | 同上 | 200;标题"Before choosing a PE pipe, be sure to confirm the purpose"在体 | PASS |
|
||||
| ①c | 产品分类列表 `/product-category/hdpe-pipe-fittings/` | 同上 | 200;term 名 + 14 个产品标题在体 | PASS |
|
||||
| ①d | 产品详情 `/product/ecological-wooden-door/` | 同上 | 200;标题"ECOLOGICAL WOOD DOORS"在体 + 12 个本地化媒体引用 | PASS |
|
||||
| ①e | 新闻列表 `/news/` | 同上 | 200;最新 5 条标题在体 | PASS |
|
||||
| ①f | 新闻详情 `/2023-pipe-market-analysis/` | 同上 | 200;标题 + 日期"2023-11-19 13:32:01"在体 | PASS |
|
||||
| ② | 图片本地化抽检 10 张 | 根相对引用 + dist 文件存在 + preview 200 + assets.json 在册 | 10/10:全部 `/wp-content/uploads/…` 根相对、dist 文件在、HTTP 200(11KB-1MB 实尺寸)、assets.json 全在册 | PASS |
|
||||
| ③ | SEO 三件套 ×5 页 | title/description 非空 + canonical === origin+路径 | 5/5 逐页精确(含编码路径 canonical `…/how-to-choose-pe-pipe%ef%bc%9f/` 原样保持) | PASS |
|
||||
| ④a | 未命中 404 | 404 + noindex | status=404 + `X-Robots-Tag: noindex, nofollow`(自定义 404.html) | PASS |
|
||||
| ④b | 301 抽检 | `?attachment_id=` 原串 3 + 路径形态 2,逐条 301 + Location 与 manifest 原串一致 | 5/5:全 301、locationMatch 全真(ASCII 位置串逐字节等;非 ASCII 原串的头域派生形见 §5.2) | PASS |
|
||||
| ⑤a | sitemap.xml(盘读) | 条目 = manifest indexable html 数 | loc=190 = indexable=190 | PASS |
|
||||
| ⑤b | robots.txt(盘读) | 存在 + Sitemap 行指向 origin | 存在,`Sitemap: https://hesupipe.com/sitemap.xml` | PASS |
|
||||
| ⑥ | 浏览器时刻(375/1440 ×3 页) | 无横向溢出 + 视觉无破版 | scrollWidth 360≤375 / 1425≤1440 ×6 组合全过;6 截图样式生效无破版(§3) | PASS |
|
||||
| ⑦a | html 全量 | 190 行全 200 | ok=190/190(keep-alive 并发 8;content-type=text/html; charset=utf-8) | PASS |
|
||||
| ⑦b | redirect 全量 | 1746 行逐条 301 + Location 一致 | ok=1746/1746(含 24 条 `?attachment_id=` exact 行按原串发请求;非 ASCII Location 修复后全过,§5.2) | PASS |
|
||||
| ⑧ | 计时对账 | 分钟级预算内 + 与阶段 3 基线同量级 | 全链首跑 ~64s(import 51.1s + build 11.9s);复跑链断言 ~15s(§4) | PASS |
|
||||
| ⑨ | 门禁 | validation_runs PASS | job=succeeded · run PASS · min-gates-v1 · p0=0 p2=0 · issues=[] | PASS |
|
||||
| 计数 | content_entries | 192(129+48+15) | SQL=192 {news_article:129, product:48, basic_page:15} | PASS |
|
||||
| 计数 | published 指针 | 192 | SQL=192 | PASS |
|
||||
| 计数 | routes | 2772(190/1746/836) | SQL 分组逐桶吻合 | PASS |
|
||||
| 计数 | media ready | 836 | SQL=836 | PASS |
|
||||
| 计数 | audit content.publish | ≥1 | =1(confirm 写入) | PASS |
|
||||
| 计数 | 构建产物面 | dist≈1029 | distFileCount=1029 · mediaCopied=836 · sitemapEntries=190 | PASS |
|
||||
|
||||
## 3. 浏览器时刻(Playwright MCP × 常驻预览 127.0.0.1:4571)
|
||||
|
||||
三页取样(与 ① 断言页一致):首页 `/`、产品详情 `/product/ecological-wooden-door/`(12 媒体引用最多)、新闻详情 `/2023-pipe-market-analysis/`。每页双视口截图(整页)。
|
||||
|
||||
| 截图 | 视口 | scrollWidth(≤视口) | 视觉判定 |
|
||||
|---|---|---|---|
|
||||
| `screenshots/phase4-mobile-375-home.png` | 375×812 | 360 | 样式生效(标题层级/列表/图片自适应),无破版 |
|
||||
| `screenshots/phase4-mobile-375-product-detail.png` | 375×812 | 360 | 产品标题 + 木门样图横排自适应缩放,无溢出无破图 |
|
||||
| `screenshots/phase4-mobile-375-news-detail.png` | 375×812 | 360 | 文章标题/日期/正文排版正常,头图自适应 |
|
||||
| `screenshots/phase4-desktop-1440-home.png` | 1440×900 | 1425 | 桌面版式正常,新闻列表/产品分节无错位 |
|
||||
| `screenshots/phase4-desktop-1440-product-detail.png` | 1440×900 | 1425 | 图廊 + 描述正文排版正常 |
|
||||
| `screenshots/phase4-desktop-1440-news-detail.png` | 1440×900 | 1425 | 标题/日期/正文/头图排版正常 |
|
||||
|
||||
- 溢出判据:`document.body.scrollWidth <= window.innerWidth`(六组合全 false 无横向溢出;360/1425 与视口的 15px 差是滚动条槽位)。
|
||||
- 控制台仅 1 条噪声:`/favicon.ico` 404(源数据无 favicon,路由表无此行——如期 404,装饰性,不修,§5.4)。
|
||||
|
||||
## 4. 耗时分解(vs 基线)
|
||||
|
||||
| 阶段 | 首跑实测 | 对账 |
|
||||
|---|---|---|
|
||||
| register(快照副本复用) | 服务内 1,750ms(wxr 15.2 / media_bundle 1,288.2 / manual_url_list 21.0——哈希命中即复用,内容寻址副本不再重拷) | 首登 19.3s(阶段 3)的重哈希路径,符合内容寻址预期 |
|
||||
| import | **51,123.8ms** | 阶段 3 基线 51,768.0ms(+0.7% 机噪,同量级) |
|
||||
| publish(dry-run + confirm) | 首跑 192 发布;复跑 dry-run 87ms + confirm 77ms(upToDate=192 幂等) | — |
|
||||
| build 总 | **11,885.4ms** | 分相 marks:snapshot=450ms / building=11,240ms / validating+report=183ms |
|
||||
| └ astro 纯构建探针 | 冻结输入重跑三次 4,567 / 5,650 / 7,486ms | building 相与探针差 ≈ 媒体复制 836 文件(~164.5MiB)+ 主题运行器编排(快照装载/产物整理),量级自洽 |
|
||||
| 全量冒烟(190 html + 1746 redirect) | **1,225ms** | keep-alive 并发 8,quiet 服务器 |
|
||||
| 复跑链(幂等命中) | register 1.8s + import 复用 94ms + publish 164ms + build 复用(不重跑,读原报告) | 断言二次全过(21/21) |
|
||||
|
||||
分钟级预算内:全链首跑 ~64s(含发布);验收复跑 ~15s。
|
||||
|
||||
## 5. 观察项兑现(验收内修补与判定)
|
||||
|
||||
### 5.1 主题分发观察项:**未触发**
|
||||
|
||||
计划预告的三类分发观察项在真实 190 路由集上均未出现,无需补主题规则:
|
||||
|
||||
- `/news/` 兄弟特例:无冲突(`/news/` 列表行唯一);
|
||||
- 嵌套分类前缀:数据集 product_cat 无嵌套(10 个分类全一级);
|
||||
- 裸 `/product-category/` 前缀:10 个归档页均有 term 且有产物(`hdpe-pipe-fittings` 14 产品为最大者);
|
||||
- `/products/` 走 basic_page 内容页(阶段 3 既有 exact 冲突事实的延续,generic 模板正常渲染)。
|
||||
|
||||
### 5.2 预览服务器修补一:Location 头 ASCII 化(真实缺陷,HTTP 级全量校验显影)
|
||||
|
||||
⑦b 首轮 1739/1746:manifest location 含非 ASCII 码位(全角括号`(1)`、间隔号`·`)时,`res.setHeader('Location', 原串)` 触发 Node `ERR_INVALID_CHAR` 崩 500(2 例)或被按 latin1 写出 mojibake `·`(3 例)。修补:`apps/preview-server/server.mjs` 新增 `asciiLocation()`——非 ASCII 码位按 UTF-8 百分号编码(与 WP/nginx 形态一致,大写 hex),控制字符同路编码(头注入面封死);manifest 原串保持权威,HTML 迁移页内嵌转义原文。断言侧比较改用同规则镜像(`asciiLocationMirror`),预期口径更新为"manifest 原串的 RFC 9110 头域 ASCII 派生形"。回归测试:`tests/integration/preview-server.test.ts` 测试 2b(非 ASCII redirect 行 → 301 + 编码 Location + 迁移页原文)。修后 1746/1746。
|
||||
|
||||
### 5.3 预览服务器修补二:CSP 放行内联样式(浏览器时刻显影,HTTP 级不可见)
|
||||
|
||||
截图会话首开时页面在浏览器里裸奔(无样式)——Astro 5 把 scoped 样式内联为 `<style>` 元素,`default-src 'self'` 拦掉全部内联样式(控制台 CSP violation)。HTTP 级断言看不到此面(样式不在头域语义内)。修补:CSP 常量改为 `default-src 'self'; style-src 'self' 'unsafe-inline'`(仅样式面放开;脚本/连接/图片仍走 default-src)。回归测试:测试 6 断言更新(/ 与 404 双路径)。修后 6 截图全部样式生效。
|
||||
|
||||
### 5.4 不修项(如实记录)
|
||||
|
||||
- `/favicon.ico` 404:源数据无 favicon、路由表无行,预览 404 是正确行为;控制台一条装饰性噪声。
|
||||
- 集成测试复跑:`pnpm vitest run tests/integration/preview-server.test.ts` 14/14 过(含 2b/6),与链路互斥执行。
|
||||
|
||||
## 6. 差异分析(如实记录,无硬凑)
|
||||
|
||||
1. **编码 html 路由仅 1 条(vs 计划假设的编码内容页更多)**:数据集 ~54 条含中文/全角的 permalink 全部是 attachment 降级 redirect 行(媒体文件目标),有内容的编码路由只有 `/how-to-choose-pe-pipe%ef%bc%9f/` 一条——①b 取样即为该条,编码链路(routes.json → dist 文件名 → manifest → 预览匹配逐字节对齐)在其身上完整验证;⑦a 190 行全 200 亦覆盖全部编码形态。
|
||||
2. **urlRewrite unresolved=317 非死链**:该计数是"body 内 src/href 有可比对路径但未命中 ready 媒体索引"的引用数——主体是**站内页面链接**(`<a href="https://hesupipe.com/…">`,非媒体、本就不在改写索引内,保持原样正确)+ 外站热链(cn-hdpe.com 等 19+)+ 已知缺失媒体 19 与 WP 尺寸变体 2(阶段 3 §5.2/5.3 同源)。门禁 ①internal-links 对根相对链接全量校验 0 死链(p0=0);dist 实测 460 个根相对图片引用全部命中产物文件。
|
||||
3. **build 复用路径的 durationMs**:复用命中返回的是原 job 行的 durationMs(11,875.1ms,非本次调用墙钟)——服务口径"未重跑、未改行",产物/计数读原 job 报告,断言不受影响。
|
||||
4. ** astro 探针三次波动 4.6-7.5s**:同冻结输入,机噪区间;取区间而非单点呈报。
|
||||
|
||||
## 7. 清理与保留声明
|
||||
|
||||
- **serve 会话与临时库**:截图会话(端口 4571)已停;`docker exec sf-postgres psql -U siteforge -d postgres -c "DROP DATABASE sf_phase4_hesupipe WITH (FORCE);"` 验后执行。本项目开发库 `siteforge` 全程零写入(所有命令经 `DATABASE_URL_TEST` 指临时库)。
|
||||
- **保留**:`storage/incoming/` 副本(阶段 3 已声明,约 345MB,gitignore);`artifacts/jobs/619c440c…/`(dist + route-manifest + 报告,artifacts/ 不入库,本地留档);`docs/evidence/screenshots/*.png` ×6 入库。
|
||||
- cms 源侧(`D:\project\cms`)本任务零访问(输入全用阶段 3 副本)。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1020 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 410 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 713 KiB |
@@ -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
|
||||
}
|
||||
@@ -182,7 +182,21 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', ()
|
||||
status: 410,
|
||||
indexable: false,
|
||||
}
|
||||
const extended: RouteManifest = { ...manifest!, routes: [...manifest!.routes, ...probeRows, goneRow] }
|
||||
// Task 8 实证形态:redirect location 含非 ASCII(全角括号/间隔号——真实 hesupipe
|
||||
// 1746 redirect 全量校验发现原样发头 ERR_INVALID_CHAR 崩 500 / latin1 乱码)
|
||||
const nonAsciiRedirectRow: RouteManifestRoute = {
|
||||
exact_key: '/nonascii-redirect/',
|
||||
query_policy: 'ignore',
|
||||
query_key: null,
|
||||
kind: 'redirect',
|
||||
status: 301,
|
||||
indexable: false,
|
||||
location: '/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg',
|
||||
}
|
||||
const extended: RouteManifest = {
|
||||
...manifest!,
|
||||
routes: [...manifest!.routes, ...probeRows, goneRow, nonAsciiRedirectRow],
|
||||
}
|
||||
|
||||
preview = createPreviewServer({ distDir, routeManifest: extended, host: '127.0.0.1', port: 0 })
|
||||
const addr = await preview.start()
|
||||
@@ -228,6 +242,19 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', ()
|
||||
expect(res.headers.get('location')).toBe('/wp-content/uploads/2026/10/fixture-a.png')
|
||||
})
|
||||
|
||||
it('2b. redirect location 含非 ASCII:Location 头按 UTF-8 百分号编码发(不崩 500/不乱码);迁移页内嵌原串', async () => {
|
||||
const res = await fetch(new URL('/nonascii-redirect/', `http://127.0.0.1:${port}/`), {
|
||||
redirect: 'manual',
|
||||
})
|
||||
expect(res.status).toBe(301)
|
||||
// 全角( U+FF08 → %EF%BC%88 / )U+FF09 → %EF%BC%89 / · U+00B7 → %C2%B7
|
||||
expect(res.headers.get('location')).toBe(
|
||||
'/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg',
|
||||
)
|
||||
const text = await res.text()
|
||||
expect(text).toContain('/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg')
|
||||
})
|
||||
|
||||
it('3. asset 行 200 + MIME:png 真实行 content_type;jpg/webp/pdf 扩展名映射;未知扩展 octet-stream', async () => {
|
||||
const png = await get('/wp-content/uploads/2026/10/fixture-a.png')
|
||||
expect(png.status).toBe(200)
|
||||
@@ -266,13 +293,17 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', ()
|
||||
expect(res.text.length).toBeLessThan(1024)
|
||||
})
|
||||
|
||||
it('6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应 CSP default-src self(asset 无 CSP)', async () => {
|
||||
it('6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应 CSP(style-src 放开内联——Astro scoped 样式;asset 无 CSP)', async () => {
|
||||
for (const p of ['/', '/definitely-not-a-route/', '/gone-page/', '/wp-content/uploads/2026/10/fixture-a.png']) {
|
||||
const res = await get(p)
|
||||
expect(res.headers.get('x-robots-tag'), p).toBe('noindex, nofollow')
|
||||
}
|
||||
expect((await get('/')).headers.get('content-security-policy')).toBe("default-src 'self'")
|
||||
expect((await get('/definitely-not-a-route/')).headers.get('content-security-policy')).toBe("default-src 'self'")
|
||||
expect((await get('/')).headers.get('content-security-policy')).toBe(
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'",
|
||||
)
|
||||
expect((await get('/definitely-not-a-route/')).headers.get('content-security-policy')).toBe(
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'",
|
||||
)
|
||||
expect((await get('/wp-content/uploads/2026/10/fixture-a.png')).headers.get('content-security-policy')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user