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 的方向 * API 是 `orientation` / `vertical`。Vue 对**单根组件**的未知 attribute 既不报错也不警告, * 只是把它透传到 DOM —— 于是那些示例静默按默认的「横排」渲染,3 个 `width: 100%` 的输入框 * 挤成一行、总宽撑爆 demo 容器,一直没人发现。 * * 这里把「示例模板里传给 G3 组件的 attribute」和「组件自己声明的 props」对一遍, * 让这类笔误在 `pnpm test` 阶段就暴露。 * * 两个刻意的实现选择: * - 组件侧的事实源是**运行时 props**(`defineProps()` 由编译器从 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> = { // 根元素是 ``,带 href 时就是 :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 = {} type RuntimeComponent = { props?: Record; 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> { const map = new Map>() for (const [name, value] of Object.entries(UI as Record)) { 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 { 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 = {} 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) }) })