#!/usr/bin/env node /** * verify-mobile-site.mjs — 移动端文档站 / 测试站的浏览器实测门禁 * * 为什么需要它(本次实测踩到):`site/m/component/.html` 里的资源路径写成了两级 * `../../.design_library/...`,从 `/site/m/component/` 解析成 `/site/.design_library/...` → **404**。 * 页面照样能打开、文字照样在、结构断言全过——**只有真浏览器会告诉你样式与演示帧一起丢了** * (实测控制台各 2 条错误)。这正是本仓库 S5-P22/S5-P23 反复证明的那条:静态检查只能证明 * 文件里有那几行,看不到"到底加载没加载、落在哪个 URL"。 * * 检查项(每条都必须在真浏览器里取值): * 1. 每页 HTTP 200 * 2. 控制台 0 错误、0 未捕获异常 * 3. 总览页与组件页的演示帧全部真实渲染(contentDocument 有内容且不是 404 页) * 4. 移动端令牌层已生效(--kole-m-touch-target 解析为 44px) * 5. 移动端站不引用 PC 侧样式(site/style.css 不得出现) * * 前置:node site/dev-server.js(或 KOLE_PORT=<端口> node site/dev-server.js) * 依赖:playwright(devDependency) * 退出码:0 全通过 / 1 有失败 / 2 环境问题 */ import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; let pass = 0; const failures = []; function check(ok, id, detail) { if (ok) { pass++; console.log(` PASS ${id}${detail ? ' — ' + detail : ''}`); } else { failures.push(id + (detail ? ' — ' + detail : '')); console.log(` FAIL ${id}${detail ? ' — ' + detail : ''}`); } } /* ---------- 待测页面清单(从索引生成,不写死) ---------- */ const indexPath = join(ROOT, '.design_library', 'kole-ui-mobile', 'components', 'index.json'); if (!existsSync(indexPath)) { console.error('[FATAL] 缺少移动端索引:' + indexPath); process.exit(2); } const index = JSON.parse(readFileSync(indexPath, 'utf8')); const pages = [ { url: '/site/m/index.html', kind: 'overview' }, ...index.components.map((c) => ({ url: `/site/m/component/${c.slug}.html`, kind: 'component' })), { url: '/tests/mobile/index.html', kind: 'plain' }, { url: '/tests/mobile/_collect.html', kind: 'collector' }, ]; let chromium; try { ({ chromium } = await import('playwright')); } catch { console.error('[FATAL] 未安装 playwright。CI 环境请先运行:npm i -D playwright && npx playwright install --with-deps chromium'); process.exit(2); } /* 前置自检:服务是否在跑 */ try { const r = await fetch(`${BASE}/site/m/data.mobile.json`, { signal: AbortSignal.timeout(5000) }); if (!r.ok) throw new Error('HTTP ' + r.status); } catch (e) { console.error(`[FATAL] 无法连接 ${BASE}/site/m/data.mobile.json — ${e.message}`); console.error(' 请先运行:node site/dev-server.js(若 3311 被占用可 KOLE_PORT=13511 node site/dev-server.js)'); process.exit(2); } console.log('移动端站点浏览器实测门禁'); console.log(`目标 ${BASE} · 页面 ${pages.length} 个`); const browser = await chromium.launch(); let consoleErrorsTotal = 0; let framesOkTotal = 0; let framesTotal = 0; for (const page of pages) { const p = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const errors = []; p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 160)); }); p.on('pageerror', (e) => errors.push('pageerror: ' + String(e.message).slice(0, 160))); p.on('requestfailed', (r) => { /* ERR_ABORTED 不算错误:收集器(_collect.html)在帧装载完成后会**主动** frame.removeAttribute('src') 释放帧,这会中断在途请求 —— 是它的设计行为, 不是资源缺失。真正的资源问题表现为 404/ERR_INVALID_URL 等。 */ const kind = r.failure()?.errorText || '?'; if (kind === 'net::ERR_ABORTED') return; errors.push('requestfailed: ' + r.url().slice(0, 120) + ' (' + kind + ')'); }); let status = 0; try { const resp = await p.goto(BASE + page.url, { waitUntil: 'load', timeout: 20000 }); status = resp ? resp.status() : 0; await p.waitForTimeout(1200); /* 总览页的预览帧是 loading="lazy":视口外的帧**不会**加载(浏览器行为,不是缺陷)。 实测:26 个卡片只加载了前 20 个,滚到底后 26/26。卡片多了以后(47 个) 一次 scrollTo 到底还会漏 —— 因为滚动过程中被跳过的帧不保证触发加载。 故:逐段滚动(每次一屏),再回到顶部,等全部帧有内容或超时。 */ if (page.kind === 'overview') { const height = await p.evaluate(() => document.body.scrollHeight); for (let y = 0; y < height; y += 800) { await p.evaluate((top) => window.scrollTo(0, top), y); await p.waitForTimeout(250); } await p.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await p.waitForTimeout(1200); await p.evaluate(() => window.scrollTo(0, 0)); await p.waitForTimeout(600); } } catch (e) { errors.push('goto: ' + String(e.message).slice(0, 120)); } const probe = await p.evaluate(() => { const frames = [...document.querySelectorAll('iframe')]; const okFrames = frames.filter((f) => { try { const d = f.contentDocument; return !!d && !!d.body && d.body.children.length > 0 && !/404 Not Found|Cannot GET/i.test(d.body.innerText || ''); } catch (e) { return false; } }); const styles = [...document.querySelectorAll('link[rel="stylesheet"]')].map((l) => l.getAttribute('href') || ''); return { frames: frames.length, okFrames: okFrames.length, touchToken: getComputedStyle(document.documentElement).getPropertyValue('--kole-m-touch-target').trim(), usesPcStyles: styles.some((h) => /(^|\/)site\/style\.css$|\/site\/style\.css/.test(h)), }; }); await p.close(); consoleErrorsTotal += errors.length; /* 汇总只统计被断言过的演示帧(总览页 / 组件页);收集器的 4 个帧是并发加载器,不在此列 */ if (page.kind === 'overview' || page.kind === 'component') { framesTotal += probe.frames; framesOkTotal += probe.okFrames; } const label = page.url; check(status === 200, `${label} HTTP 200`, status === 200 ? null : '实际 ' + status); check(errors.length === 0, `${label} 控制台 0 错误`, errors.length ? errors.slice(0, 2).join(' || ') : null); check(probe.touchToken === '44px', `${label} 移动端令牌已生效`, probe.touchToken === '44px' ? null : `--kole-m-touch-target="${probe.touchToken}"`); check(!probe.usesPcStyles, `${label} 未引用 PC 站样式`, probe.usesPcStyles ? '命中 site/style.css' : null); if (page.kind === 'overview' || page.kind === 'component') { check( probe.frames > 0 && probe.okFrames === probe.frames, `${label} 演示帧 ${probe.okFrames}/${probe.frames} 正常渲染`, probe.frames === 0 ? '未找到 iframe' : null ); } } await browser.close(); /* ---------- 窄屏无横向溢出 ---------- 移动端文档站必须自己在手机上能用。实测(2026-09-20,375px)本站在小屏上横向溢出 128px: 元凶是表格里不可断行的长路径(`.design_library/kole-ui-mobile/components/` 把表格最小宽度顶到 462px) 与组件页固定 375px 的设备帧。修完加这道门禁 —— 只靠"在桌面宽度看一眼"是发现不了的。 */ const NARROW_WIDTHS = [320, 375, 414, 768]; const NARROW_PAGES = [ '/site/m/index.html', `/site/m/component/${index.components[0].slug}.html`, '/tests/mobile/index.html', ]; const narrowBrowser = await chromium.launch(); for (const url of NARROW_PAGES) { const p = await narrowBrowser.newPage({ viewport: { width: 1280, height: 820 } }); const errors = []; p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 120)); }); for (const w of NARROW_WIDTHS) { await p.setViewportSize({ width: w, height: 820 }); try { await p.goto(BASE + url, { waitUntil: 'load', timeout: 20000 }); await p.waitForTimeout(500); } catch (e) { errors.push('goto@' + w + ': ' + String(e.message).slice(0, 80)); } const over = await p.evaluate((width) => document.documentElement.scrollWidth - width, w); check(over <= 1, `${url} @${w}px 无横向溢出`, over > 1 ? '溢出 ' + over + 'px' : null); } await p.close(); check(errors.length === 0, `${url} 窄屏全程 0 控制台错误`, errors.length ? errors.slice(0, 2).join(' || ') : null); } await narrowBrowser.close(); console.log(''); console.log(`汇总:控制台错误 ${consoleErrorsTotal} 条 · 演示帧 ${framesOkTotal}/${framesTotal} 正常`); console.log('\n────────────────────────────'); if (failures.length) { console.error(`[FAIL] ${failures.length} 条移动端站点断言失败(通过 ${pass} 条):`); failures.forEach((f) => console.error(' - ' + f)); process.exit(1); } console.log(`[OK] 移动端站点全部通过(${pass} 条断言 · ${pages.length} 页)`);