#!/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');