#!/usr/bin/env node /** * verify-templates.mjs — 模板页库(S4-P11)浏览器实测。 * * 前置:仓库根目录起 dev-server(node site/dev-server.js),或设 REG_BASE 指向已跑的实例。 * * 为什么必须真跑:模板页的价值在于「交互可用」——筛选、分页、提交反馈、抽屉、开关 * 这些都靠 JS 驱动,静态检查只能证明文件里有那几行。这里逐页走真实交互, * 并核对「令牌生效」(组件视觉来自 colors_and_type.css / components.css,而不是自造样式)。 */ import { join } from 'node:path'; const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; const failures = []; const checks = []; function check(name, ok, detail = '') { checks.push({ name, ok, detail }); if (!ok) failures.push(`${name}${detail ? ` — ${detail}` : ''}`); } let chromium; try { ({ chromium } = await import('playwright')); } catch { console.error('[templates] 未安装 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(`[templates] 无法连接 ${BASE}/site/data.json — ${e.message}`); console.error(' 请先在仓库根目录运行:node site/dev-server.js'); process.exit(2); } const PAGES = [ { slug: 'login', title: '登录页' }, { slug: 'dashboard', title: '数据看板' }, { slug: 'order-list', title: '订单列表' }, { slug: 'settings', title: '系统设置' }, ]; const browser = await chromium.launch(); for (const spec of PAGES) { const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); 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()}`); }); await page.goto(`${BASE}/site/scenario/${spec.slug}.html`, { waitUntil: 'networkidle', timeout: 20000 }); const tag = `[${spec.slug}]`; /* 令牌与组件样式必须真的加载并生效 */ const tokenOk = await page.evaluate(() => { const v = getComputedStyle(document.documentElement).getPropertyValue('--kole-color-brand').trim(); const body = getComputedStyle(document.body).backgroundColor; return { brand: v, bodyBg: body }; }); check(`${tag} 令牌加载`, tokenOk.brand !== '', `--kole-color-brand = "${tokenOk.brand}"`); /* 页面有可见内容(不是白屏) */ const mainBox = await page.locator('main, .lg-shell, .db-shell').first().boundingBox(); check(`${tag} 非白屏`, Boolean(mainBox && mainBox.height > 200), mainBox ? `高度 ${Math.round(mainBox.height)}px` : '主体不可见'); check(`${tag} 零控制台错误`, consoleErrors.length === 0, consoleErrors.join(' | ')); check(`${tag} 零 4xx/5xx`, badResponses.length === 0, badResponses.join(' | ')); /* ── 逐页交互 ───────────────────────────────────────── */ if (spec.slug === 'login') { /* 空提交 → 两条字段级错误提示 */ await page.locator('#lg-submit').click(); const errs = await page.locator('#lg-account-err, #lg-password-err').evaluateAll((els) => els.filter((e) => !e.hidden).map((e) => e.textContent.trim())); check(`${tag} 空提交报错`, errs.length === 2, JSON.stringify(errs)); /* 格式错误 → 具体可操作的提示 */ await page.locator('#lg-account').fill('not-an-email'); await page.locator('#lg-password').fill('short'); await page.locator('#lg-submit').click(); const accErr = await page.locator('#lg-account-err').textContent(); const pwdErr = await page.locator('#lg-password-err').textContent(); check(`${tag} 账号格式提示`, accErr.includes('邮箱') || accErr.includes('手机号'), accErr.trim()); check(`${tag} 密码长度提示`, pwdErr.includes('8'), pwdErr.trim()); /* 密码可见性切换 */ await page.locator('#lg-eye').click(); const typeAfter = await page.locator('#lg-password').getAttribute('type'); check(`${tag} 密码显隐切换`, typeAfter === 'text', `type=${typeAfter}`); await page.locator('#lg-eye').click(); /* 正确凭据 → 成功态 */ await page.locator('#lg-account').fill('demo@kole.ui'); await page.locator('#lg-password').fill('koleui123'); await page.locator('#lg-submit').click(); await page.waitForSelector('#lg-done:not([hidden])', { timeout: 5000 }); const doneText = await page.locator('#lg-done-sub').textContent(); check(`${tag} 提交成功反馈`, doneText.includes('demo@kole.ui'), doneText.trim()); /* 锁定账号 → 业务错误 */ await page.locator('#lg-retry').click(); await page.locator('#lg-account').fill('locked@kole.ui'); await page.locator('#lg-password').fill('koleui123'); await page.locator('#lg-submit').click(); const lockedErr = await page.locator('#lg-account-err').textContent(); check(`${tag} 账号锁定反馈`, lockedErr.includes('锁定'), lockedErr.trim()); } if (spec.slug === 'dashboard') { /* 指标卡有真实数字 */ const m1 = await page.locator('#db-m1').textContent(); check(`${tag} 指标卡渲染`, /¥[\d,]+/.test(m1), m1.trim()); /* 图表有数据点 */ const shapes = await page.locator('#db-svg rect, #db-svg polyline, #db-svg circle').count(); check(`${tag} 图表渲染`, shapes > 10, `${shapes} 个图元`); /* 切换时间范围 → 数字变化 */ await page.locator('.kole-segmented-item[data-range="30"]').click(); const m2 = await page.locator('#db-m1').textContent(); check(`${tag} 范围切换生效`, m2 !== m1, `${m1.trim()} → ${m2.trim()}`); /* 面板折叠 */ await page.locator('#db-panel-toggle').click(); const collapsed = await page.locator('#db-panel').evaluate((el) => el.classList.contains('is-collapsed')); check(`${tag} 图表面板折叠`, collapsed === true); /* 分页 */ const before = await page.locator('#db-tbody tr').first().textContent(); await page.locator('#db-pager button', { hasText: '下一页' }).click(); const after = await page.locator('#db-tbody tr').first().textContent(); check(`${tag} 分页切页`, before !== after, `第 1 页首行 → ${after.trim().slice(0, 20)}`); /* 导出反馈 */ await page.locator('#db-export').click(); await page.waitForSelector('.kole-message', { timeout: 5000 }); check(`${tag} 导出反馈`, true); } if (spec.slug === 'order-list') { const total = await page.locator('#ol-total').textContent(); check(`${tag} 表格渲染`, Number(total) === 37, `共 ${total} 条`); /* 筛选:状态 = 已完成 */ await page.locator('#ol-status').selectOption('已完成'); await page.locator('#ol-filter button[type="submit"]').click(); const filtered = await page.locator('#ol-total').textContent(); const tags = await page.locator('#ol-tbody .kole-tag').allTextContents(); check(`${tag} 筛选生效`, tags.length > 0 && tags.every((t) => t.trim() === '已完成'), `命中 ${filtered} 条,状态 ${[...new Set(tags.map((t) => t.trim()))].join('/')}`); /* 空结果 → 空状态 */ await page.locator('#ol-kw').fill('ZZZ-NOT-EXIST'); await page.locator('#ol-filter button[type="submit"]').click(); const emptyVisible = await page.locator('#ol-empty').evaluate((el) => el.classList.contains('is-open')); check(`${tag} 空状态显示`, emptyVisible === true); /* 清空筛选 */ await page.locator('#ol-empty-reset').click(); const restored = await page.locator('#ol-total').textContent(); check(`${tag} 清空筛选恢复`, Number(restored) === 37, `恢复 ${restored} 条`); /* 全选 → 批量条出现 */ await page.locator('#ol-check-all').check(); const barOpen = await page.locator('#ol-batchbar').evaluate((el) => el.classList.contains('is-open')); const selN = await page.locator('#ol-sel-count').textContent(); check(`${tag} 全选与批量条`, barOpen && Number(selN) === 8, `已选 ${selN} 项`); /* 批量审核 → 状态流转 */ await page.locator('#ol-batch-approve').click(); await page.waitForSelector('.kole-message', { timeout: 5000 }); const msg = await page.locator('.kole-message-content').textContent(); check(`${tag} 批量审核反馈`, msg.includes('已审核') || msg.includes('没有待审核'), msg.trim()); /* 详情抽屉 */ await page.locator('#ol-tbody .ol-detail').first().click(); const drawerOpen = await page.locator('#ol-mask').evaluate((el) => el.classList.contains('is-open')); check(`${tag} 详情抽屉打开`, drawerOpen === true); const dlCount = await page.locator('#ol-drawer-dl dt').count(); check(`${tag} 抽屉字段渲染`, dlCount === 7, `${dlCount} 个字段`); await page.keyboard.press('Escape'); const drawerClosed = await page.locator('#ol-mask').evaluate((el) => !el.classList.contains('is-open')); check(`${tag} Esc 关闭抽屉`, drawerClosed === true); /* 每页条数 */ await page.locator('#ol-size').selectOption('5'); const rows = await page.locator('#ol-tbody tr').count(); check(`${tag} 每页条数生效`, rows === 5, `${rows} 行`); } if (spec.slug === 'settings') { /* 分区切换 */ await page.locator('#st-nav button[data-panel="security"]').click(); const secVisible = await page.locator('#st-panel-security').isVisible(); const basicHidden = await page.locator('#st-panel-basic').isHidden(); check(`${tag} 分区切换`, secVisible && basicHidden); /* 开关即时生效 */ const sw = page.locator('#st-panel-security .kole-switch-switch').nth(1); const before = await sw.getAttribute('aria-checked'); await sw.click(); const after = await sw.getAttribute('aria-checked'); check(`${tag} 开关切换`, before === 'false' && after === 'true', `${before} → ${after}`); /* IP 白名单联动(属于保存式表单,会标脏) */ await page.locator('#st-ip-on').check(); const areaEnabled = await page.locator('#st-ip').isEnabled(); check(`${tag} IP 白名单联动`, areaEnabled === true); /* 脏检查 → 保存。先撤销上一步的勾选,让基线回到干净态, 顺便验证「改回原值 = 自动回到干净态」这个快照式行为。 */ await page.locator('#st-ip-on').uncheck(); const backClean = await page.locator('#st-save').isDisabled(); check(`${tag} 改回原值回到干净态`, backClean === true); await page.locator('#st-nav button[data-panel="basic"]').click(); const saveDisabledBefore = await page.locator('#st-save').isDisabled(); await page.locator('#st-name').fill('测试企业名称'); const saveEnabled = await page.locator('#st-save').isEnabled(); const dirtyVisible = await page.locator('#st-dirty').isVisible(); check(`${tag} 脏检查启用保存`, saveDisabledBefore && saveEnabled && dirtyVisible); await page.locator('#st-save').click(); /* 保存有 700ms 的模拟延迟:等按钮文案回到「保存」再断言, 否则读到的是上一条开关提示,且后续断言会基于保存前的基线。 */ await page.waitForFunction(() => { const b = document.getElementById('st-save'); return b && b.textContent.trim() === '保存' && b.disabled; }, { timeout: 5000 }); /* 重开一次 toast,确保下面读到的是保存反馈而不是更早的开关提示 */ await page.waitForFunction(() => { const m = document.querySelector('.kole-message-content'); return m && m.textContent.includes('已保存'); }, { timeout: 5000 }); const savedMsg = await page.locator('.kole-message-content').textContent(); check(`${tag} 保存反馈`, savedMsg.includes('已保存'), savedMsg.trim()); const saveDisabledAfter = await page.locator('#st-save').isDisabled(); check(`${tag} 保存后回到干净态`, saveDisabledAfter === true); /* 放弃修改:回到「上一次保存」的值,而不是硬编码的初始值 */ await page.locator('#st-name').fill('改坏了'); await page.locator('#st-reset').click(); const restored = await page.locator('#st-name').inputValue(); check(`${tag} 放弃修改回到上次保存值`, restored === '测试企业名称', restored); } /* ── 响应式:无横向溢出 ─────────────────────────────── */ await page.setViewportSize({ width: 375, height: 800 }); await page.waitForTimeout(150); const overflow375 = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); check(`${tag} 375px 无横向溢出`, overflow375 <= 1, `溢出 ${overflow375}px`); await page.setViewportSize({ width: 1024, height: 800 }); await page.waitForTimeout(150); const overflow1024 = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); check(`${tag} 1024px 无横向溢出`, overflow1024 <= 1, `溢出 ${overflow1024}px`); await page.close(); } await browser.close(); for (const c of checks) { console.log(`[templates] ${c.ok ? 'OK' : 'FAIL'} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); } if (failures.length) { console.error(`\n[templates] ${failures.length} check(s) failed`); process.exit(1); } console.log(`\n[templates] OK — ${checks.length} checks passed`);