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 未做部分)
287 lines
18 KiB
JavaScript
287 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-site-routes.mjs — 无 # 路由(History API)的浏览器级验收。
|
||
*
|
||
* 前置:仓库根目录起 dev-server(node site/dev-server.js),或把 REG_BASE 指向已跑的实例。
|
||
*
|
||
* 为什么必须用真浏览器:`parseRoute()` 单独测不出问题 —— 深层 URL 是否可用取决于
|
||
* 「服务器回落 + SPA 壳的 <base> + 资源绝对地址 + pushState/历史」四件事同时成立,
|
||
* 静态检查只能证明文件里有那几行。这里逐条走真实导航:
|
||
* 深链直开 / 深链刷新 / 旧 # 链接改写 / 站内跳转不整页刷新 / 框架切换写进 URL /
|
||
* 前进后退 / 薄壳重定向 / 缺失资源仍回真 404 / file:// 降级(pushState 不可用)。
|
||
* 其中「控制台零报错 + 零 4xx」是资源路径写法的回归哨兵:SPA 壳里一旦写回静态
|
||
* 相对路径,预扫描器会在深层 URL 下按错误的基准预取(实测会多出 404 与报错)。
|
||
*/
|
||
import { pathToFileURL } from 'node:url';
|
||
import { join } from 'node:path';
|
||
|
||
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
|
||
const failures = [];
|
||
const assert = (ok, message) => {
|
||
if (!ok) failures.push(message);
|
||
console.log(`[site-routes] ${ok ? 'OK' : 'FAIL'} ${message}`);
|
||
};
|
||
|
||
let chromium;
|
||
try {
|
||
({ chromium } = await import('playwright'));
|
||
} catch {
|
||
console.error('[site-routes] 未安装 playwright。CI 环境请先 npm i -D playwright && npx playwright install --with-deps chromium');
|
||
process.exit(2);
|
||
}
|
||
|
||
try {
|
||
const r = await fetch(`${BASE}/site/data.json`, { signal: AbortSignal.timeout(5000) });
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
} catch (e) {
|
||
console.error(`[site-routes] 无法连接 ${BASE}/site/data.json — ${e.message}`);
|
||
console.error(' 请先在仓库根目录运行:node site/dev-server.js');
|
||
process.exit(2);
|
||
}
|
||
|
||
try {
|
||
const browser = await chromium.launch();
|
||
/* 视口取宽屏:≤1366px 时顶栏被 CSS 收成抽屉,导航链接点不到(见 verify-nav-responsive.mjs) */
|
||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||
page.setDefaultTimeout(15000);
|
||
|
||
const consoleErrors = [];
|
||
const badResponses = [];
|
||
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160)); });
|
||
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 160)));
|
||
page.on('response', (res) => { if (res.status() >= 400) badResponses.push(`${res.status()} ${res.url()}`); });
|
||
|
||
const probe = () => page.evaluate(() => ({
|
||
url: location.pathname + location.search + location.hash,
|
||
siteBase: window.KOLE_SITE_BASE,
|
||
baseUri: document.baseURI,
|
||
h1: (document.querySelector('#content h1') || {}).textContent || '',
|
||
sheets: document.styleSheets.length,
|
||
token: getComputedStyle(document.documentElement).getPropertyValue('--kole-color-brand').trim(),
|
||
}));
|
||
|
||
/* 1. 深链直开:渲染、URL 干净、站点根与令牌都对 */
|
||
await page.goto(`${BASE}/site/component/button/h5`, { waitUntil: 'networkidle' });
|
||
let s = await probe();
|
||
assert(/按钮|Button/.test(s.h1), `深链 /site/component/button/h5 直接打开能渲染(h1=${s.h1 || '空'})`);
|
||
assert(s.url === '/site/component/button/h5', `深链 URL 里没有 #(实际 ${s.url})`);
|
||
assert(s.siteBase === '/site/' && s.baseUri.endsWith('/site/'), `<base> 与 KOLE_SITE_BASE 指向站点根(${s.baseUri})`);
|
||
assert(s.sheets >= 2 && s.token !== '', `站点令牌样式表已生效(sheets=${s.sheets} --kole-color-brand=${s.token || '空'})`);
|
||
assert(consoleErrors.length === 0, `深链冷加载 0 条控制台报错(实际 ${consoleErrors.length}${consoleErrors[0] ? ':' + consoleErrors[0] : ''})`);
|
||
assert(badResponses.length === 0, `深链冷加载 0 个 4xx/5xx(实际 ${badResponses.length}${badResponses[0] ? ':' + badResponses[0] : ''})`);
|
||
|
||
/* 2. 旧 # 链接:渲染 + 就地改写成干净地址 */
|
||
await page.goto(`${BASE}/site/#/component/modal`, { waitUntil: 'networkidle' });
|
||
s = await probe();
|
||
assert(/对话框|Modal/.test(s.h1), `旧 #/component/modal 仍能渲染(h1=${s.h1 || '空'})`);
|
||
assert(s.url === '/site/component/modal', `旧 # 链接被 replaceState 成无 # 地址(实际 ${s.url})`);
|
||
|
||
/* 3. 站内跳转:pushState 局部渲染,不整页刷新 */
|
||
await page.goto(`${BASE}/site/component/button/h5`, { waitUntil: 'networkidle' });
|
||
await page.evaluate(() => { window.__routeMark = 1; });
|
||
await page.click('.topnav a[data-nav="design"]');
|
||
await page.waitForFunction(() => location.pathname === '/site/design');
|
||
s = await probe();
|
||
assert(s.url === '/site/design', `顶栏链接跳到 /site/design(实际 ${s.url})`);
|
||
assert(await page.evaluate(() => window.__routeMark === 1), '跳转是 pushState(页面未整页刷新)');
|
||
|
||
/* 4. 侧栏进入组件页 + 框架切换写进 URL */
|
||
await page.goto(`${BASE}/site/overview`, { waitUntil: 'networkidle' });
|
||
await page.click('.side-item[data-slug="button"]');
|
||
await page.waitForFunction(() => location.pathname.startsWith('/site/component/button'));
|
||
s = await probe();
|
||
assert(s.url === '/site/component/button', `侧栏进入组件页(实际 ${s.url})`);
|
||
await page.selectOption('#fw-select', 'react');
|
||
await page.waitForFunction(() => location.pathname === '/site/component/button/react');
|
||
s = await probe();
|
||
assert(s.url === '/site/component/button/react', `框架切换写进 URL(实际 ${s.url})`);
|
||
|
||
/* 5. 前进 / 后退 */
|
||
await page.goBack();
|
||
await page.waitForFunction(() => location.pathname === '/site/component/button');
|
||
assert((await probe()).url === '/site/component/button', '后退回到短形态组件页');
|
||
await page.goBack();
|
||
await page.waitForFunction(() => location.pathname === '/site/overview');
|
||
s = await probe();
|
||
assert(/组件总览|Components/.test(s.h1), `再后退回到组件总览(h1=${s.h1 || '空'})`);
|
||
await page.goForward();
|
||
await page.waitForFunction(() => location.pathname === '/site/component/button');
|
||
assert((await probe()).url === '/site/component/button', '前进回到组件页');
|
||
|
||
/* 6. 深链刷新:走的是服务器回落,不是前端兜底 */
|
||
await page.goto(`${BASE}/site/component/table/vue3`, { waitUntil: 'networkidle' });
|
||
await page.reload({ waitUntil: 'networkidle' });
|
||
s = await probe();
|
||
assert(/表格|Table/.test(s.h1), `深链刷新后仍渲染(h1=${s.h1 || '空'})`);
|
||
assert(s.url === '/site/component/table/vue3', `刷新后 URL 不变形(实际 ${s.url})`);
|
||
|
||
/* 7. 组件入口薄壳(build-site.ps1 生成物)重定向到干净目标 */
|
||
await page.goto(`${BASE}/site/components/button/h5.html`, { waitUntil: 'networkidle' });
|
||
s = await probe();
|
||
assert(s.url === '/site/component/button/h5', `薄壳重定向落到 /site/component/button/h5(实际 ${s.url})`);
|
||
|
||
/* 8. 服务器回落只接无扩展名的路径:缺失资源必须回真 404,不能拿 HTML 顶替 */
|
||
const missing = await fetch(`${BASE}/site/component/button/style.css`);
|
||
assert(missing.status === 404, `缺失的带扩展名资源回真 404(实际 ${missing.status})`);
|
||
const deep = await fetch(`${BASE}/site/component/button/h5`);
|
||
assert(deep.status === 200 && (deep.headers.get('content-type') || '').includes('text/html'), '深层路由由服务器回落到 SPA 壳(200 text/html)');
|
||
|
||
/* 9. file:// 直开:pushState 不可用时退化为 hash 形态,而不是白屏 */
|
||
await page.goto(pathToFileURL(join(process.cwd(), 'site', 'index.html')).href + '#/component/button', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForTimeout(800);
|
||
s = await probe();
|
||
assert(/按钮|Button/.test(s.h1), `file:// 下仍能渲染组件页(h1=${s.h1 || '空'})`);
|
||
assert(s.url.includes('#/'), `file:// 下退化为 hash 形态(实际 ${s.url})`);
|
||
|
||
/* 10. 搜索弹窗跳转:走的是同一条 go() 路径 */
|
||
await page.goto(`${BASE}/site/overview`, { waitUntil: 'networkidle' });
|
||
await page.evaluate(() => { window.__routeMark = 1; });
|
||
await page.keyboard.press('Control+k');
|
||
await page.waitForSelector('#search-modal:not(.hidden)');
|
||
await page.fill('#search-input', 'button');
|
||
await page.waitForSelector('.sr-item');
|
||
await page.click('.sr-item');
|
||
await page.waitForFunction(() => location.pathname.startsWith('/site/component/'));
|
||
s = await probe();
|
||
assert(s.url === '/site/component/button', `搜索结果跳转到 /site/component/button(实际 ${s.url})`);
|
||
assert(await page.evaluate(() => window.__routeMark === 1), '搜索跳转是 pushState(页面未整页刷新)');
|
||
|
||
/* 11. 相对链接仍受 <base> 正确解析(「在线测试」与测试页入口都是相对地址) */
|
||
const playHref = await page.evaluate(() => {
|
||
const a = document.querySelector('a[href*="playground"]');
|
||
return a ? a.href : '';
|
||
});
|
||
assert(/\/site\/playground\.html\?slug=button$/.test(playHref), `「在线测试」相对链接解析到站点根(实际 ${playHref || '未找到'})`);
|
||
if (playHref) {
|
||
await page.goto(playHref, { waitUntil: 'networkidle' });
|
||
s = await probe();
|
||
assert(s.url === '/site/playground.html?slug=button', `在线测试页带上 slug 打开(实际 ${s.url})`);
|
||
}
|
||
|
||
/* 12. 版本切换:清单加载、保留路由、快照站点自渲染。
|
||
站点根靠 pathname 里第一段 "/site/" 反推,快照必须是 /<x.y.z>/site/… 才成立;
|
||
清单由 tools/precompute.mjs 生成,只有磁盘上真存在快照的版本才会出现。 */
|
||
await page.goto(`${BASE}/site/component/button/h5`, { waitUntil: 'networkidle' });
|
||
const verManifest = await page.evaluate(async () => {
|
||
try { return await (await fetch(new URL('versions.json', document.baseURI).href, { cache: 'no-store' })).json(); }
|
||
catch (e) { return null; }
|
||
});
|
||
if (verManifest && Array.isArray(verManifest.versions) && verManifest.versions.length > 1) {
|
||
const chip = await page.evaluate(() => {
|
||
const el = document.querySelector('#version-trigger');
|
||
return el ? { text: el.textContent.trim(), label: el.getAttribute('aria-label'), single: el.getAttribute('data-single') } : null;
|
||
});
|
||
assert(!!chip && /^v\d+\.\d+\.\d+$/.test(chip.text), `版本角标显示当前版本(实际 ${chip ? chip.text : '未找到'})`);
|
||
assert(!!chip && chip.label === '选择版本' && chip.single === '0', `多版本时角标可交互(label=${chip && chip.label} single=${chip && chip.single})`);
|
||
|
||
await page.click('#version-trigger');
|
||
await page.waitForSelector('#version-menu:not(.hidden)');
|
||
const opts = await page.evaluate(() => Array.prototype.slice.call(document.querySelectorAll('#version-menu .ver-opt'))
|
||
.map((o) => ({ v: o.getAttribute('data-version'), cur: o.getAttribute('aria-selected') })));
|
||
assert(opts.length === verManifest.versions.length, `版本菜单列出全部在线版本(${opts.length}/${verManifest.versions.length})`);
|
||
assert(opts.filter((o) => o.cur === 'true').length === 1, '版本菜单里恰好一项标记为当前版本');
|
||
|
||
const target = verManifest.versions.find((v) => v.path !== '..');
|
||
const optIndex = opts.findIndex((o) => o.v === target.version);
|
||
await page.click(`#version-menu .ver-opt:nth-of-type(${optIndex + 1})`);
|
||
await page.waitForURL(`**/${target.version}/site/component/button/h5`, { timeout: 15000 });
|
||
await page.waitForLoadState('domcontentloaded');
|
||
s = await probe();
|
||
assert(s.url === `/${target.version}/site/component/button/h5`, `切换版本保留当前路由(实际 ${s.url})`);
|
||
assert(s.siteBase === `/${target.version}/site/`, `快照内站点根仍由 "/site/" 反推(实际 ${s.siteBase})`);
|
||
assert(s.token !== '', `快照版本自己的令牌样式表生效(--kole-color-brand=${s.token || '空'})`);
|
||
await page.waitForFunction(() => /按钮|Button/.test(((document.querySelector('#content h1') || {}).textContent) || ''), null, { timeout: 15000 });
|
||
const back = await page.evaluate(() => {
|
||
const el = document.querySelector('#version-trigger');
|
||
return el ? el.textContent.trim() : '';
|
||
});
|
||
/* 快照角标取的是「快照自己的构建版本」(它那份 data.meta.version)。
|
||
用 tools/snapshot-site.mjs --force-version 造出来的演练快照会与目录名不同号 —— 那是
|
||
有意的:内容就是当前构建,只是挂在了旧版本号下;正式归档(不带 --force-version)时两者一致。 */
|
||
/* 快照角标显示的是**清单里该快照对应的版本号**(也就是 URL 里那一段),不是快照内部
|
||
冻结的 data.meta.version。用 --force-version 造的演练快照两者不同号(内容=当前构建、
|
||
版本号=旧版),正好能把这条区分开;正式归档(不带 --force-version)时两者一致。 */
|
||
assert(back === 'v' + target.version, `快照内版本角标显示该快照对应的版本(期望 v${target.version},实际 ${back || '空'})`);
|
||
/* 快照页的菜单必须仍可交互、且「当前版本」标的是它自己 —— 实测踩过的两个坑:
|
||
① 快照里残留的旧清单把菜单压成单版本 → 角标不可点;② 按路径前缀判当前版本时
|
||
'/site/…' 与 '/1.4.1/site/…' 撞车,根站点条目抢走「当前」。两者都会导致切不回来。 */
|
||
const snapState = await page.evaluate(() => {
|
||
const t = document.querySelector('#version-trigger');
|
||
return {
|
||
single: t ? t.getAttribute('data-single') : null,
|
||
chip: document.querySelector('#ver').textContent,
|
||
opts: Array.prototype.slice.call(document.querySelectorAll('#version-menu .ver-opt')).map((o) => ({ v: o.getAttribute('data-version'), cur: o.getAttribute('aria-selected') }))
|
||
};
|
||
});
|
||
assert(snapState.single === '0', `快照页的版本菜单仍可交互(data-single=${snapState.single})`);
|
||
assert(snapState.opts.filter((o) => o.cur === 'true').length === 1 && snapState.opts.filter((o) => o.cur === 'true')[0].v === target.version,
|
||
`快照页把「当前版本」标在自己身上(chip=${snapState.chip})`);
|
||
/* 切回最新版:保留路由 + 角标回到最新版 */
|
||
const backIndex = snapState.opts.findIndex((o) => o.cur === 'false');
|
||
await page.click('#version-trigger');
|
||
await page.waitForSelector('#version-menu:not(.hidden)');
|
||
await page.click(`#version-menu .ver-opt:nth-of-type(${backIndex + 1})`);
|
||
await page.waitForURL('**/site/component/button/h5', { timeout: 15000 });
|
||
await page.waitForLoadState('domcontentloaded');
|
||
s = await probe();
|
||
assert(s.url === '/site/component/button/h5', `从快照切回最新版保留路由(实际 ${s.url})`);
|
||
await page.waitForFunction(() => /按钮|Button/.test(((document.querySelector('#content h1') || {}).textContent) || ''), null, { timeout: 15000 });
|
||
const backChip = await page.evaluate(() => (document.querySelector('#ver') || {}).textContent || '');
|
||
assert(backChip === 'v' + verManifest.versions.find((v) => v.path === '..').version, `切回后角标回到最新版(实际 ${backChip || '空'})`);
|
||
/* file:// 直开时 details/sources 的 fetch 必然失败(浏览器不允许 file 方案 fetch),
|
||
那是既有降级路径的噪声,不计入「版本切换全程零报错」 */
|
||
const httpErrors = consoleErrors.filter((m) => !/URL scheme "file"/.test(m) && !/Cannot load file:/.test(m));
|
||
assert(httpErrors.length === 0, `版本切换全程 0 条控制台报错(实际 ${httpErrors.length}${httpErrors[0] ? ':' + httpErrors[0] : ''})`);
|
||
assert(badResponses.length === 0, `版本切换全程 0 个 4xx/5xx(实际 ${badResponses.length}${badResponses[0] ? ':' + badResponses[0] : ''})`);
|
||
} else {
|
||
/* 只有一个在线版本(开发期常态):下拉仍要在,里面就一项且标为当前版本 ——
|
||
打开就能看清「当前是哪个版本」,点击当前项不跳转(避免无谓整页刷新)。
|
||
窄屏那一档同理:顶栏 select 出现,只有一项且选中。 */
|
||
const solo = await page.evaluate(() => {
|
||
const t = document.querySelector('#version-trigger');
|
||
const menu = document.querySelector('#version-menu');
|
||
const wrap = document.querySelector('#ver-select-wrap');
|
||
return {
|
||
chip: (document.querySelector('#ver') || {}).textContent || '',
|
||
single: t ? t.getAttribute('data-single') : null,
|
||
hasPopup: t ? t.hasAttribute('aria-haspopup') : null,
|
||
label: t ? t.getAttribute('aria-label') : null,
|
||
menuHidden: menu ? menu.classList.contains('hidden') : null,
|
||
opts: Array.prototype.slice.call(document.querySelectorAll('#version-menu .ver-opt')).map((o) => ({
|
||
v: o.getAttribute('data-version'), cur: o.getAttribute('aria-selected')
|
||
})),
|
||
selectHidden: wrap ? wrap.hasAttribute('hidden') : null,
|
||
selectOptions: Array.prototype.slice.call(document.querySelectorAll('#ver-select option')).map((o) => o.value + (o.selected ? '*' : ''))
|
||
};
|
||
});
|
||
assert(solo.chip === 'v' + verManifest.versions[0].version, `单版本时角标显示当前版本(实际 ${solo.chip || '空'})`);
|
||
assert(solo.single === '0' && solo.hasPopup === true, `单版本时下拉仍可用(data-single=${solo.single} popup=${solo.hasPopup})`);
|
||
assert(solo.menuHidden === true, '单版本时菜单初始为关闭');
|
||
assert(solo.opts.length === 1 && solo.opts[0].v === verManifest.versions[0].version && solo.opts[0].cur === 'true',
|
||
`单版本菜单里只有一项且标为当前(${JSON.stringify(solo.opts)})`);
|
||
assert(solo.selectHidden === false && solo.selectOptions.length === 1 && solo.selectOptions[0].endsWith('*'),
|
||
`单版本时窄屏 select 出现并选中当前版本(hidden=${solo.selectHidden} ${JSON.stringify(solo.selectOptions)})`);
|
||
|
||
/* 打开 → 点唯一一项:应关闭菜单且 URL 不变(当前版本不重复跳转) */
|
||
await page.click('#version-trigger');
|
||
await page.waitForSelector('#version-menu:not(.hidden)');
|
||
await page.click('#version-menu .ver-opt:nth-of-type(1)');
|
||
await page.waitForTimeout(400);
|
||
const afterClick = await page.evaluate(() => ({ url: location.pathname, hidden: document.querySelector('#version-menu').classList.contains('hidden') }));
|
||
assert(afterClick.url === '/site/component/button/h5', `点当前版本不跳转(实际 ${afterClick.url})`);
|
||
console.log(`[site-routes] SKIP 版本切换(清单里只有 v${verManifest.versions[0].version} 一个在线版本,下拉内只有它一项)`);
|
||
}
|
||
|
||
await browser.close();
|
||
} catch (e) {
|
||
failures.push('探针中断: ' + String(e.message).split('\n')[0].slice(0, 200));
|
||
console.error('[site-routes] FAIL 中断: ' + String(e.message).split('\n')[0].slice(0, 200));
|
||
}
|
||
|
||
if (failures.length) {
|
||
console.error(`\n[site-routes] FAIL (${failures.length})`);
|
||
failures.forEach((f) => console.error(`- ${f}`));
|
||
process.exit(1);
|
||
}
|
||
console.log('\n[site-routes] OK — all checks passed');
|