#!/usr/bin/env node /** * 管理端冒烟:用真实 Chromium 把每个页面与每个标签点一遍,任何 console 错误或 * 未捕获异常都算失败。 * * 为什么需要它:单元测试(纯函数 + 源码断言)与 vue-tsc 都抓不到「组件 setup 期 * 抛异常」这类问题 —— 例如 AthletesSection 里 filteredIds 引用了后面才声明的 * filtered,watch 注册时同步求值触发 TDZ,Vue 只在 console 里报 * `ReferenceError: Cannot access 'x' before initialization`,页面则整块空白。 * 该缺陷自 631c8dd 起在 dev_0.1.6 / 0.1.7 / 0.1.8 三个版本里都没被发现。 * * 用法(需要本机有 playwright 与 chromium): * node scripts/smoke-admin.mjs # 默认打 http://127.0.0.1:19540 * node scripts/smoke-admin.mjs http://127.0.0.1:19777 * node scripts/smoke-admin.mjs --headed # 看得到窗口,便于排查 * * 注意:本仓库离线开发,playwright 不是 package.json 的依赖(装不上)。 * 若 import 失败,用 NODE_PATH 或 symlink 指向本机已有的 playwright 即可: * ln -s /path/to/playwright/node_modules ./node_modules * 脚本自身会因为缺依赖而给出明确提示,不会伪装成通过。 */ import { pathToFileURL } from 'node:url' const ADMIN_ROUTES = [ 'dashboard', 'entry', 'review', 'records', 'adjustments', 'standings', 'display', 'base-data', 'export', 'history', 'logs', 'system', 'password', 'about', ] /** 基础数据页的七个分区标签;每个都必须能点开并渲染出内容。 */ const BASE_DATA_TABS = ['参赛单位', '大项 / 小项', '组别', '计分通道', '积分模板', '运动员', '报名导入'] /** 这些 console 消息是环境噪声,不算失败:访客身份下的 403(系统设置/日志是 admin-only)。 */ const IGNORED = [/Failed to load resource/, /status of 403/, /status of 401/] function isIgnored(text) { return IGNORED.some((re) => re.test(text)) } async function loadPlaywright() { try { return await import('playwright') } catch { try { // 允许用 NODE_PATH 指定已有的 playwright const mod = await import(pathToFileURL(`${process.env.PLAYWRIGHT_DIR ?? ''}/index.js`).href) return mod } catch { console.error('找不到 playwright。请在装有 playwright 的环境里运行,或用 NODE_PATH / PLAYWRIGHT_DIR 指过去。') process.exit(2) } } } const args = process.argv.slice(2) const headed = args.includes('--headed') const base = (args.find((a) => a.startsWith('http')) ?? 'http://127.0.0.1:19540').replace(/\/$/, '') const { chromium } = await loadPlaywright() const browser = await chromium.launch({ headless: !headed, args: ['--no-sandbox'] }) const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) const failures = [] let current = '' page.on('pageerror', (e) => failures.push(`${current} ← ${e.message.split('\n')[0]}`)) page.on('console', (m) => { const text = m.text() if (m.type() === 'error' && !isIgnored(text)) failures.push(`${current} ← ${text.split('\n')[0]}`) }) async function visit(name, hash, after) { current = name await page.goto(`${base}/admin#/${hash}`, { waitUntil: 'networkidle' }) await page.waitForTimeout(900) if (after) await after() } for (const route of ADMIN_ROUTES) { await visit(`/${route}`, route) } await visit('/base-data (各分区)', 'base-data', async () => { for (const tab of BASE_DATA_TABS) { current = `/base-data › ${tab}` await page.getByRole('button', { name: tab, exact: true }).click() await page.waitForTimeout(800) const rendered = await page.evaluate(() => document.querySelector('section.mx-card') !== null) // 报名导入分区没有 mx-card,用页面文本兜底判断 const text = (await page.evaluate(() => document.querySelector('main')?.innerText ?? '')).trim() if (!rendered && text.length < 20) failures.push(`${current} ← 分区没有渲染出任何内容`) } current = '/base-data' }) const display = await browser.newPage({ viewport: { width: 1920, height: 1080 } }) display.on('pageerror', (e) => failures.push(`/display ← ${e.message.split('\n')[0]}`)) display.on('console', (m) => { const text = m.text() if (m.type() === 'error' && !isIgnored(text)) failures.push(`/display ← ${text.split('\n')[0]}`) }) current = '/display' await display.goto(`${base}/display`, { waitUntil: 'networkidle' }) await display.waitForTimeout(2000) await browser.close() if (failures.length > 0) { console.error(`冒烟失败:${failures.length} 条`) for (const f of failures) console.error(' - ' + f) process.exit(1) } console.log(`冒烟通过:${ADMIN_ROUTES.length} 个管理端路由 + ${BASE_DATA_TABS.length} 个基础数据分区 + 大屏页,均无 console 错误与未捕获异常。`)