Files
aurora-admin/tools/verify-component-page.mjs
aurora-admin 5ee5d829a4
Regression / regression (push) Canceled after 0s
refactor(详情页+S8): 详情页减重(导航互斥/入口去重/i18n去重) + 品牌标识入库
本提交含两条并行工作线,因互相咬合(package.json scripts、regression.yml、
npm run build 链)无法按文件干净拆分,故合并为一次自洽提交。

## 详情页减重(本轮主任务:修复「AI 干太重」)

- 导航收敛:宽屏只用右侧目录、≤1200px 只用页内 sticky 导航,纯 CSS 媒体查询
  实现(不引入 JS 宽度监听)。此前两套导航同时可见,active 状态互相打架。
- 入口去重:标题区由「查看示例/在线测试/契约 JSON」三个减为「在线测试」一个;
  契约 JSON 归入实现资源;删除示例底部重复的「在线测试」。
- 示例工具栏:删除与目录锚点重复的示例下拉选择器;「全部展开代码」只在
  示例数 >1 时出现(103 个组件里 49 个仅 1 个示例,此前恒显示)。
- 重复文案:示例区两句同义导语合并为一句。
- 首页 CTA 由 5 个减为 2 个(浏览组件/快速开始),测试总览入口挂到已有的
  通过率统计卡上,不再另占 Hero 按钮。
