fix(preview+pipeline): 资产键编码归一——7 张全角名图片预览 404 修复;证据文档叙述修正
spec 复审发现(浏览器可见缺陷):manifest 资产行 exact_key 有 7 例为裸形 (文件名含全角括号/顿号/间隔号)——html/redirect 键源数据侧已编码、asset 键来自文件名本体是裸形;浏览器对裸非 ASCII 一律百分号编码后发请求,预览 按请求字节逐字匹配 → 裸键永不命中 → 两页 3 张图破图(dist 文件在盘; 生产面 nginx 先解码不受影响)。修补 pipeline.toManifestRoute:exact_key 归一为 URL 线形(可打印 ASCII 原样、其余 UTF-8 百分号编码大写 hex), target_file 仍盘上裸名、DB/渲染快照域不变。 回归三层:build-pipeline 注入裸全角资产行(红验证);preview-server 2c 非 ASCII redirect → Location → 资产终端 200;e2e 断言 ④c(7 条全查 跟随到终端 200)+ ⑦c(manifest 2772 键全 ASCII 不变量)。spec 复审复跑 (新库全链 job e059bf41)23/23 PASS,浏览器实测 3 张原破图全部加载。 证据文档:§5.2 失败计数修正(5×500+2×mojibake=7 例)、§2 ①c 措辞对齐 断言实义(抽前 3 标题)、§5.5 修补记录、计时/产物/job 面更新至复跑值。 验证:vitest 382 + 主题 26 全绿(408);typecheck 过;lint 裸跑零告警。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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/',
|
||||
|
||||
Reference in New Issue
Block a user