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 不是本仓库依赖(离线开发装不上),脚本缺依赖时会明确报错退出而不是假装通过。
This commit is contained in:
1 parent
8a27f5a4fb
commit
db94d9c119
4 files changed
+336
-1
No files matched your search
+2
-1
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"smoke": "node scripts/smoke-admin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^4.0.3",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 声明顺序守卫:抓「setup 期就会求值的表达式(含它的间接依赖)引用了后面才声明的 const」这一类 TDZ。
|
||||
*
|
||||
* 背景:AthletesSection.vue 里 `filteredIds = computed(() => filtered.value...)` 引用了
|
||||
* 199 行之后才声明的 `filtered`,而 `watch(filteredIds, ...)` 在注册时会**同步求值一次源** ——
|
||||
* setup 阶段就访问到 TDZ 中的 `filtered`,抛
|
||||
* `ReferenceError: Cannot access 'filtered' before initialization`,整个分区渲染失败、
|
||||
* 页面只剩标签栏。这个缺陷躲过了 vue-tsc 与全部单元测试(没有 jsdom,组件从不真正挂载),
|
||||
* 在 dev_0.1.6 / 0.1.7 / 0.1.8 三个版本里都没被发现,所以单独做一个静态检查。
|
||||
*
|
||||
* 规则(保守,只报确定会炸的):
|
||||
* 1. `watch(...)` / `watchEffect(...)` 是**注册时同步求值**的(Vue 要取初始值);
|
||||
* 2. 从它的第一个实参出发,沿「const 初始化式里引用了哪些 const」这条边做**传递闭包**;
|
||||
* 3. 闭包里任何 const 的声明行落在 watch 调用行**之后**,就是一次必然的 TDZ。
|
||||
* 直接依赖与间接依赖都要查 —— 只查直接依赖会漏掉本次事故(引用的 filteredIds 声明在前面)。
|
||||
*
|
||||
* 只扫 `<script setup>` / `.ts` 模块顶层;`var`/`function` 有提升,不参与判断。
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
|
||||
/** 从源码里抠出需要检查的脚本片段(.vue 取 <script setup> 块,.ts 整份)。 */
|
||||
export function scriptBlocksOf(fileName, source) {
|
||||
if (!fileName.endsWith('.vue')) return [{ offsetLine: 0, code: source }]
|
||||
const match = /<script setup[^>]*>([\s\S]*?)<\/script>/.exec(source)
|
||||
if (!match) return []
|
||||
const before = source.slice(0, match.index)
|
||||
return [{ offsetLine: before.split('\n').length - 1, code: match[1] }]
|
||||
}
|
||||
|
||||
/** 取 `=` 之后的初始化表达式文本:按括号深度扫描,回到深度 0 且遇到换行即结束。 */
|
||||
function initializerOf(code, from) {
|
||||
let depth = 0
|
||||
let i = from
|
||||
for (; i < code.length; i += 1) {
|
||||
const ch = code[i]
|
||||
if (ch === '(' || ch === '[' || ch === '{') depth += 1
|
||||
else if (ch === ')' || ch === ']' || ch === '}') {
|
||||
if (depth === 0) break
|
||||
depth -= 1
|
||||
} else if (ch === '\n' && depth === 0) break
|
||||
}
|
||||
return code.slice(from, i)
|
||||
}
|
||||
|
||||
/** 收集 `const NAME = ...`:声明行(1 基,含 offsetLine)与初始化式里引用到的标识符。 */
|
||||
export function constDeclarations(code, offsetLine = 0) {
|
||||
const out = new Map()
|
||||
const re = /^const\s+([A-Za-z_$][\w$]*)\s*=/gm
|
||||
let m
|
||||
while ((m = re.exec(code)) !== null) {
|
||||
const name = m[1]
|
||||
const line = offsetLine + code.slice(0, m.index).split('\n').length
|
||||
const init = initializerOf(code, m.index + m[0].length)
|
||||
const refs = new Set()
|
||||
for (const id of init.matchAll(/(\.)?\b([A-Za-z_$][\w$]*)\b/g)) {
|
||||
if (id[1] === '.') continue // 属性访问里的名字不是变量
|
||||
refs.add(id[2])
|
||||
}
|
||||
if (!out.has(name)) out.set(name, { line, refs })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 找「注册时同步求值」的调用点,返回 [{ expr, line }]。 */
|
||||
export function eagerWatchRefs(code, offsetLine = 0) {
|
||||
const out = []
|
||||
const re = /\bwatch(?:Effect)?\s*\(/g
|
||||
let m
|
||||
while ((m = re.exec(code)) !== null) {
|
||||
const start = m.index + m[0].length
|
||||
let depth = 0
|
||||
let end = start
|
||||
for (let i = start; i < code.length; i += 1) {
|
||||
const ch = code[i]
|
||||
if (ch === '(' || ch === '[' || ch === '{') depth += 1
|
||||
else if (ch === ')' || ch === ']' || ch === '}') {
|
||||
if (depth === 0) break
|
||||
depth -= 1
|
||||
} else if (ch === ',' && depth === 0) break
|
||||
end = i
|
||||
}
|
||||
const line = offsetLine + code.slice(0, m.index).split('\n').length
|
||||
out.push({ expr: code.slice(start, end + 1), line })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 从一组标识符出发,沿 const 依赖边做传递闭包。 */
|
||||
function closureOf(seeds, decls) {
|
||||
const seen = new Set()
|
||||
const stack = [...seeds]
|
||||
while (stack.length > 0) {
|
||||
const name = stack.pop()
|
||||
if (seen.has(name)) continue
|
||||
const decl = decls.get(name)
|
||||
if (!decl) continue
|
||||
seen.add(name)
|
||||
for (const ref of decl.refs) if (!seen.has(ref)) stack.push(ref)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
/** 返回违规列表 [{ file, line, name, declaredAt }]。 */
|
||||
export function findOrderViolations(fileName, source) {
|
||||
const violations = []
|
||||
for (const block of scriptBlocksOf(fileName, source)) {
|
||||
const decls = constDeclarations(block.code, block.offsetLine)
|
||||
for (const { expr, line } of eagerWatchRefs(block.code, block.offsetLine)) {
|
||||
const seeds = []
|
||||
for (const id of expr.matchAll(/(\.)?\b([A-Za-z_$][\w$]*)\b/g)) {
|
||||
if (id[1] !== '.') seeds.push(id[2])
|
||||
}
|
||||
for (const name of closureOf(seeds, decls)) {
|
||||
const decl = decls.get(name)
|
||||
if (decl.line > line) violations.push({ file: fileName, line, name, declaredAt: decl.line })
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/** 递归列出目录下的 .ts / .vue(跳过依赖与构建产物)。 */
|
||||
export function sourceFilesUnder(root) {
|
||||
const out = []
|
||||
const skip = new Set(['node_modules', 'dist', '.git', 'assets', '__snapshots__'])
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (skip.has(entry)) continue
|
||||
const full = join(dir, entry)
|
||||
const st = statSync(full)
|
||||
if (st.isDirectory()) walk(full)
|
||||
else if (
|
||||
(entry.endsWith('.ts') || entry.endsWith('.vue')) &&
|
||||
// 测试文件里会有意内联「反面样例」源码字符串,扫它们只会自伤
|
||||
!entry.endsWith('.test.ts')
|
||||
) {
|
||||
out.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 供测试与命令行共用:扫一个根目录,返回带相对路径的违规列表。 */
|
||||
export function scanWebSources(root) {
|
||||
const violations = []
|
||||
for (const file of sourceFilesUnder(root)) {
|
||||
for (const v of findOrderViolations(relative(root, file), readFileSync(file, 'utf8'))) violations.push(v)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const root = process.argv[2] ?? new URL('../src', import.meta.url).pathname
|
||||
const violations = scanWebSources(root)
|
||||
if (violations.length === 0) {
|
||||
console.log(`声明顺序检查通过:${root} 下没有「注册期求值引用后声明 const」的写法。`)
|
||||
} else {
|
||||
console.error(`声明顺序检查失败:${violations.length} 处`)
|
||||
for (const v of violations) {
|
||||
console.error(` - ${v.file}:${v.line} 会求值到 ${v.name},但它声明在第 ${v.declaredAt} 行`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/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 错误与未捕获异常。`)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { scanWebSources, findOrderViolations } from '../../../scripts/check-declaration-order.mjs'
|
||||
|
||||
/**
|
||||
* 声明顺序守卫(见 scripts/check-declaration-order.mjs 的说明)。
|
||||
*
|
||||
* 守护的事故:AthletesSection 的 `watch(filteredIds, ...)` 会同步求值一次,
|
||||
* 而 filteredIds 的初始化式引用了后面才声明的 filtered → setup 抛 TDZ,
|
||||
* 页面整块空白。这个缺陷躲过了 vue-tsc 与全部既有单元测试(组件从不真正挂载)。
|
||||
*/
|
||||
test('注册期求值的表达式不得引用后面才声明的 const(间接依赖也要查)', () => {
|
||||
const broken = `<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
const filteredIds = computed(() => filtered.value.map((a) => a.id))
|
||||
watch(filteredIds, () => {})
|
||||
const filtered = computed(() => [])
|
||||
</script>`
|
||||
const violations = findOrderViolations('Broken.vue', broken)
|
||||
assert.ok(violations.length > 0, '应当报出 watch 求值到后声明的 filtered')
|
||||
assert.equal(violations[0].name, 'filtered')
|
||||
assert.ok(violations[0].declaredAt > violations[0].line, '声明行必须晚于求值行')
|
||||
})
|
||||
|
||||
test('声明顺序正确时不报', () => {
|
||||
const fixed = `<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
const filtered = computed(() => [])
|
||||
const filteredIds = computed(() => filtered.value.map((a) => a.id))
|
||||
watch(filteredIds, () => {})
|
||||
</script>`
|
||||
assert.deepEqual(findOrderViolations('Fixed.vue', fixed), [])
|
||||
})
|
||||
|
||||
test('函数局部的 const 不算违规(只看脚本块顶层的声明)', () => {
|
||||
const localConst = `<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
const source = ref(0)
|
||||
const ids = computed(() => {
|
||||
const local = source.value * 2
|
||||
return [local]
|
||||
})
|
||||
watch(ids, () => {})
|
||||
</script>`
|
||||
assert.deepEqual(findOrderViolations('Local.vue', localConst), [])
|
||||
})
|
||||
|
||||
test('整个 web/src 目录当前没有这类写法', () => {
|
||||
const root = new URL('../../', import.meta.url).pathname
|
||||
const violations = scanWebSources(root)
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
violations.map((v) => `${v.file}:${v.line} 会求值到 ${v.name}(声明于第 ${v.declaredAt} 行)`).join('\n'),
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user