Files
workspace/code/g3soft-libs/packages/ui/tests/docs-examples.test.ts
T
2026-10-09 22:05:02 +08:00

196 lines
8.1 KiB
TypeScript
Raw 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.
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)
})
})