- 统一详情取数:抽出 fetchDetail/loadDetail 作为 details/*.json 的唯一路径,
  FAQ 不再自行 fetch 一遍,与组件页共用缓存与失败兜底。

## i18n

- 删除 12 组重复键(含整段 FAQ 说明),字典 604 → 585 唯一键。
- 删除本次改动产生的 6 个死键。
- verify-i18n.mjs 新增 `unique dictionary keys` 断言:重复键在对象字面量里
  是静默的后值覆盖,此前无从发现;现由门禁拦住。

## 文档事实修正(实测为准)

- TESTING/CONTRIBUTING:79 → 103 组件;旧断言数改为回指 tests/report.json。
- PLATFORMS:移动端 108 文件/18 端 → 282 文件/47 端;契约 5 → 47;
  令牌 17 → 15;uni-app SFC 21 → 50。
- AGENTS:断言 1405/18 页 → 1464/103 页(PC)、807/47 页(移动端)。
- package.json:YOUR-ACCOUNT 占位 → gitea 实址与 kole-ui.mymoyu.top。

## 品牌标识(并行会话成果,一并入库)

- brand-mark.json 收归真源,build:brand 生成 favicon 与单色 SVG;
  PC 与移动端共用资产,verify:brand 24 条断言。
- regression.yml 增加 verify:brand 步骤。

## 门禁与验收

新增工具:verify-component-page.mjs(86 条真实浏览器断言,随详情页改造同步
更新为「只允许一套导航可见」)、verify-brand-mark.mjs、lib/i18n-dead-keys.mjs
(只读诊断)。

回归:PC 100%(1464/1464,103 页,N/A 50)· 移动端 100%(807/807,47 页)。
门禁:component-page 86 · i18n 17 · brand 24 · routes all · smoke all ·
theme OK · isolation 31 · nav all · examples 9 · api-docs 10 · mobile-docs 12。

已知未做:i18n 另有约 44 条历史死键(非本次产生),已记为 ROADMAP S8-P6;
顶栏与悬浮区的两个主题入口为刻意设计(verify-theme 断言其互斥),未删。
2026-09-22 23:56:05 +08:00

157 lines
10 KiB
JavaScript

#!/usr/bin/env node
import { chromium } from 'playwright';
import { readFileSync } from 'node:fs';
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const failures = [];
let checks = 0;
function check(ok, message) {
checks++;
if (!ok) failures.push(message);
console.log(`[component-page] ${ok ? 'OK' : 'FAIL'} ${message}`);
}
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => {
localStorage.setItem('kole-lang', 'zh');
localStorage.setItem('kole-mode', 'light');
});
async function ready(slug, suffix = 'h5') {
await page.goto(`${BASE}/site/component/${slug}/${suffix}`, { waitUntil: 'networkidle' });
await page.waitForFunction(() => {
const root = document.querySelector('.kole-component-page');
return root && root.dataset.examplesReady !== 'pending' && root.dataset.detailReady !== 'pending';
});
}
/* 宽屏(>1200px)右侧目录是唯一导航,窄屏它被隐藏、页内 sticky 导航接管。
active 状态与可达性都要按当前生效的那套来判,不能要求两套同时可见。 */
const visibleNav = () => page.evaluate(() => {
const toc = document.querySelector('#toc');
const localNav = document.querySelector('.kole-component-nav');
const tocVisible = !!toc && getComputedStyle(toc).display !== 'none' && toc.getBoundingClientRect().width > 0;
const navVisible = !!localNav && !localNav.hidden && localNav.getBoundingClientRect().width > 0;
return { tocVisible, navVisible };
});
try {
const served = await (await page.request.get(`${BASE}/site/app.js`)).text();
check(served === readFileSync(new URL('../site/app.js', import.meta.url), 'utf8'), 'preview serves the current workspace app.js');
for (const slug of ['button', 'input', 'table', 'modal', 'dragupload', 'icon']) {
await ready(slug);
const structure = await page.evaluate(() => {
const root = document.querySelector('.kole-component-page');
const sections = [...root.querySelectorAll(':scope > section')].map(x => x.id);
const ids = [...root.querySelectorAll('[id]')].map(x => x.id);
const anchors = [...document.querySelectorAll('#toc [data-anchor], .kole-component-nav [data-anchor]')];
const toc = [...document.querySelectorAll('#toc [data-anchor]')].map(x => document.getElementById(x.dataset.anchor));
return {
sections,
unique: new Set(ids).size === ids.length,
validAnchors: anchors.every(x => document.getElementById(x.dataset.anchor)),
tocOrdered: toc.every((x, i) => !i || !!(toc[i-1].compareDocumentPosition(x) & Node.DOCUMENT_POSITION_FOLLOWING)),
exampleCount: root.querySelectorAll('.ex-card').length,
openCode: root.querySelectorAll('.ex-code:not([hidden])').length,
hasApi: !!root.querySelector('#api'),
apiLink: !!root.querySelector('.kole-component-nav [data-anchor="api"]'),
/* #toc 在 .kole-component-page 之外(右侧栏),必须用文档级查询 ——
用 root.querySelector('#toc …') 会恒为 null,让这条断言变成假通过。 */
tocApiLink: !!document.querySelector('#toc [data-anchor="api"]'),
reference: !!root.querySelector('#design #spec .kole-spec-disclosure'),
wrappedTables: [...root.querySelectorAll('table')].every(x => x.parentElement.classList.contains('kole-doc-table')),
pickers: root.querySelectorAll('.kole-example-picker').length,
expandAll: root.querySelectorAll('.kole-expand-code').length
};
});
check(structure.sections[0] === 'demo' && structure.sections.includes('design'), `${slug}: examples precede reference material`);
check(structure.unique && structure.validAnchors && structure.tocOrdered, `${slug}: unique anchors and TOC match document order`);
check(structure.exampleCount === 0 || structure.openCode === 1, `${slug}: first example code is open, remaining code is folded`);
check(structure.hasApi === structure.tocApiLink, `${slug}: API navigation matches available data`);
check(structure.reference && structure.wrappedTables, `${slug}: specification and tables remain accessible`);
/* 入口减重:示例选择器已删(跳转由目录承担);
「全部展开代码」只在多示例时出现 —— 单示例时它没有可批量操作的对象。 */
check(structure.pickers === 0, `${slug}: the duplicate example picker is gone`);
check(structure.expandAll === (structure.exampleCount > 1 ? 1 : 0), `${slug}: expand-all only exists for multi-example pages (examples=${structure.exampleCount}, buttons=${structure.expandAll})`);
const nav = await visibleNav();
check(nav.tocVisible !== nav.navVisible, `${slug}: exactly one navigation is visible at 1440px (toc=${nav.tocVisible}, local=${nav.navVisible})`);
}
await ready('button');
const payload = JSON.parse(readFileSync(new URL('../site/examples/button.json', import.meta.url), 'utf8'));
const first = page.locator('.ex-card').first();
const firstId = await first.getAttribute('id');
await page.evaluate(() => document.querySelector('.ex-card').dataset.identity = 'retained');
await first.locator('.kole-example-toggle').focus();
await page.keyboard.press('Enter');
check(await first.locator('.ex-code').isHidden() && await first.locator('.kole-example-toggle').getAttribute('aria-expanded') === 'false', 'keyboard folds the code with accurate aria-expanded');
await page.locator('.kole-expand-code').click();
check(await page.locator('.ex-code[hidden]').count() === 0, 'expand all opens every usage snippet');
await first.locator('.ex-tabs [data-kind="jsx"]').click();
check((await first.locator('code').textContent()) === payload.examples[0].code.jsx, 'React tab shows the exact generated usage snippet');
check(await first.locator('.ex-tabs [aria-pressed="true"]').count() === 1, 'exactly one example stack is selected');
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await first.locator('.code-copy').click();
await page.waitForFunction(() => document.querySelector('.ex-card .code-copy').textContent.includes('已复制'));
const copied = await page.evaluate(() => navigator.clipboard.readText());
// Windows clipboard normalizes LF to CRLF; only normalize line endings, not text or whitespace.
// A missing prop, different stack or altered indentation must still fail this exact comparison.
check(copied.replace(/\r\n/g, '\n') === payload.examples[0].code.jsx.replace(/\r\n/g, '\n'), 'copy uses the active stack snippet');
await page.locator('.kole-expand-code').click();
check(await page.locator('.ex-code:not([hidden])').count() === 0, 'collapse all folds every snippet');
/* 宽屏用右侧目录做节间跳转(页内 sticky 导航此时隐藏) */
await page.locator('#toc [data-anchor="api"]').click();
await page.waitForFunction(() => document.querySelector('#toc [data-anchor="api"]').getAttribute('aria-current') === 'location');
check(await page.locator('.ex-card').first().getAttribute('data-identity') === 'retained', 'section navigation does not recreate previews');
const apiTop = await page.locator('#api').evaluate(x => x.getBoundingClientRect().top);
check(apiTop >= 0 && apiTop < 180, `API heading lands below the top bar after anchor navigation (top=${Math.round(apiTop)})`);
const lastId = payload.examples[payload.examples.length - 1].id;
await page.locator(`#toc [data-anchor="${lastId}"]`).click();
await page.waitForFunction(id => {
const node = document.getElementById(id);
return node && node.getBoundingClientRect().top < 260;
}, lastId);
check(new URL(page.url()).hash === '#' + lastId, 'example TOC link creates a shareable section anchor');
await page.reload({ waitUntil: 'networkidle' });
await page.waitForFunction(id => {
const node = document.getElementById(id);
return node && node.getBoundingClientRect().top >= 0 && node.getBoundingClientRect().top < 260;
}, lastId);
check(true, 'example deep link survives reload and async content');
await page.locator('#toc [data-anchor="spec"]').click();
check(await page.locator('.kole-spec-disclosure').getAttribute('open') !== null, 'specification TOC link opens the disclosure');
await ready('button', 'react');
check(await page.locator('.ex-card').first().locator('.ex-tabs [aria-pressed="true"]').getAttribute('data-kind') === 'jsx', 'framework deep link selects matching usage code');
await page.locator('#lang-trigger').click();
await page.locator('#lang-menu [data-lang="en"]').click();
check((await page.locator('#toc').textContent()).includes('Design & resources'), 'English section labels are translated in the on-page TOC');
check(await page.locator('.ex-card').first().locator('.ex-tabs [aria-pressed="true"]').getAttribute('data-kind') === 'jsx', 'language switch preserves framework');
/* 窄屏:右侧目录隐藏,页内 sticky 导航接管 —— 它必须仍然完整可达 */
for (const width of [320, 375, 768, 1024, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 });
const layout = await page.evaluate(() => ({
overflow: document.documentElement.scrollWidth - innerWidth,
nav: [...document.querySelectorAll('.kole-component-nav a')].every(x => {
const r = x.getBoundingClientRect();
return r.width > 0 && r.left >= 0 && r.right <= innerWidth;
}),
tables: [...document.querySelectorAll('.kole-doc-table')].every(x => x.getBoundingClientRect().width <= innerWidth)
}));
check(layout.overflow <= 1, `English ${width}px: no page overflow (overflow=${layout.overflow})`);
check(layout.tables, `English ${width}px: data tables stay inside the viewport`);
const nav = await visibleNav();
check(nav.tocVisible !== nav.navVisible, `English ${width}px: exactly one navigation is visible (toc=${nav.tocVisible}, local=${nav.navVisible})`);
if (nav.navVisible) check(layout.nav, `English ${width}px: every local-navigation control is reachable`);
}
check(errors.length === 0, `no uncaught page errors${errors.length ? ': ' + errors.join('; ') : ''}`);
} finally {
await browser.close();
}
if (failures.length) {
console.error(`[component-page] FAIL — ${failures.length}/${checks} checks failed`);
process.exit(1);
}
console.log(`[component-page] OK — ${checks} checks passed`);