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 配置收敛。
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// 全站页面清单生成器(`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'));
|
||||
Reference in New Issue
Block a user