#!/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(); } /* 字体容忍度诊断:把顶栏所有承载文字的元素字号乘以 k,二分找出仍不折行/不溢出的最大 k。 用途是把"CI 字体与开发机不同"这一风险变成可见的余量数字 —— 若某天 k 掉到接近 1, 说明余量被吃掉,此时失败信息能直接指向字体而不是"布局坏了"。 本机实测:k≈1.175(Verdana 最宽也只有 1.058,Arial/Segoe UI 为 0.918)。 */ { const p = await browser.newPage({ viewport: { width: COLLAPSE + 1, height: 900 } }); await p.addInitScript(() => { try { localStorage.setItem('kole-lang', 'en'); } catch (e) {} }); await p.goto(URL, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(1200); await p.evaluate(() => { /* 承载顶栏文字的元素清单:随 UI 走。2026-09-20 技术栈选择器从