Files
AskaEth db94d9c119 test(web): 补两类守卫,专抓「组件 setup 期抛异常」这类既有测试抓不到的问题
背景:基础数据「运动员」页整块空白的根因是 setup 期 TDZ(watch 注册时同步求值,
间接引用到后面才声明的 const),vue-tsc 与 449 条单元测试都没抓到——组件从不真正挂载。

1) scripts/check-declaration-order.mjs + src/shared/tests/declaration-order.test.ts
   静态检查:从 watch/watchEffect 的源出发沿 const 依赖链做传递闭包,
   任何声明行晚于求值行的 const 即判违规。已随既有测试命令运行(纯 node,无需浏览器)。
   变异验证:把 AthletesSection 退回 ab7662b 的写法 → 精确报
   「AthletesSection.vue:84 会求值到 filtered,但它声明在第 272 行」。

2) scripts/smoke-admin.mjs(pnpm smoke)
   真 Chromium 冒烟:14 个管理端路由 + 7 个基础数据分区 + 大屏页,
   任何 console 错误或未捕获异常即失败并给出路由名。
   变异验证:同一份代码下修复版通过、退回版报
   「/base-data › 运动员 ← ReferenceError: Cannot access 'Y' before initialization」。
   playwright 不是本仓库依赖(离线开发装不上),脚本缺依赖时会明确报错退出而不是假装通过。
2026-09-11 22:58:19 +08:00

112 lines
4.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 错误与未捕获异常。`)