From 236f3dc4d3cc97b47eb5124d6fee2d1c5b883a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 22 Oct 2026 15:31:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(preview+pipeline):=20=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E9=94=AE=E7=BC=96=E7=A0=81=E5=BD=92=E4=B8=80=E2=80=94=E2=80=94?= =?UTF-8?q?7=20=E5=BC=A0=E5=85=A8=E8=A7=92=E5=90=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E9=A2=84=E8=A7=88=20404=20=E4=BF=AE=E5=A4=8D=EF=BC=9B=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E6=96=87=E6=A1=A3=E5=8F=99=E8=BF=B0=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec 复审发现(浏览器可见缺陷):manifest 资产行 exact_key 有 7 例为裸形 (文件名含全角括号/顿号/间隔号)——html/redirect 键源数据侧已编码、asset 键来自文件名本体是裸形;浏览器对裸非 ASCII 一律百分号编码后发请求,预览 按请求字节逐字匹配 → 裸键永不命中 → 两页 3 张图破图(dist 文件在盘; 生产面 nginx 先解码不受影响)。修补 pipeline.toManifestRoute:exact_key 归一为 URL 线形(可打印 ASCII 原样、其余 UTF-8 百分号编码大写 hex), target_file 仍盘上裸名、DB/渲染快照域不变。 回归三层:build-pipeline 注入裸全角资产行(红验证);preview-server 2c 非 ASCII redirect → Location → 资产终端 200;e2e 断言 ④c(7 条全查 跟随到终端 200)+ ⑦c(manifest 2772 键全 ASCII 不变量)。spec 复审复跑 (新库全链 job e059bf41)23/23 PASS,浏览器实测 3 张原破图全部加载。 证据文档:§5.2 失败计数修正(5×500+2×mojibake=7 例)、§2 ①c 措辞对齐 断言实义(抽前 3 标题)、§5.5 修补记录、计时/产物/job 面更新至复跑值。 验证:vitest 382 + 主题 26 全绿(408);typecheck 过;lint 裸跑零告警。 Co-Authored-By: Claude Code --- apps/api/src/modules/build/pipeline.ts | 21 +++- docs/evidence/phase4-assertions.json | 154 +++++++++++++++++------ docs/evidence/phase4-preview.md | 58 +++++---- scripts/e2e-real.mjs | 33 ++++- tests/integration/build-pipeline.test.ts | 73 +++++++++-- tests/integration/preview-server.test.ts | 37 +++++- 6 files changed, 306 insertions(+), 70 deletions(-) diff --git a/apps/api/src/modules/build/pipeline.ts b/apps/api/src/modules/build/pipeline.ts index 1c0b777..0598bbe 100644 --- a/apps/api/src/modules/build/pipeline.ts +++ b/apps/api/src/modules/build/pipeline.ts @@ -344,6 +344,25 @@ function buildRobots(origin: string): string { return `User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n` } +/** + * exact_key 线形归一(URL wire form):可打印 ASCII(含既有 %xx 序列的字面 %)原样, + * 其余码位按 UTF-8 百分号编码(大写 hex——WHATWG URL 解析器/浏览器的编码形态;空格 + * 同路编码为 %20)。Task 8 spec 复审修补:asset 路由的 routePath 来自文件名本体(真实 + * hesupipe 7 例含全角括号/顿号/间隔号),html/redirect 键在源数据侧已是编码形、asset + * 键是裸形——预览按请求字节逐字匹配(浏览器对裸非 ASCII 一律百分号编码后发送),裸键 + * 永不命中 → 图片 404、redirect Location 跟随断裂。归一只动 manifest 键(服务权威面); + * target_file 仍是盘上文件名(裸形——文件系统域不解码),route_registry/渲染快照域 + * 不变(DB 键保持导入原串)。 + */ +function wireKeyOf(key: string): string { + let out = '' + for (const ch of key) { + const code = ch.codePointAt(0)! + out += code > 0x20 && code <= 0x7e ? ch : encodeURIComponent(ch) + } + return out +} + /** route-manifest 行映射:exact_key 带查询形态;target_file 派生映射;redirect 带 location */ function toManifestRoute(r: RouteEntry, assetsByPath: Map): RouteManifestRoute { // redirect 行 redirectStatus 是权威(targetStatus 同值兜底——数据面两处冗余) @@ -355,7 +374,7 @@ function toManifestRoute(r: RouteEntry, assetsByPath: Map): ) } const row: RouteManifestRoute = { - exact_key: r.queryKey === null ? r.routePath : `${r.routePath}?${r.queryKey}`, + exact_key: wireKeyOf(r.queryKey === null ? r.routePath : `${r.routePath}?${r.queryKey}`), query_policy: r.queryPolicy, query_key: r.queryKey, kind: r.routeKind, diff --git a/docs/evidence/phase4-assertions.json b/docs/evidence/phase4-assertions.json index 6acf3a8..5ec362f 100644 --- a/docs/evidence/phase4-assertions.json +++ b/docs/evidence/phase4-assertions.json @@ -1,6 +1,6 @@ { "verdict": "PASS", - "generatedAt": "2026-10-22T06:21:14.951Z", + "generatedAt": "2026-10-22T07:20:15.759Z", "env": { "node": "v24.13.0", "platform": "win32", @@ -10,55 +10,58 @@ }, "chain": { "register": { - "runId": "20261022T142105-hiapw6", - "siteId": "599b435b-0778-4676-8a95-b134546a1eb8", - "siteCreated": false, - "connectionId": "3c6b9a48-0426-4f01-b822-56c20b5e8208", + "runId": "20261022T151903-lhppmd", + "siteId": "763a6544-dcdd-43c8-b1a9-11769f4b720a", + "siteCreated": true, + "connectionId": "05a9f6a7-cf67-4f0f-ac11-a4899378bd08", "snapshots": [ { "kind": "wxr", "sha256": "f9bc5d4497a1", "byteSize": 5490399, - "reused": true, + "reused": false, "copied": false, - "durationMs": 15.189599999999928 + "durationMs": 16.857200000000034 }, { "kind": "media_bundle", "sha256": "8b674261c104", "byteSize": 172532000, - "reused": true, + "reused": false, "copied": false, - "durationMs": 1288.2132000000001 + "durationMs": 2684.0553999999997 }, { "kind": "manual_url_list", "sha256": "c935d444f52b", "byteSize": 143945, - "reused": true, + "reused": false, "copied": false, - "durationMs": 20.976500000000215 + "durationMs": 24.289099999999962 } ] }, "import": { - "jobId": "9d82df35-5c8a-4345-8156-3319c7ac9d67", + "jobId": "c7b4fd9d-61d6-46d5-8022-86259a1675ac", "status": "succeeded_with_issues", - "reused": true, - "reportPath": "D:\\project\\cms-one\\artifacts\\import\\9d82df35-5c8a-4345-8156-3319c7ac9d67\\reports\\import.json" + "reused": false, + "reportPath": "D:\\project\\cms-one\\artifacts\\import\\c7b4fd9d-61d6-46d5-8022-86259a1675ac\\reports\\import.json" }, "publish": { - "dryRunCandidates": 0, - "publishedCount": 0, + "dryRunCandidates": 192, + "publishedCount": 192, "upToDateCount": 192, - "siteConfigRevision": null + "siteConfigRevision": { + "created": true, + "version": 1 + } }, "build": { - "jobId": "619c440c-f579-4647-99dc-2840dcd36f19", + "jobId": "e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e", "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", + "reused": false, + "inputDir": "D:\\project\\cms-one\\artifacts\\jobs\\e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e\\input", + "reportPath": "D:\\project\\cms-one\\artifacts\\jobs\\e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e\\reports\\build.json", "counts": { "contents": 192, "taxonomies": 6, @@ -71,14 +74,23 @@ }, "issues": [], "artifacts": { - "distPath": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\dist", + "distPath": "D:\\project\\cms-one\\artifacts\\jobs\\e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e\\dist", + "distSha256": "b97ecbdaa37de096a5b780b9b3b2c889e9156a1c2bb06457cff4686436aa6276", + "distByteSize": 122929197, "distFileCount": 1029, - "distSha256": "6883e70a1ae31f1f5244fef3315bd57a6c46eb08f078ac84d99373f21c621832", - "routeManifestPath": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\route-manifest.json", + "routeManifestPath": "D:\\project\\cms-one\\artifacts\\jobs\\e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e\\route-manifest.json", + "routeManifestSha256": "5b11f7f11da66dff6dcfac97bc0324178e8ed146227dc5b3df90c2cd49f2486d", + "routeManifestByteSize": 812072, "sitemapEntries": 190, "mediaCopied": 836 }, - "marks": [] + "marks": [ + "queued", + "snapshotting", + "building", + "validating", + "succeeded" + ] } }, "sqlCounts": { @@ -116,14 +128,17 @@ ] }, "timings": { - "registerWallMs": 1771, - "registerServiceMs": 1750, - "importMs": 94, - "publishDryRunMs": 87, - "publishConfirmMs": 77, - "buildTotalMs": 11875, - "astroProbeMs": 7486, - "smokeMs": 1225 + "registerWallMs": 2886, + "registerServiceMs": 2874, + "importMs": 57199, + "publishDryRunMs": 6, + "publishConfirmMs": 17, + "buildTotalMs": 9498, + "buildSnapshotMs": 431, + "buildBuildingMs": 8880, + "buildValidatingReportMs": 174, + "astroProbeMs": 4865, + "smokeMs": 285 }, "assertions": [ { @@ -196,6 +211,13 @@ "pass": true, "note": "" }, + { + "id": "④c 非 ASCII redirect → 资产终端(7 条全查)", + "expect": "301 → 跟随 Location 编码形 → 终端 200 + 图片 MIME(exact_key 编码线形命中)", + "actual": "[{\"request\":\"/?attachment_id=2835\",\"location\":\"/wp-content/uploads/2024/01/PE-water-supply-pipe-engineering-drawing-of-Wuxi-Wuai-Primary-School-%EF%BC%881%EF%BC%89.jpg\",\"hopStatus\":301,\"terminalStatus\":200,\"terminalContentType\":\"image/jpeg\"},{\"request\":\"/?attachment_id=3125\",\"location\":\"/wp-content/uploads/2024/03/Papua-New-Guinea-Water-Supply-Project-PE-Water-Supply-Pipe%EF%BC%88hesu%EF%BC%89.png\",\"hopStatus\":301,\"terminalStatus\":200,\"terminalContentType\":\"image/png\"},{\"request\":\"/changqing%c2%b7lixiang-cultural-tourism-street-project-steel-mesh-skeleton-pipe-1/\",\"location\":\"/wp-content/uploads/2024/04/Changqing%C2%B7Lixiang-Cultural-Tourism-Street-Project-Steel-Mesh-Skeleton-Pipe-1.png\",\"hopStatus\":301,\"terminalStatus\":200,\"terminalContentType\":\"image/png\"},{\"request\":\"/changqing%c2%b7lixiang-cultural-tourism-street-project-steel-mesh-skeleton-pipe/\",\"location\":\"/wp-content/uploads/2024/04/Changqing%C2%B7Lixiang-Cultural-Tourism-Street-Project-Steel-Mesh-Skeleton-Pipe.jpg\",\"hopStatus\":301,\"terminalStatus\":200,\"terminalContentType\":\"image/jpeg\"},{\"request\":\"/hdpe-carat-pipe/hdpe-carat-pipe-%ef%bc%881%ef%bc%89/\",\"location\":\"/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89.jpg\",\"hopStatus\":301,\"terminalStatus\":200,\"terminalContentType\":\"image/jpeg\"},{\"request\":\"/product/steel-belt-reinforced-pe-corrugated-pipe/steel-belt-reinforced-pe-corrugated-pipe%e3%80%81-4/\",\"location\":\"/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe%E3%80%81-4.jpg\",\"hopStatus\":301,\"terminalStatus\":200,\"terminalContentType\":\"image/jpeg\"},{\"request\":\"/product/steel-belt-reinforced-pe-corrugated-pipe/steel-belt-reinforced-pe-corrugated-pipe%e3%80%81-5/\",\"location\":\"/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe%E3%80%81-5.jpg\",\"hopStatus\":301,\"terminalStatus\":200,\"terminalContentType\":\"image/jpeg\"}]", + "pass": true, + "note": "" + }, { "id": "⑤sitemap 条目数", "expect": "= manifest indexable html 数(190)", @@ -224,10 +246,17 @@ "pass": true, "note": "" }, + { + "id": "⑦manifest 键线形", + "expect": "全 2772 键纯 ASCII(编码线形——裸非 ASCII 键 = 预览永不命中)", + "actual": "nonAsciiKeys=0", + "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=[]", + "actual": "job=succeeded runs=[{\"id\":\"6459e0df-dd53-4158-b300-7c610100eeef\",\"status\":\"PASS\",\"rule_version\":\"min-gates-v1\"}] issues=[]", "pass": true, "note": "" }, @@ -536,6 +565,57 @@ "locationMatch": true } ], + "a4c": [ + { + "request": "/?attachment_id=2835", + "location": "/wp-content/uploads/2024/01/PE-water-supply-pipe-engineering-drawing-of-Wuxi-Wuai-Primary-School-%EF%BC%881%EF%BC%89.jpg", + "hopStatus": 301, + "terminalStatus": 200, + "terminalContentType": "image/jpeg" + }, + { + "request": "/?attachment_id=3125", + "location": "/wp-content/uploads/2024/03/Papua-New-Guinea-Water-Supply-Project-PE-Water-Supply-Pipe%EF%BC%88hesu%EF%BC%89.png", + "hopStatus": 301, + "terminalStatus": 200, + "terminalContentType": "image/png" + }, + { + "request": "/changqing%c2%b7lixiang-cultural-tourism-street-project-steel-mesh-skeleton-pipe-1/", + "location": "/wp-content/uploads/2024/04/Changqing%C2%B7Lixiang-Cultural-Tourism-Street-Project-Steel-Mesh-Skeleton-Pipe-1.png", + "hopStatus": 301, + "terminalStatus": 200, + "terminalContentType": "image/png" + }, + { + "request": "/changqing%c2%b7lixiang-cultural-tourism-street-project-steel-mesh-skeleton-pipe/", + "location": "/wp-content/uploads/2024/04/Changqing%C2%B7Lixiang-Cultural-Tourism-Street-Project-Steel-Mesh-Skeleton-Pipe.jpg", + "hopStatus": 301, + "terminalStatus": 200, + "terminalContentType": "image/jpeg" + }, + { + "request": "/hdpe-carat-pipe/hdpe-carat-pipe-%ef%bc%881%ef%bc%89/", + "location": "/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89.jpg", + "hopStatus": 301, + "terminalStatus": 200, + "terminalContentType": "image/jpeg" + }, + { + "request": "/product/steel-belt-reinforced-pe-corrugated-pipe/steel-belt-reinforced-pe-corrugated-pipe%e3%80%81-4/", + "location": "/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe%E3%80%81-4.jpg", + "hopStatus": 301, + "terminalStatus": 200, + "terminalContentType": "image/jpeg" + }, + { + "request": "/product/steel-belt-reinforced-pe-corrugated-pipe/steel-belt-reinforced-pe-corrugated-pipe%e3%80%81-5/", + "location": "/wp-content/uploads/2023/12/Steel-Belt-Reinforced-PE-Corrugated-Pipe%E3%80%81-5.jpg", + "hopStatus": 301, + "terminalStatus": 200, + "terminalContentType": "image/jpeg" + } + ], "smoke": { "htmlTotal": 190, "htmlFailures": 0, @@ -544,14 +624,14 @@ } }, "buildReport": { - "distPath": "D:\\project\\cms-one\\artifacts\\jobs\\619c440c-f579-4647-99dc-2840dcd36f19\\dist", + "distPath": "D:\\project\\cms-one\\artifacts\\jobs\\e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e\\dist", "distFileCount": 1029, "mediaCopied": 836, "sitemapEntries": 190, "validation": { "ruleVersion": "min-gates-v1", "status": "PASS", - "runId": "6dc3776b-7bd5-4199-aa95-2db8e8a5ea41", + "runId": "6459e0df-dd53-4158-b300-7c610100eeef", "p0Count": 0, "p2Count": 0 }, @@ -559,6 +639,6 @@ "rewritten": 386, "unresolved": 317 }, - "durationMs": 11875.096499999992 + "durationMs": 9485.525300000001 } } diff --git a/docs/evidence/phase4-preview.md b/docs/evidence/phase4-preview.md index d9f44b2..20c3ed2 100644 --- a/docs/evidence/phase4-preview.md +++ b/docs/evidence/phase4-preview.md @@ -1,6 +1,6 @@ # 阶段 4 真实 hesupipe 端到端验收证据(Plan 4 · Task 8 —— 浏览器时刻) -**verdict:PASS**(断言总表 21 项脚本断言全过 + 浏览器时刻 6 截图无破版无横向溢出;两处验收内修补(预览服务器 Location 头 ASCII 化、CSP 放行内联样式)均有回归测试锁定,见 §5;观察项未触发主题补规则,见 §5.1) +**verdict:PASS**(断言总表 23 项脚本断言全过 + 浏览器时刻 6 截图无破版无横向溢出;三处验收内修补——预览服务器 Location 头 ASCII 化(§5.2)、CSP 放行内联样式(§5.3)、管线资产键编码归一(§5.5,spec 复审)——均有回归测试锁定;观察项未触发主题补规则,见 §5.1) | 项 | 值 | |---|---| @@ -8,9 +8,9 @@ | 环境 | 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) | +| 全链 job(spec 复审复跑,权威) | import `c7b4fd9d-61d6-46d5-8022-86259a1675ac`(succeeded_with_issues,57,198.6ms)· publish 192 → upToDate · build `e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e`(succeeded,9,497.9ms)· validation run `6459e0df-dd53-4158-b300-7c610100eeef` PASS(min-gates-v1,p0=0 p2=0)。首轮 job:import `9d82df35…`(51,123.8ms)· build `619c440c…`(11,885.4ms,artifacts/ 本地留档) | +| 产物面 | dist 1029 文件(190 html + 836 媒体 + sitemap.xml + robots.txt + 404.html)· route-manifest 2772 行(html 190 / redirect 1746 / asset 836)· dist SHA-256 `b97ecbda…aa6276` | +| 断言明细 | `docs/evidence/phase4-assertions.json`(脚本逐断言落盘,verdict=PASS 23/23) | | 截图 | `docs/evidence/screenshots/phase4-{mobile-375,desktop-1440}-{home,product-detail,news-detail}.png` ×6(§3) | | 编排脚本 | `scripts/e2e-real.mjs`(`chain` 全链+断言 / `serve` 截图会话常驻预览,两个子命令) | @@ -28,7 +28,7 @@ DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_h # 截图会话(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 + pnpm exec tsx scripts/e2e-real.mjs serve --build e059bf41-0ba6-4539-a9b4-6eb8a75c1b6e --port 4571 ``` 互斥纪律(Task 5 备忘):链路执行期间零并发测试/零并发构建(vitest 全套在链路完成后单独跑,见 §5.3);astro 计时探针冻结输入重跑(`SF_BUILD_INPUT` 指向 job input 目录,前后清主题暂存 dist)。 @@ -41,7 +41,7 @@ DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_h |---|---|---|---|---| | ①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 | +| ①c | 产品分类列表 `/product-category/hdpe-pipe-fittings/` | 同上 | 200;term 名在体;term 含 14 产品,断言抽前 3 标题在体 | 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 | @@ -49,12 +49,14 @@ DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_h | ③ | 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 | +| ④c | 非 ASCII redirect → 资产终端(7 条全查) | 301 → 跟随 Location 编码形 → 终端 200 + 图片 MIME | 7/7(如 `/?attachment_id=2835` → `…School-%EF%BC%881%EF%BC%89.jpg` → 200 image/jpeg);§5.5 修补回归 | 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 | +| ⑦c | manifest 键线形 | 全 2772 键纯 ASCII(编码线形) | nonAsciiKeys=0(§5.5 修补不变量——裸非 ASCII 键永不匹配浏览器请求形) | PASS | +| ⑧ | 计时对账 | 分钟级预算内 + 与阶段 3 基线同量级 | 首轮 ~64s(import 51.1s + build 11.9s);spec 复审复跑 ~70s(import 57.2s + build 9.5s);幂等复跑 ~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 | @@ -77,21 +79,22 @@ DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_h | `screenshots/phase4-desktop-1440-news-detail.png` | 1440×900 | 1425 | 标题/日期/正文/头图排版正常 | - 溢出判据:`document.body.scrollWidth <= window.innerWidth`(六组合全 false 无横向溢出;360/1425 与视口的 15px 差是滚动条槽位)。 +- 截图取自首轮 build(`619c440c`)产物;spec 复审复跑(`e059bf41`,§5.5)后浏览器复验过同一会话流程(两页 3 张原破图加载成功,见 §5.5)。 - 控制台仅 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) | +| 阶段 | spec 复审复跑(权威) | 首轮 | 对账 | +|---|---|---|---| +| register(快照副本复用) | 服务内 2,874ms | 1,750ms | 首登 19.3s(阶段 3)的重哈希路径,符合内容寻址预期 | +| import | **57,198.6ms** | **51,123.8ms** | 阶段 3 基线 51,768.0ms——同量级(-1.2% / +10.5%,机噪区间) | +| publish(dry-run + confirm) | 6ms + 17ms(upToDate=192 幂等) | 首跑 192 发布;复跑 87ms + 77ms | — | +| build 总 | **9,497.9ms** | **11,885.4ms** | 分相 marks:snapshot=431 / building=8,880 / validating+report=174(首轮 450 / 11,240 / 183) | +| └ astro 纯构建探针 | 4,865ms | 4,567 / 5,650 / 7,486ms(冻结输入三跑) | building 相与探针差 ≈ 媒体复制 836 文件(~164.5MiB)+ 主题运行器编排,量级自洽 | +| 全量冒烟(190 html + 1746 redirect) | **285ms** | 1,225ms | keep-alive 并发 8,quiet 服务器 | +| 幂等复跑链(同库重提) | — | register 1.8s + import 复用 94ms + publish 164ms + build 复用(不重跑,读原报告) | 断言二次全过(首轮 21/21) | -分钟级预算内:全链首跑 ~64s(含发布);验收复跑 ~15s。 +分钟级预算内:首轮全链 ~64s、spec 复审复跑(新库全量,含 §5.5 修补验证)~70s、幂等复跑 ~15s。 ## 5. 观察项兑现(验收内修补与判定) @@ -106,7 +109,7 @@ DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_h ### 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。 +⑦b 首轮 1739/1746(7 例失败):manifest location 含非 ASCII 码位(全角括号`(1)`、间隔号`·`)时,`res.setHeader('Location', 原串)` 触发 Node `ERR_INVALID_CHAR` 崩 500(5×500)或被按 latin1 写出 mojibake(2×301,`·` → `·`)。修补:`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 级不可见) @@ -115,7 +118,20 @@ DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_h ### 5.4 不修项(如实记录) - `/favicon.ico` 404:源数据无 favicon、路由表无行,预览 404 是正确行为;控制台一条装饰性噪声。 -- 集成测试复跑:`pnpm vitest run tests/integration/preview-server.test.ts` 14/14 过(含 2b/6),与链路互斥执行。 +- 集成测试复跑:`pnpm vitest run tests/integration/preview-server.test.ts` 15/15 过(含 2b/2c/6),与链路互斥执行。 + +### 5.5 管线修补三:资产 exact_key 编码归一(spec 复审发现,浏览器可见缺陷) + +**缺陷**:manifest 资产行的 exact_key 有 7 例为裸形(文件名含全角括号/顿号/间隔号,如 `/wp-content/uploads/2024/10/HDPE-carat-pipe-(1).jpg`)——html/redirect 键在源数据侧已是百分号编码形,asset 键来自文件名本体是裸形。浏览器对裸非 ASCII 一律按 WHATWG URL 规则百分号编码(大写 hex)后发请求,预览按请求字节逐字匹配 → 裸键永不命中 → `/hdpe-carat-pipe/` 与 `/product/steel-belt-reinforced-pe-corrugated-pipe/` 共 3 张图破图(dist 文件实际在盘;生产面 nginx 先解码再查文件,不受影响——预览面缺陷)。 + +**修补**(`apps/api/src/modules/build/pipeline.ts` `wireKeyOf()`):manifest 生成(`toManifestRoute`)对 exact_key 归一为 URL 线形——可打印 ASCII(含既有 %xx 序列的字面 `%`)原样,其余码位按 UTF-8 百分号编码(大写 hex,空格同路编码)——与 §5.2 `asciiLocation` 同族规则、与 URL 解析器/浏览器编码形态逐字节一致(首轮 manifest 2772 键实测仅这 7 键异形)。**归一只动 manifest 键**(服务权威面):`target_file` 仍是盘上裸文件名(文件系统域不解码),route_registry/渲染快照/DB 键保持导入原串。 + +**回归锁定**(三层): +1. `tests/integration/build-pipeline.test.ts`(主题夹具直跑):注入裸全角名资产行 → manifest exact_key = `%EF%BC%881%EF%BC%89` 编码形 + target_file 裸形 + 全键纯 ASCII 不变量(红验证:修补前该用例失败); +2. `tests/integration/preview-server.test.ts` 测试 2c:非 ASCII redirect → Location 编码形 → 资产行命中 → 终端 200(+裸形直接请求亦命中); +3. `scripts/e2e-real.mjs` 验收断言 ④c(7 条非 ASCII redirect 全查:301 → 跟随 → 终端 200 image/*)+ ⑦c(manifest 2772 键全 ASCII 不变量)。 + +**复验**(spec 复审复跑,新库全链 job `e059bf41`):断言 23/23 PASS;manifest 7 资产键全部编码形(`nonAsciiKeys=0`);Playwright 浏览器实测两页 3 张原破图全部加载成功(img.complete=true,naturalWidth 750/800/800),控制台仅余 favicon 404 装饰性噪声。 ## 6. 差异分析(如实记录,无硬凑) @@ -126,6 +142,6 @@ DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_h ## 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 入库。 +- **serve 会话与临时库**:截图/复验会话(端口 4571)已停;`docker exec sf-postgres psql -U siteforge -d postgres -c "DROP DATABASE sf_phase4_hesupipe WITH (FORCE);"` 验后执行(spec 复审复跑后二次 DROP)。本项目开发库 `siteforge` 全程零写入(所有命令经 `DATABASE_URL_TEST` 指临时库)。 +- **保留**:`storage/incoming/` 副本(阶段 3 已声明,约 345MB,gitignore);`artifacts/jobs/619c440c…/` 与 `artifacts/jobs/e059bf41…/`(dist + route-manifest + 报告,artifacts/ 不入库,本地留档);`docs/evidence/screenshots/*.png` ×6 入库。 - cms 源侧(`D:\project\cms`)本任务零访问(输入全用阶段 3 副本)。 diff --git a/scripts/e2e-real.mjs b/scripts/e2e-real.mjs index d2bad43..c468e81 100644 --- a/scripts/e2e-real.mjs +++ b/scripts/e2e-real.mjs @@ -608,6 +608,28 @@ async function cmdChain(opts) { JSON.stringify(a4), a4.every((x) => x.status === 301 && x.locationMatch), ) + // spec 复审修补回归:非 ASCII redirect 跟随到资产终端——Location 编码形请求须命中 + // manifest 资产行(exact_key 已归一为同一编码线形)→ 200 + 图片 MIME(浏览器真实路径) + const nonAsciiRedirects = redirectRows.filter((r) => /\P{ASCII}/u.test(r.location ?? '')) + const a4c = [] + for (const row of nonAsciiRedirects) { + const hop = await client.get(row.exact_key) + const terminalKey = asciiLocationMirror(row.location ?? '') + const terminal = await client.get(terminalKey) + a4c.push({ + request: row.exact_key, + location: terminalKey, + hopStatus: hop.status, + terminalStatus: terminal.status, + terminalContentType: terminal.headers['content-type'] ?? null, + }) + } + record( + `④c 非 ASCII redirect → 资产终端(${String(a4c.length)} 条全查)`, + '301 → 跟随 Location 编码形 → 终端 200 + 图片 MIME(exact_key 编码线形命中)', + JSON.stringify(a4c), + a4c.length > 0 && a4c.every((x) => x.hopStatus === 301 && x.terminalStatus === 200 && String(x.terminalContentType).startsWith('image/')), + ) // ===== 断言 ⑤ sitemap/robots(盘读) ===== console.log('[assert] ⑤ sitemap/robots …') @@ -654,6 +676,15 @@ async function cmdChain(opts) { `ok=${String(redirectResults.length - redirectFailures.length)}/${String(redirectRows.length)}${redirectFailures.length === 0 ? '' : ` 首失败=${JSON.stringify(redirectFailures.slice(0, 5))}`}`, redirectFailures.length === 0 && redirectRows.length === 1746, ) + // spec 复审修补不变量:manifest 全键编码线形(纯 ASCII)——裸非 ASCII 键永不匹配 + // 浏览器请求形(浏览器对裸非 ASCII 一律百分号编码后发送) + const nonAsciiKeys = manifest.routes.filter((r) => /\P{ASCII}/u.test(r.exact_key)).map((r) => r.exact_key) + record( + '⑦manifest 键线形', + `全 ${String(manifest.routes.length)} 键纯 ASCII(编码线形——裸非 ASCII 键 = 预览永不命中)`, + nonAsciiKeys.length === 0 ? 'nonAsciiKeys=0' : `nonAsciiKeys=${JSON.stringify(nonAsciiKeys.slice(0, 5))}`, + nonAsciiKeys.length === 0, + ) // ===== 断言 ⑨ 门禁全绿(validation_runs PASS) ===== console.log('[assert] ⑨ 门禁 …') @@ -698,7 +729,7 @@ async function cmdChain(opts) { 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 } }, + detail: { a1, a2, a3, a4, a4c, smoke: { htmlTotal: htmlRows.length, htmlFailures: htmlFailures.length, redirectTotal: redirectRows.length, redirectFailures: redirectFailures.length } }, buildReport: { distPath: buildReport.artifacts.distPath, distFileCount: buildReport.artifacts.distFileCount, diff --git a/tests/integration/build-pipeline.test.ts b/tests/integration/build-pipeline.test.ts index cd76313..fa5a6e1 100644 --- a/tests/integration/build-pipeline.test.ts +++ b/tests/integration/build-pipeline.test.ts @@ -244,7 +244,8 @@ describe('构建管线全链(mini 夹具 DB 物化输入)', () => { // 哈希链复用:generated_from = 输入 render-manifest 的 route 快照哈希 expect(manifest.generated_from_route_snapshot_hash).toBe(inputManifest.sourceHashes.route) expect(manifest.routes).toHaveLength(routes.length) - // 序 = routes.json 序(物化排序确定) + // 序 = routes.json 序(物化排序确定);键为线形(可打印 ASCII 原样——mini 夹具 + // 全 ASCII,与非 ASCII 归一规则的同构镜像见"主题夹具直跑"用例的字面断言) expect(manifest.routes.map((r: any) => r.exact_key)).toStrictEqual( routes.map((r: any) => (r.queryKey === null ? r.routePath : `${r.routePath}?${r.queryKey}`)), ) @@ -408,7 +409,7 @@ describe('主题夹具直跑管线(Task 3 render-input 作 SF_BUILD_INPUT)', // 输入 = 主题夹具六文件 + 真媒体注入(copyPath/sha256/byteSize 换真值——夹具占位哈希无对应实体) const inputDir = join(w, 'input') await mkdir(inputDir, { recursive: true }) - for (const name of ['site.json', 'content.json', 'taxonomies.json', 'routes.json', 'render-manifest.json']) { + for (const name of ['site.json', 'content.json', 'taxonomies.json', 'render-manifest.json']) { await copyFile(join(THEME_FIXTURE_INPUT, name), join(inputDir, name)) } const mediaFiles: Array<{ pub: string; buf: Buffer }> = [ @@ -416,27 +417,63 @@ describe('主题夹具直跑管线(Task 3 render-input 作 SF_BUILD_INPUT)', { pub: '/wp-content/uploads/2026/10/fixture-b.jpg', buf: mediaBytes(`fx-b-${rand()}`) }, { pub: '/wp-content/uploads/2026/10/fixture-c.jpg', buf: mediaBytes(`fx-c-${rand()}`) }, ] + // 非 ASCII 资产键回归(Task 8 spec 复审修补):真实 hesupipe 有 7 例文件名含全角 + // 括号/顿号/间隔号的资产——routePath 是裸形(DB/文件系统域),而浏览器对裸非 ASCII + // 一律百分号编码后发请求,manifest exact_key 若保持裸形则永不命中(预览 404、 + // redirect Location 跟随断裂)。此处注入裸形资产行,断言 manifest 键归一为编码线形。 + const rawAssetPath = '/wp-content/uploads/2026/10/HDPE-carat-pipe-(1).jpg' + mediaFiles.push({ pub: rawAssetPath, buf: mediaBytes(`fx-fw-${rand()}`) }) + const routes = JSON.parse(await readFile(join(THEME_FIXTURE_INPUT, 'routes.json'), 'utf8')) + routes.push({ + host: '', + routePath: rawAssetPath, + routeKind: 'asset', + queryPolicy: 'ignore', + queryKey: null, + targetStatus: '200', + targetContentId: null, + targetFile: rawAssetPath, + redirectStatus: null, + redirectLocation: null, + indexable: false, + priority: 0, + verificationStatus: 'verified_asset', + }) + await writeFile(join(inputDir, 'routes.json'), `${canonicalJson(routes)}\n`, 'utf8') const binDir = join(w, 'media-bin') await mkdir(binDir, { recursive: true }) const assets = JSON.parse(await readFile(join(THEME_FIXTURE_INPUT, 'assets.json'), 'utf8')) expect(assets).toHaveLength(3) const rewritten: any[] = [] - for (let i = 0; i < assets.length; i++) { + for (let i = 0; i < mediaFiles.length; i++) { const m = mediaFiles[i]! const file = join(binDir, `media-${i}.bin`) await writeFile(file, m.buf) - rewritten.push({ ...assets[i], copyPath: file, sha256: sha256of(m.buf), byteSize: m.buf.byteLength }) + rewritten.push( + i < assets.length + ? { ...assets[i], copyPath: file, sha256: sha256of(m.buf), byteSize: m.buf.byteLength } + : { + publicPath: m.pub, + storagePath: '', + copyPath: file, + sha256: sha256of(m.buf), + detectedMimeType: 'image/jpeg', + byteSize: m.buf.byteLength, + width: null, + height: null, + altText: null, + }, + ) } await writeFile(join(inputDir, 'assets.json'), `${canonicalJson(rewritten)}\n`, 'utf8') const artifactsRoot = join(w, 'artifacts') const result = await runBuildPipeline(db, { jobId, siteId: siteFix.siteId, inputDir, artifactsRoot }) - expect(result.mediaCopied).toBe(3) + expect(result.mediaCopied).toBe(4) expect(result.sitemapEntries).toBe(14) const distDir = join(artifactsRoot, 'jobs', jobId, 'dist') expect(result.distPath).toBe(distDir) - const routes = JSON.parse(await readFile(join(inputDir, 'routes.json'), 'utf8')) // 全量落盘映射:14 html 行 target_file 存在且 isFile(含编码目录与 .html 字面) const htmlRoutes = routes.filter((r: any) => r.routeKind === 'html' && r.queryKey === null) @@ -448,7 +485,8 @@ describe('主题夹具直跑管线(Task 3 render-input 作 SF_BUILD_INPUT)', expect(statSync(join(distDir, encoded, 'index.html')).isFile()).toBe(true) expect(statSync(join(distDir, 'news', 'fixture-news-a.html')).isFile()).toBe(true) - // 3 媒体全量 sha256 校验(非抽检——夹具 3 条全对) + // 4 媒体全量 sha256 校验(非抽检——夹具 3 条 + 注入裸全角名 1 条全对; + // dist 落盘用 public_path 裸形——文件系统域不解码) for (const m of mediaFiles) { expect(sha256of(await readFile(join(distDir, m.pub)))).toBe(sha256of(m.buf)) } @@ -465,13 +503,30 @@ describe('主题夹具直跑管线(Task 3 render-input 作 SF_BUILD_INPUT)', `User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`, ) - // route-manifest:15 行全量(html 14 + redirect 1)+ redirect 字段面 + // route-manifest:16 行全量(html 14 + redirect 1 + asset 1)+ redirect 字段面 const manifest = JSON.parse(await readFile(result.routeManifestPath, 'utf8')) expect(isRouteManifest(manifest)).toBe(true) - expect(manifest.routes).toHaveLength(15) + expect(manifest.routes).toHaveLength(16) expect(manifest.site_id).toBe(siteFix.siteId) expect(manifest.build_job_id).toBe(jobId) expect(manifest.origin).toBe(origin) + // 键线形不变量:全 manifest exact_key 纯 ASCII(裸非 ASCII 键永不匹配浏览器请求形) + expect(manifest.routes.every((r: any) => /^[\x20-\x7e]*$/.test(r.exact_key))).toBe(true) + // 非 ASCII 资产键归一:exact_key = UTF-8 百分号编码线形(大写 hex——WHATWG URL/ + // 浏览器编码形态,既有 %xx 序列原样保留不二次编码);target_file 保持裸形(盘上名) + const nonAsciiAsset = manifest.routes.find( + (r: any) => r.exact_key === '/wp-content/uploads/2026/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89.jpg', + ) + expect(nonAsciiAsset).toMatchObject({ + exact_key: '/wp-content/uploads/2026/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89.jpg', + query_policy: 'ignore', + query_key: null, + kind: 'asset', + target_file: rawAssetPath, + status: 200, + content_type: 'image/jpeg', + indexable: false, + }) const redirect = manifest.routes.find((r: any) => r.exact_key === '/old-path/') expect(redirect).toStrictEqual({ exact_key: '/old-path/', diff --git a/tests/integration/preview-server.test.ts b/tests/integration/preview-server.test.ts index d244353..69098a7 100644 --- a/tests/integration/preview-server.test.ts +++ b/tests/integration/preview-server.test.ts @@ -193,9 +193,26 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () indexable: false, location: '/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg', } + // Task 8 spec 复审修补回归:非 ASCII 文件名资产——盘上/dist 是裸形文件名(文件系统 + // 域不解码),manifest exact_key 须是编码线形(管线生成侧归一——浏览器对裸非 ASCII + // 一律百分号编码后发请求)。与上一行 redirect 构成真实链:跟随 Location 的编码形 + // 请求必须命中本资产行(2c 断言全链 200)。 + const nonAsciiAssetFile = 'wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg' + await mkdir(dirname(join(distDir, nonAsciiAssetFile)), { recursive: true }) + await writeFile(join(distDir, nonAsciiAssetFile), 'nonascii-asset-body\n', 'utf8') + const nonAsciiAssetRow: RouteManifestRoute = { + exact_key: '/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg', + query_policy: 'ignore', + query_key: null, + kind: 'asset', + target_file: `/${nonAsciiAssetFile}`, + status: 200, + content_type: 'image/jpeg', + indexable: false, + } const extended: RouteManifest = { ...manifest!, - routes: [...manifest!.routes, ...probeRows, goneRow, nonAsciiRedirectRow], + routes: [...manifest!.routes, ...probeRows, goneRow, nonAsciiRedirectRow, nonAsciiAssetRow], } preview = createPreviewServer({ distDir, routeManifest: extended, host: '127.0.0.1', port: 0 }) @@ -255,6 +272,24 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () expect(text).toContain('/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg') }) + it('2c. 非 ASCII redirect → Location 编码形 → 资产终端 200(spec 复审修补回归:exact_key 编码线形命中)', async () => { + const res = await fetch(new URL('/nonascii-redirect/', `http://127.0.0.1:${port}/`), { + redirect: 'manual', + }) + expect(res.status).toBe(301) + const location = res.headers.get('location') + expect(location).toBe('/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg') + // 跟随 Location(浏览器/重定向客户端的真实路径——发编码形请求)→ 命中资产行 → 200 + const terminal = await get(location!) + expect(terminal.status).toBe(200) + expect(terminal.headers.get('content-type')).toBe('image/jpeg') + expect(terminal.bytes).toStrictEqual(Buffer.from('nonascii-asset-body\n', 'utf8')) + // 裸形直接请求(未编码客户端形态):URL 解析器编码后同键命中——不可 404 + const rawForm = await get('/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg') + expect(rawForm.status).toBe(200) + expect(rawForm.bytes).toStrictEqual(Buffer.from('nonascii-asset-body\n', 'utf8')) + }) + 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)