背景:基础数据「运动员」页整块空白的根因是 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 不是本仓库依赖(离线开发装不上),脚本缺依赖时会明确报错退出而不是假装通过。
112 lines
4.8 KiB
JavaScript
112 lines
4.8 KiB
JavaScript
#!/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 错误与未捕获异常。`)
|