196 lines
8.1 KiB
TypeScript
196 lines
8.1 KiB
TypeScript
import { readdirSync, readFileSync } from 'node:fs'
|
||
import { join, relative, resolve } from 'node:path'
|
||
import { parse } from 'vue/compiler-sfc'
|
||
import { describe, expect, it } from 'vitest'
|
||
import * as UI from '../src'
|
||
|
||
/**
|
||
* 文档示例的「组件 prop 契约」校验。
|
||
*
|
||
* 起因:docs/examples 里一度有 39 处 `<G3Space direction="vertical">`,而 G3Space 的方向
|
||
* API 是 `orientation` / `vertical`。Vue 对**单根组件**的未知 attribute 既不报错也不警告,
|
||
* 只是把它透传到 DOM —— 于是那些示例静默按默认的「横排」渲染,3 个 `width: 100%` 的输入框
|
||
* 挤成一行、总宽撑爆 demo 容器,一直没人发现。
|
||
*
|
||
* 这里把「示例模板里传给 G3 组件的 attribute」和「组件自己声明的 props」对一遍,
|
||
* 让这类笔误在 `pnpm test` 阶段就暴露。
|
||
*
|
||
* 两个刻意的实现选择:
|
||
* - 组件侧的事实源是**运行时 props**(`defineProps<Props>()` 由编译器从 types.ts 生成),
|
||
* 所以 import 真实组件读 `.props`,而不是去解析 TS 类型 —— 后者会漏掉「类型文件改了但
|
||
* 没重新解析」这类情况(见 CODEBUDDY.md 里那条 dev server 缓存陷阱)。
|
||
* - 模板用 vue/compiler-sfc 解析成 AST,不用正则:示例里存在跨行标签和属性值里带 `>` 的
|
||
* 写法,正则必然误判。
|
||
*
|
||
* 本文件放在 packages/ui 而非 docs,是因为仓库只有 ui 配了测试跑器(docs 没有 vitest)。
|
||
* 代价是 ui 的单测会读 docs 目录 —— 路径不存在时下面的第一个用例会失败,不会静默跳过。
|
||
*/
|
||
|
||
// 用 cwd 而不是 import.meta.url:happy-dom 环境下后者是 http:// 而非 file://,
|
||
// fileURLToPath 会直接抛「The URL must be of scheme file」。
|
||
// vitest 的 cwd 就是 packages/ui(见 package.json 的 test 脚本),故固定往上两级。
|
||
const EXAMPLES_DIR = resolve(process.cwd(), '../../docs/examples')
|
||
|
||
/**
|
||
* 与组件自身 props 无关、任何元素上都合法的 attribute。
|
||
* 这些都是 `$attrs` 的正常用法:落到组件根元素上(class/style/key/ref)或参与渲染控制。
|
||
* 另有按前缀放行的 `data-*` / `aria-*`(原生无障碍与测试钩子,见下方判定)。
|
||
* 注意**不要**往这里加组件业务属性 —— 加一个就少拦一类笔误。
|
||
*/
|
||
const ALWAYS_ALLOWED = new Set(['class', 'style', 'id', 'key', 'ref', 'slot', 'is', 'role', 'tabindex', 'title'])
|
||
|
||
/**
|
||
* 「组件没声明、但落到根元素上确实生效」的 attribute,按组件登记。
|
||
* 只在这些组件没有 `inheritAttrs: false`(全库只有 ConfigProvider 设了)且根元素恰好
|
||
* 是语义元素时成立 —— 此时 `$attrs` 的自动透传是有意义的,不算笔误。
|
||
* 登记前必须确认根元素,否则就是把真 bug 洗白。
|
||
*/
|
||
const PASS_THROUGH: Record<string, Set<string>> = {
|
||
// 根元素是 `<component :is="href ? 'a' : 'span'">`,带 href 时就是 <a>:target / rel 直接生效
|
||
G3Tag: new Set(['target', 'rel']),
|
||
}
|
||
|
||
/**
|
||
* 已知的「组件未声明、示例在用」用法。每一项都是**待修问题,不是白名单**。
|
||
* 计数必须**精确匹配**:新增会被拦下(防回归),改好一项也必须同步删掉对应条目。
|
||
*
|
||
* 曾经有三类(`G3Space direction` 39 处、`G3Select allow-clear` 4 处、
|
||
* `G3Select/G3DatePicker placeholder` 各 9 处),前两类已在示例侧改正,
|
||
* 第三类由组件补上 `placeholder` / `startPlaceholder` / `endPlaceholder` 解决 —— 现在为空。
|
||
*/
|
||
const KNOWN_PENDING: Record<string, number> = {}
|
||
|
||
type RuntimeComponent = { props?: Record<string, unknown>; install?: unknown }
|
||
|
||
type AstProp = {
|
||
type: number // 6 = ATTRIBUTE,7 = DIRECTIVE
|
||
name: string
|
||
arg?: { content: string; isStatic?: boolean } | string
|
||
loc?: { start: { line: number } }
|
||
}
|
||
|
||
type AstNode = {
|
||
type: number // 1 = ELEMENT
|
||
tag?: string
|
||
props?: AstProp[]
|
||
children?: AstNode[]
|
||
branches?: AstNode[]
|
||
}
|
||
|
||
const toCamel = (value: string) => value.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
|
||
const toPascal = (value: string) =>
|
||
value
|
||
.split('-')
|
||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||
.join('')
|
||
|
||
/** 组件名 → 它声明的 props。G3UI 是整体注册插件,不是组件,排除。 */
|
||
function collectG3Components(): Map<string, Set<string>> {
|
||
const map = new Map<string, Set<string>>()
|
||
for (const [name, value] of Object.entries(UI as Record<string, unknown>)) {
|
||
if (!/^G3[A-Z]/.test(name)) continue
|
||
const component = value as RuntimeComponent | null
|
||
if (!component || typeof component !== 'object' || 'install' in component) continue
|
||
map.set(name, new Set(Object.keys(component.props ?? {})))
|
||
}
|
||
return map
|
||
}
|
||
|
||
function walkVueFiles(dir: string, out: string[] = []): string[] {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, entry.name)
|
||
if (entry.isDirectory()) walkVueFiles(full, out)
|
||
else if (entry.name.endsWith('.vue')) out.push(full)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function* walkNodes(nodes: AstNode[] | undefined): Generator<AstNode> {
|
||
for (const node of nodes ?? []) {
|
||
yield node
|
||
yield* walkNodes(node.children)
|
||
yield* walkNodes(node.branches)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取这个 attribute 对应的 prop 名;返回 null 表示「不是 prop 用法,无需校验」。
|
||
* v-on / v-slot / v-if / v-show / v-html 等一律放行;`v-bind:foo` → foo;`v-model:x` → x。
|
||
*/
|
||
function propNameOf(prop: AstProp): string | null {
|
||
if (prop.type === 6) return prop.name
|
||
if (prop.type !== 7) return null
|
||
|
||
const arg = typeof prop.arg === 'string' ? prop.arg : prop.arg?.content
|
||
const staticArg = typeof prop.arg === 'string' ? true : prop.arg?.isStatic !== false
|
||
// 动态参数 `:[key]="v"` 静态看不出来,跳过
|
||
if (arg && !staticArg) return null
|
||
|
||
if (prop.name === 'bind') return arg ?? null
|
||
if (prop.name === 'model') return arg ?? 'modelValue'
|
||
return null
|
||
}
|
||
|
||
describe('docs/examples 的组件 prop 契约', () => {
|
||
const components = collectG3Components()
|
||
const files = walkVueFiles(EXAMPLES_DIR)
|
||
|
||
it('示例目录可读、组件表非空', () => {
|
||
expect(files.length).toBeGreaterThan(0)
|
||
expect(components.size).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('没有给 G3 组件传它没声明的 attribute', () => {
|
||
const counts: Record<string, number> = {}
|
||
const detail: string[] = []
|
||
|
||
for (const file of files) {
|
||
const source = readFileSync(file, 'utf8')
|
||
const template = parse(source, { filename: file }).descriptor.template?.ast as unknown as
|
||
| AstNode
|
||
| undefined
|
||
if (!template) continue
|
||
|
||
const rel = relative(EXAMPLES_DIR, file).replace(/\\/g, '/')
|
||
|
||
for (const element of walkNodes([template])) {
|
||
if (element.type !== 1 || !element.tag) continue
|
||
|
||
const tag = toPascal(element.tag)
|
||
const declared = components.get(tag)
|
||
if (!declared) continue
|
||
|
||
// 有 `v-bind="obj"` 时静态分析不出来,整个元素跳过(宁漏勿错)
|
||
const hasSpread = (element.props ?? []).some(
|
||
(prop) => prop.type === 7 && prop.name === 'bind' && !prop.arg,
|
||
)
|
||
if (hasSpread) continue
|
||
|
||
for (const prop of element.props ?? []) {
|
||
const raw = propNameOf(prop)
|
||
if (!raw) continue
|
||
|
||
// data-* / aria-* 用**原始名**判断:camel 化之后(aria-label → ariaLabel)前缀就丢了
|
||
if (raw.startsWith('data-') || raw.startsWith('aria-')) continue
|
||
|
||
const name = toCamel(raw)
|
||
if (ALWAYS_ALLOWED.has(name)) continue
|
||
if (PASS_THROUGH[tag]?.has(name)) continue
|
||
if (declared.has(name)) continue
|
||
|
||
const key = `${tag} ${raw}`
|
||
counts[key] = (counts[key] ?? 0) + 1
|
||
detail.push(
|
||
`${rel}:${prop.loc?.start.line ?? 0} <${tag} ${raw}> 未声明的 prop(已声明:${
|
||
[...declared].join(', ') || '无'
|
||
})`,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 与 KNOWN_PENDING 精确比对:多出来的是新笔误(失败),少了的说明已修好(也要同步删条目)
|
||
expect(counts, `\n${detail.join('\n')}\n`).toEqual(KNOWN_PENDING)
|
||
})
|
||
})
|