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