Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
211 lines
9.2 KiB
JavaScript
211 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-mobile-site.mjs — 移动端文档站 / 测试站的浏览器实测门禁
|
||
*
|
||
* 为什么需要它(本次实测踩到):`site/m/component/<slug>.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} 页)`);
|