Files
aurora-admin/tools/verify-nav-responsive.mjs
T
aurora-admin eb25feedaf feat(S6-P21): 组件族参数化(13族/44成员/导航族15文件同源/79→48概念组件)
【本次核心 · S6-P21】
- 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams;
  data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。
- 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。
- 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 /
  变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。
- 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成
  TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed;
  三份 CSS md5 完全相同 = 一份样式表服务三个组件。
- 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、
  tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。

【同时清掉此前已完成但未提交的批次】
生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告)
跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交:
- Package:三端可 import(S5-P18)+ 发布到私有 npm 源
- Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性
- Security:生产部署链审计修复(2026-09-19)+ 线上部署
- Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性;
  S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程
- 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、
  安全审计修复与待决策项.md

【验收】
- node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变)
- node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44)
- node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时
- 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误
- 零运行时依赖 OK;build-site.ps1 ASCII-only OK

【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
2026-09-20 03:32:31 +08:00

199 lines
10 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.
#!/usr/bin/env node
/**
* verify-nav-responsive.mjs — 顶栏响应式验收(≤1366px 折叠为汉堡 + 抽屉)
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js)
* 依赖:npm i -D playwright(CI 专用;运行时零依赖不变)
* 运行:node tools/verify-nav-responsive.mjs
* 退出码:0 全通过 / 1 有断言失败 / 2 环境问题
*
* 为什么需要它:顶栏的窄屏行为只在真实布局下才成立 —— 元素"可见"不等于"点得到"
* (遮罩层叠、visibility 过渡都会让点击静默失效),也不等于"文字没顶出 60px 栏"。
* 本脚本把这两类都测了,改动前实测到的缺陷即由它定位。
*
* 断言:
* A. 18 个宽度 × 中英双语:顶栏无横向溢出;无链接文字顶出 60px 顶栏(出血);
* 宽屏(>1366)链接必须单行且在视口内;窄屏(≤1366)汉堡可见。
* B. 抽屉交互:点汉堡打开 → 7 条链接全部落在视口内、遮罩出现、页面滚动被锁;
* Esc / 点遮罩 / 点链接 三种方式都能关闭;aria-expanded 同步;
* 打开时焦点进抽屉、关闭时焦点还给汉堡;放大回宽屏自动复位(不留滚动锁)。
*
* 断点依据(改动前实测,Chromium):
* 中文 1280px 起 7 条链接全部折行,1100px 起文字顶出顶栏,960px 起顶栏溢出;
* 英文 1366px 起顶栏溢出,1200px 起最右链接已不可达,480px 起 7 条全部不可见。
* 英文是约束方,故折叠阈值取 1366px。
*/
import { chromium } from 'playwright';
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const URL = `${BASE}/site/`;
const COLLAPSE = 1366; // 与 style.css 的 @media (max-width: 1366px) 必须一致
const WIDTHS = [1600, 1440, 1401, 1400, 1367, 1366, 1320, 1280, 1100, 1024, 960, 900, 820, 768, 640, 480, 375, 320];
const failures = [];
function assert(ok, msg) {
if (!ok) failures.push(msg);
console.log(`[nav-responsive] ${ok ? 'OK ' : 'FAIL'} ${msg}`);
}
/* 在页面上下文里量顶栏:溢出、出血、链接可达性 */
const probeLayout = (collapse) => {
const q = (s) => document.querySelector(s);
const inner = q('.topbar-inner');
const nav = q('.topnav');
const btn = q('#nav-toggle');
const tb = q('.topbar').getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const lines = (el) => {
const r = document.createRange();
r.selectNodeContents(el);
const rects = [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5);
return { n: rects.length, top: rects.length ? Math.min(...rects.map((x) => x.top)) : 0, bottom: rects.length ? Math.max(...rects.map((x) => x.bottom)) : 0 };
};
const links = [...nav.querySelectorAll('a')].map((a) => {
const b = a.getBoundingClientRect();
const t = lines(a);
const inViewX = b.left >= 0 && b.right <= vw;
return {
text: a.textContent.trim(),
inView: inViewX && b.top >= 0 && b.top < 900,
lines: t.n,
// 只判视口内链接:抽屉关闭时链接被 translateX 移出视口属预期,不是缺陷
bleed: inViewX && (t.bottom > tb.bottom + 0.5 || t.top < tb.top - 0.5),
};
});
return {
overflow: inner.scrollWidth - inner.clientWidth,
bleed: links.filter((x) => x.bleed).map((x) => x.text),
inView: links.filter((x) => x.inView).length,
total: links.length,
maxLines: Math.max(...links.map((x) => x.lines)),
hamburger: btn ? getComputedStyle(btn).display !== 'none' : false,
drawerOpen: document.body.classList.contains('nav-open'),
navVisibility: getComputedStyle(nav).visibility,
backdrop: q('#nav-backdrop') ? getComputedStyle(q('#nav-backdrop')).display !== 'none' : false,
bodyOverflow: getComputedStyle(document.body).overflow,
drawerLeft: Math.round(nav.getBoundingClientRect().left),
expanded: btn ? btn.getAttribute('aria-expanded') : null,
};
};
const browser = await chromium.launch();
try {
/* 环境自检:服务未起或页面结构缺失时报 exit 2(同 run-regression.mjs 的口径),
否则在 CI 上会退化成一串看不懂的断言失败。 */
{
const probe = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
const res = await probe.goto(URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
if (!res || !res.ok()) throw new Error(`HTTP ${res ? res.status() : 'no response'}`);
/* 必须用 attached:汉堡按钮在 >1366px 是 display:none(设计如此),
默认的 visible 状态会在这里误报环境失败。 */
await probe.waitForSelector('#nav-toggle', { state: 'attached', timeout: 8000 });
} catch (err) {
console.error(`[nav-responsive] ENV FAIL: 无法加载 ${URL}(${err.message})`);
console.error('[nav-responsive] 先起服务:node site/dev-server.js');
await browser.close();
process.exit(2);
}
/* 字体度量基准:宽度断言依赖字体度量,CI 上缺 CJK 字体时中文会退化成豆腐块/拉丁回退,
宽度随之改变。打印基准值,好让日后的失败一眼可定位到"字体变了"而不是"布局坏了"。 */
const m = await probe.evaluate(() => {
const c = document.createElement('canvas').getContext('2d');
c.font = '14px sans-serif';
return {
han1: +c.measureText('国').width.toFixed(1),
han4: +c.measureText('快速开始').width.toFixed(1),
lat: +c.measureText('Getting Started').width.toFixed(1),
};
});
console.log(`[nav-responsive] 字体度量基准 14px:中文单字=${m.han1}px 快速开始=${m.han4}px "Getting Started"=${m.lat}px`);
await probe.close();
}
for (const lang of ['zh', 'en']) {
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
await page.addInitScript((l) => { try { localStorage.setItem('aa-lang', l); } catch (e) {} }, lang);
await page.goto(URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
for (const w of WIDTHS) {
await page.setViewportSize({ width: w, height: 900 });
await page.waitForTimeout(180);
const L = await page.evaluate(probeLayout, COLLAPSE);
const tag = `${lang}@${w}px`;
assert(L.overflow === 0, `${tag} 顶栏无横向溢出${L.overflow ? `(实测 ${L.overflow}px)` : ''}`);
assert(L.bleed.length === 0, `${tag} 无链接文字顶出顶栏${L.bleed.length ? `(${L.bleed.join(',')})` : ''}`);
if (w > COLLAPSE) {
assert(!L.hamburger, `${tag} 宽屏不显示汉堡`);
assert(L.maxLines === 1, `${tag} 链接单行(实测最多 ${L.maxLines} 行)`);
assert(L.inView === L.total, `${tag} 链接全部可见(${L.inView}/${L.total})`);
} else {
assert(L.hamburger, `${tag} 窄屏显示汉堡`);
await page.click('#nav-toggle');
await page.waitForTimeout(340);
const O = await page.evaluate(probeLayout, COLLAPSE);
assert(O.drawerOpen && O.navVisibility === 'visible', `${tag} 点汉堡后抽屉打开`);
assert(O.inView === O.total, `${tag} 抽屉内链接全部可达(${O.inView}/${O.total})`);
assert(O.backdrop, `${tag} 遮罩出现`);
assert(O.bodyOverflow === 'hidden', `${tag} 抽屉打开时锁定页面滚动`);
assert(O.drawerLeft >= 0, `${tag} 抽屉未被推出视口(left=${O.drawerLeft})`);
assert(O.expanded === 'true', `${tag} aria-expanded 同步为 true`);
await page.keyboard.press('Escape');
await page.waitForTimeout(340);
const C = await page.evaluate(probeLayout, COLLAPSE);
assert(!C.drawerOpen, `${tag} Esc 关闭抽屉`);
assert(C.expanded === 'false', `${tag} 关闭后 aria-expanded 同步为 false`);
}
}
await page.close();
}
/* ---- 交互序列(390px 中文)---- */
const p = await browser.newPage({ viewport: { width: 390, height: 800 } });
await p.goto(URL, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(1500);
const open = async () => { await p.click('#nav-toggle'); await p.waitForTimeout(340); };
const isOpen = () => p.evaluate(() => document.body.classList.contains('nav-open'));
await open();
assert(await p.evaluate(() => document.activeElement === document.querySelector('.topnav a')), '交互 打开后焦点进入抽屉首链接');
const left = (await p.evaluate(() => Math.round(document.querySelector('.topnav').getBoundingClientRect().left)));
await p.mouse.click(Math.max(5, left - 20), 500);
await p.waitForTimeout(340);
assert(!(await isOpen()), '交互 点遮罩关闭');
assert(await p.evaluate(() => document.activeElement === document.querySelector('#nav-toggle')), '交互 关闭后焦点还给汉堡');
await open();
await p.click('.topnav a[data-nav="design"]');
await p.waitForTimeout(700);
assert(!(await isOpen()), '交互 点链接后抽屉关闭');
assert(p.url().includes('#/design'), '交互 点链接后完成跳转');
assert(await p.evaluate(() => !!document.querySelector('.topnav a[data-nav="design"].active')), '交互 跳转后链接高亮');
await p.click('#lang-trigger');
await p.waitForTimeout(300);
const menuBefore = await p.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
await open();
const menuAfter = await p.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
assert(menuBefore && !menuAfter, '交互 开抽屉会收起语言菜单(避免叠层)');
await p.setViewportSize({ width: 1600, height: 800 });
await p.waitForTimeout(450);
assert(!(await isOpen()), '交互 放大回宽屏自动复位 nav-open');
assert((await p.evaluate(() => getComputedStyle(document.body).overflow)) !== 'hidden', '交互 放大后页面滚动解锁');
assert(await p.evaluate(() => getComputedStyle(document.querySelector('#nav-toggle')).display === 'none'), '交互 放大后汉堡隐藏');
assert(await p.evaluate(() => {
const b = document.querySelector('.topnav').getBoundingClientRect();
return b.top < 60 && b.height <= 61 && b.right <= innerWidth;
}), '交互 放大后导航回到顶栏内单行');
await p.close();
} finally {
await browser.close();
}
console.log(`\n[nav-responsive] ${failures.length === 0 ? 'OK — all checks passed' : `FAIL — ${failures.length} 项未通过`}`);
process.exit(failures.length === 0 ? 0 : 1);