#!/usr/bin/env node
/**
* verify-site-routes.mjs — 无 # 路由(History API)的浏览器级验收。
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js),或把 REG_BASE 指向已跑的实例。
*
* 为什么必须用真浏览器:`parseRoute()` 单独测不出问题 —— 深层 URL 是否可用取决于
* 「服务器回落 + SPA 壳的 + 资源绝对地址 + 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/'), ` 与 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. 相对链接仍受 正确解析(「在线测试」与测试页入口都是相对地址) */
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/" 反推,快照必须是 //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');