#!/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);