// Task 4 构建级用例:六页夹具(真实 hesupipe 抽样形状脱敏化)→ astro build 真跑 → dist/ 断言。 // 与 Container API 组件测试(components.test.ts)分层:此处验证的是路由分派 + 模板组装 + // 产物映射(尾斜杠→/index.html、.html 后缀→字面文件、百分号编码路径→编码原样目录) // 的整链,不只是单组件渲染。 import { execFileSync } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { RouteEntry } from '../src/lib/load-render-data.js' const THEME_ROOT = fileURLToPath(new URL('..', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./fixtures/render-input', import.meta.url)) const ASTRO_ENTRY = fileURLToPath(new URL('../node_modules/astro/astro.js', import.meta.url)) const ORIGIN = 'https://demo.example' const ENCODED = '%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac' /** 路由 → dist 相对文件路径(Task 5 管线同款映射规则:target_file 形态是唯一权威) */ function distRel(routePath: string): string { if (routePath === '/') return 'index.html' const rel = routePath.replace(/^\//, '').replace(/\/+$/, '') return rel.endsWith('.html') ? rel : `${rel}/index.html` } let dist = '' function page(routePath: string): string { return readFileSync(join(dist, distRel(routePath)), 'utf8') } beforeAll(() => { dist = mkdtempSync(join(tmpdir(), 'sf-render-cases-')) execFileSync(process.execPath, [ASTRO_ENTRY, 'build', '--outDir', dist], { cwd: THEME_ROOT, env: { ...process.env, SF_BUILD_INPUT: FIXTURE }, stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, maxBuffer: 16 * 1024 * 1024, }) }, 180_000) afterAll(() => { rmSync(dist, { recursive: true, force: true }) }) describe('六类页面构建(astro build × render-input 夹具)', () => { it('全量落盘映射:html 纯路径行 → /index.html(尾斜杠)或字面 .html 文件;redirect 不产页;404.html 存在', () => { const routes = JSON.parse( readFileSync(join(FIXTURE, 'routes.json'), 'utf8'), ) as RouteEntry[] const htmlRoutes = routes.filter((r) => r.routeKind === 'html' && r.queryKey === null) expect(htmlRoutes).toHaveLength(14) for (const r of htmlRoutes) { const rel = distRel(r.routePath) const f = join(dist, rel) // isFile 而非 exists:.html 字面路径若被 Astro 折成同名目录,existsSync 仍真——形态必须钉死 expect(statSync(f).isFile(), `${r.routePath} → ${rel}(须为文件)`).toBe(true) } // redirect 行不产页(301 的处置在 preview/route-manifest 域,不在静态产物域) expect(existsSync(join(dist, 'old-path'))).toBe(false) expect(existsSync(join(dist, 'old-path', 'index.html'))).toBe(false) // 404 独立静态页(Astro 特例 → 根级 404.html) expect(statSync(join(dist, '404.html')).isFile()).toBe(true) expect(page('/')).toContain('demo-site') // dist 根无内容层 stub(astro 5.18.2 无条件发射——钩子归属清理;dist 树=target_file 面) expect(readdirSync(dist)).not.toContain('content-assets.mjs') expect(readdirSync(dist)).not.toContain('content-modules.mjs') }) it(`编码路径目录原样落盘:dist/${ENCODED}/ 字节不变(前置事实 7——禁 percent-decode)`, () => { const roots = readdirSync(dist) expect(roots).toContain(ENCODED) expect(roots).not.toContain('关于我们') // 无解码形态孪生目录 const html = page(`/${ENCODED}/`) expect(html).toContain('关于我们') expect(html).toContain('关于我们页面正文。') // canonical 拼接保持编码形态(canonicalUrl 只做 origin + path 原样连接) expect(html).toContain(`href="${ORIGIN}/${ENCODED}/"`) }) it('首页:site name hero + 最新 3 新闻卡 + 最新 3 产品卡(第 4 条最旧被截断)+ 内链 routeHrefFor + 图片根相对', () => { const html = page('/') expect(html.match(/]/g)).toHaveLength(1) // 恰一 h1(门禁③纪律) expect(html).toContain('demo-site') // 最新 3 新闻(postDate 降序:a 2026-10-01 / b 09-20 / c 08-15;d 07-01 被截断) expect(html).toContain('Fixture News A') expect(html).toContain('Fixture News B') expect(html).toContain('Fixture News C') expect(html).not.toContain('Fixture News D') // 最新 3 产品(old 2025-12-01 被截断) expect(html).toContain('Fixture Product') expect(html).toContain('Fixture Product B') expect(html).toContain('Fixture Product C') expect(html).not.toContain('Fixture Product Old') // 内链用路由表真值(routeHrefFor——产品卡可点到详情页) expect(html).toContain('href="/product/fixture-product/"') expect(html).toContain('href="/fixture-news-b/"') // 封面图 src = media 块引用的 public_path(根相对原样) expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"') }) it('产品详情:h1 标题 + 参数表键值 + 相册三图(featured + gallery[0..1] 根相对)+ 正文;extras.phpRaw 不渲染', () => { const html = page('/product/fixture-product/') expect(html).toContain('Fixture Product') expect(html).toContain('产品说明。') // 参数表(data.specifications 纯对象转表) expect(html).toContain('specifications') expect(html).toContain('材质') expect(html).toContain('不锈钢') expect(html).toContain('DN400') // 相册:featured 首图 + gallery 按序号,src 根相对 expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"') expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-b.jpg"') expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-c.jpg"') // extras.phpRaw 明确忽略 expect(html).not.toContain('a:1:{') }) it('新闻详情(.html 字面路径):dist/news/fixture-news-a.html 是文件而非目录;日期/正文/根相对图片/canonical', () => { const f = join(dist, 'news', 'fixture-news-a.html') expect(statSync(f).isFile()).toBe(true) const html = readFileSync(f, 'utf8') expect(html).toContain('Fixture News A') expect(html).toMatch(/]*datetime="2026-10-01 09:00:00"/) expect(html).toContain('正文引用图片') expect(html).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"') expect(html).toContain(`href="${ORIGIN}/news/fixture-news-a.html"`) }) it('新闻列表 /news/:News 标题 + 全部 4 条新闻卡(分页 V1 不做——单页全列)', () => { const html = page('/news/') expect(html).toContain('News') expect(html).toContain('href="/news/fixture-news-a.html"') expect(html).toContain('href="/fixture-news-b/"') expect(html).toContain('Fixture News C') expect(html).toContain('Fixture News D') // 列表全列,不截断 }) it('分类列表 /product-category/pipe-fittings/:面包屑(首页 › 当前分类)+ term 名标题 + 4 产品卡可点', () => { const html = page('/product-category/pipe-fittings/') expect(html).toContain('aria-label="面包屑"') expect(html).toContain('href="/"') expect(html).toContain('aria-current="page"') expect(html).toContain('Pipe Fittings') // term.contentEntryIds 反查 contents——4 产品全卡片(含首页被截断的 old) expect(html).toContain('href="/product/fixture-product/"') expect(html).toContain('href="/product/fixture-product-b/"') expect(html).toContain('href="/product/fixture-product-c/"') expect(html).toContain('href="/product/fixture-product-old/"') expect(html).toContain('Fixture Product Old') }) it('SEO 基线:六页集 + /products/ 全部有 title / meta description / canonical(origin+自身路径)', () => { const cases: Array<[string, string, string]> = [ ['/', 'demo-site', `${ORIGIN}/`], ['/about-us/', 'About Us', `${ORIGIN}/about-us/`], [`/${ENCODED}/`, '关于我们', `${ORIGIN}/${ENCODED}/`], ['/product-category/pipe-fittings/', 'Pipe Fittings', `${ORIGIN}/product-category/pipe-fittings/`], ['/product/fixture-product/', 'Fixture Product', `${ORIGIN}/product/fixture-product/`], ['/news/', 'News', `${ORIGIN}/news/`], ['/news/fixture-news-a.html', 'Fixture News A', `${ORIGIN}/news/fixture-news-a.html`], // 前置事实:/products/ 被 WP "Products" 内容页占据——落 GenericPage(非列表壳) ['/products/', 'Products', `${ORIGIN}/products/`], ] for (const [routePath, title, canonical] of cases) { const html = page(routePath) expect(html, routePath).toContain(`${title}`) expect(html, routePath).toMatch(/`) } // 内容页 description 优先取修订 seo.description(yoast 面) expect(page('/about-us/')).toContain('About us fixture page description.') expect(page('/news/fixture-news-a.html')).toContain('Fixture news A meta description.') }) })