Files
novalon-website/scripts/accessibility/crawl-routes.mjs
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

166 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 全站页面清单生成器(`check:axe` 的分母来源)。
//
// 来源:本文件由 `docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs` 平移而来(2026-09-23),口径不变:
// 从 BASE 起 BFS 跟随同源链接,并与 sitemap / 预渲染产物取并集 —— 因为 `src/app/sitemap.ts` 只列
// 「希望被收录」的页面,404 与根错误兜底页天然不在其中(见 docs/lessons-learned.md §5.24)。
//
// 用法(零参数即可跑通,Jenkins 的 ♿♿ 阶段就是这么调的):
// BASE=http://localhost:3100 npm run check:axe:routes
// 环境变量:
// BASE 生产服务地址(默认 http://localhost:3100;须是 `node dist/standalone/server.js`,不是 dev server)
// SEED 清单种子:既可以是本地 sitemap.xml 路径,也可以是 http(s) URL(默认取 `${BASE}/sitemap.xml`)
// MANIFEST 预渲染产物目录(默认 dist/standalone/dist/server/app),存在才参与并集
// OUT 输出的路由清单 XML(默认 /tmp/axe-routes.xml,即 axe-node-count.mjs 的默认 SITEMAP)
// MAX_PAGES BFS 上限(默认 200)
// 退出码:0 成功 / 2 清单为空或 BASE 完全不可达(宁可不产出,也不要让门禁扫到 0 页还判绿)。
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// 搬到 scripts/accessibility/ 后不再依赖 cwd:相对路径按仓库根解析。
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const repoPath = (p) => (isAbsolute(p) ? p : resolve(REPO_ROOT, p));
const BASE = process.env.BASE ?? 'http://localhost:3100';
const SEED = process.env.SEED ?? `${BASE}/sitemap.xml`;
const OUT = repoPath(process.env.OUT ?? '/tmp/axe-routes.xml');
const MANIFEST = repoPath(process.env.MANIFEST ?? 'dist/standalone/dist/server/app');
const MAX_PAGES = Number(process.env.MAX_PAGES ?? 200);
const CONCURRENCY = 5;
const origin = new URL(BASE).origin;
const found = new Set();
const visited = new Set();
function normalize(raw) {
let u;
try {
u = new URL(raw, BASE);
} catch {
return null;
}
if (u.origin !== origin) return null;
if (u.search || u.hash) return null;
const p = decodeURIComponent(u.pathname);
if (/\.(svg|png|jpe?g|webp|avif|ico|css|js|mjs|txt|xml|json|woff2?|mp4|pdf|zip)$/i.test(p)) return null;
if (p.startsWith('/api/') || p.startsWith('/_next/') || p.startsWith('/admin')) return null;
if (p === '/_global-error') return null; // 框架内置 500 文档,见下方 MANIFEST 段的按内容判定
if (p.includes('/uploads/')) return null;
return p === '/' ? '/' : p.replace(/\/+$/, '') || '/';
}
async function readSeed() {
if (!SEED) return '';
if (/^https?:\/\//i.test(SEED)) {
try {
const res = await fetch(SEED);
if (!res.ok) {
console.warn(`WARN: 种子 sitemap ${SEED} 返回 ${res.status},仅用预渲染产物 + BFS 作为清单来源`);
return '';
}
return await res.text();
} catch (err) {
console.warn(`WARN: 种子 sitemap ${SEED} 不可达(${err instanceof Error ? err.message : err}),仅用预渲染产物 + BFS`);
return '';
}
}
return readFile(repoPath(SEED), 'utf8'); // 本地文件路径:读不到就抛错,不静默降级
}
for (const m of (await readSeed()).matchAll(/<loc>([^<]+)<\/loc>/g)) {
const n = normalize(m[1]);
if (n) found.add(n);
}
// 预渲染产物清单:sitemap 只收静态页 + 已发布 slug,站内无入口的孤儿页不在其中。
// 构建产物里的 .html 才是「实际会渲染的公开页面」全集,故与 sitemap 取并集。
async function walk(dir) {
const out = [];
for (const ent of await readdir(dir, { withFileTypes: true })) {
const p = `${dir}/${ent.name}`;
if (ent.isDirectory()) out.push(...(await walk(p)));
else if (ent.name.endsWith('.html')) out.push(p);
}
return out;
}
let manifestCount = 0;
let skippedInternal = 0;
if (existsSync(MANIFEST)) {
for (const file of await walk(MANIFEST)) {
// Next.js 为根错误路径预渲染的是**框架内置 500 文档**(`<html id="__next_error__">`,零应用标记、
// 无 lang、深色落到浏览器默认画布),它不是本站编写的路由,扫它等于测框架模板。
// 本站确实提供了 src/app/global-error.tsx(佐证:dist/.../chunks/ssr/src_app_global-error_tsx_*.js 内含本站文案),
// 所以按**内容**判定而不是按名字硬编码:一旦框架把本站组件预渲染到此路径,条件不再命中,路由自动回到清单。
if (/<html[^>]*id="__next_error__"/.test(await readFile(file, 'utf8'))) {
skippedInternal++;
continue;
}
const rel = relative(MANIFEST, file).replace(/\.html$/, '');
const path = rel === 'index' ? '/' : `/${rel.replace(/\/index$/, '')}`;
const n = normalize(path);
if (n && !found.has(n)) manifestCount++;
if (n) found.add(n);
}
} else {
console.warn(`WARN: 预渲染产物目录 ${MANIFEST} 不存在,清单将缺少孤儿页(先 npm run build)`);
}
let reachable = 0;
let frontier = [...found];
while (frontier.length && visited.size < MAX_PAGES) {
const batch = frontier.splice(0, CONCURRENCY * 4);
const results = await Promise.all(
batch.map(async (path) => {
if (visited.has(path)) return [];
visited.add(path);
try {
const res = await fetch(BASE + path);
if (!res.ok) return [];
reachable += 1;
const html = await res.text();
const links = new Set();
for (const m of html.matchAll(/<a\b[^>]*href="([^"]+)"/g)) {
const n = normalize(m[1]);
if (n && !visited.has(n)) links.add(n);
}
return [...links];
} catch {
return [];
}
})
);
for (const links of results) {
for (const l of links) {
if (!found.has(l)) {
found.add(l);
frontier.push(l);
}
}
}
}
const paths = [...found].sort();
// 两条「宁红不假绿」的保险丝:清单为空 ⇒ 门禁等于没测;一条都不可达 ⇒ BASE 起错了/没起。
// 两种情况下都**不写 OUT**,避免用空清单覆盖上一份可用清单。
if (paths.length === 0) {
console.error(`FATAL: 三源(${SEED} ∪ ${MANIFEST} ∪ BFS)并集为 0 条路由,不写出 ${OUT}`);
process.exit(2);
}
if (visited.size > 0 && reachable === 0) {
console.error(`FATAL: BASE=${BASE} 上 ${visited.size} 条候选路由全部不可达,清单不可信,不写出 ${OUT}`);
process.exit(2);
}
const xml =
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
paths.map((p) => ` <url><loc>${BASE}${p}</loc></url>`).join('\n') +
'\n</urlset>\n';
await writeFile(OUT, xml);
console.log(
`crawled visited=${visited.size} reachable=${reachable} urls=${paths.length} manifestExtra=${manifestCount} skippedInternalDocs=${skippedInternal} -> ${OUT}`
);
console.log(paths.join('\n'));