vite-plugin-vue-testid 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,361 @@
1
+ # vite-plugin-vue-testid
2
+
3
+ 编译期 + 运行时自动为 Vue 组件模板注入 `data-testid` 的 Vite 插件,专为 ant-design-vue 4.x 适配,大幅降低 E2E 测试中的元素定位成本。
4
+
5
+ ## 特性
6
+
7
+ - **编译期注入** — 通过 Vue 编译器 `nodeTransform` 自动为组件模板添加 `data-testid`
8
+ - **运行时注入** — 为 ant-design-vue teleport 弹出层(DatePicker、Cascader、Select、TreeSelect 等)动态注入 testId
9
+ - **<a-table> 适配** — bodyCell 插槽内组件自动生成「列名 + 行号 + 序号」的唯一 testId
10
+ - **自动补全 index 解构** — 即使 `#bodyCell="{ column, record }"` 未写 `index`,插件也会自动补上
11
+ - **不会覆盖已有 testId** — 手动标注了 `data-testid` 的元素会被跳过
12
+ - **teleport 组件双注入** — DatePicker / RangePicker / Modal 同时注入 `id` + `data-testid`
13
+ - **日期面板模式感知** — date / month / year / decade 面板自动区分
14
+ - **MutationObserver 重注入** — 面板切换、树节点展开/折叠时自动检测并注入新 DOM
15
+ - **可扩展** — 支持自定义组件列表、testId 生成规则、弹出面板注入策略
16
+
17
+ ---
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ pnpm add -D vite-plugin-vue-testid
23
+ ```
24
+
25
+ > 要求:`vite >= 5.0.0`,`vue >= 3.3.0`,`@vue/compiler-core >= 3.5.0`
26
+
27
+ ---
28
+
29
+ ## 快速开始
30
+
31
+ ### 1. 编译期注入(vite.config.ts)
32
+
33
+ ```ts
34
+ // vite.config.ts
35
+ import { defineConfig } from 'vite'
36
+ import vue from '@vitejs/plugin-vue'
37
+ import { createVueTestIdTransform } from 'vite-plugin-vue-testid'
38
+
39
+ export default defineConfig({
40
+ plugins: [
41
+ vue({
42
+ template: {
43
+ compilerOptions: {
44
+ nodeTransforms: [createVueTestIdTransform()]
45
+ }
46
+ }
47
+ })
48
+ ]
49
+ })
50
+ ```
51
+
52
+ ### 2. 运行时注入(main.ts)
53
+
54
+ ```ts
55
+ // main.ts
56
+ import { createApp } from 'vue'
57
+ import App from './App.vue'
58
+ import { setupAntDropdownTestIds } from 'vite-plugin-vue-testid/runtime'
59
+
60
+ const app = createApp(App)
61
+ app.mount('#app')
62
+
63
+ // 在 mount 之后调用,监听 teleport 面板并自动注入 testId
64
+ setupAntDropdownTestIds()
65
+ ```
66
+
67
+ ---
68
+
69
+ ## 生成的 testId 示例
70
+
71
+ ### 普通组件
72
+
73
+ ```html
74
+ <!-- 源码 -->
75
+ <a-input />
76
+ <a-select />
77
+ <a-button />
78
+
79
+ <!-- 编译后 -->
80
+ <a-input data-testid="a-input-0" />
81
+ <a-select data-testid="a-select-1" />
82
+ <a-button data-testid="a-button-2" />
83
+ ```
84
+
85
+ ### teleport 组件(双注入)
86
+
87
+ ```html
88
+ <!-- 源码 -->
89
+ <a-date-picker />
90
+ <a-modal v-model:visible="show" title="提示" />
91
+
92
+ <!-- 编译后 -->
93
+ <a-date-picker id="a-date-picker-0" data-testid="a-date-picker-0" />
94
+ <a-modal id="a-modal-1" data-testid="a-modal-1" />
95
+ ```
96
+
97
+ ### <a-table> bodyCell 内组件
98
+
99
+ ```
100
+ 格式:${column.dataIndex}-${index}-${组件名}-${序号}
101
+
102
+ 示例:
103
+ - label-0-a-input-4 (label 列,第 0 行)
104
+ - value-2-a-input-5 (value 列,第 2 行)
105
+ ```
106
+
107
+ ### 运行时 DatePicker 面板
108
+
109
+ ```
110
+ a-date-picker-0-dropdown # 面板容器
111
+ a-date-picker-0-dropdown-prev # 上一月
112
+ a-date-picker-0-dropdown-next # 下一月
113
+ a-date-picker-0-dropdown-header-date # header 容器
114
+ a-date-picker-0-dropdown-header-month-btn # header 月份按钮
115
+ a-date-picker-0-dropdown-header-year-btn # header 年份按钮
116
+ a-date-picker-0-dropdown-date-2026-06-15 # 日期单元格
117
+ a-date-picker-0-dropdown-ok # 确定按钮
118
+ ```
119
+
120
+ 切换到月面板 / 年面板后(MutationObserver 自动重注入):
121
+
122
+ ```
123
+ a-date-picker-0-dropdown-header-month # 月面板 header
124
+ a-date-picker-0-dropdown-month-06 # 6月
125
+ a-date-picker-0-dropdown-header-year # 年面板 header
126
+ a-date-picker-0-dropdown-year-2026 # 2026年
127
+ a-date-picker-0-dropdown-decade-2020-2029 # 2020-2029 年代
128
+ ```
129
+
130
+ ### 运行时 RangePicker 面板
131
+
132
+ ```
133
+ a-range-picker-0-dropdown-prev-left # 左侧面板上一月
134
+ a-range-picker-0-dropdown-date-2026-05-15-left # 左侧面板日期
135
+ a-range-picker-0-dropdown-date-2026-06-20-right # 右侧面板日期
136
+ ```
137
+
138
+ ### 运行时 TimePicker
139
+
140
+ ```
141
+ a-time-picker-0-dropdown-time-column-0 # 时列
142
+ a-time-picker-0-dropdown-time-08 # 08时
143
+ a-time-picker-0-dropdown-time-column-1 # 分列
144
+ a-time-picker-0-dropdown-time-30 # 30分
145
+ ```
146
+
147
+ ### 运行时 Cascader
148
+
149
+ ```
150
+ a-cascader-0-dropdown # 面板容器
151
+ a-cascader-0-dropdown-menu-0 # 第1级菜单
152
+ a-cascader-0-dropdown-item-浙江 # 浙江选项
153
+ a-cascader-0-dropdown-menu-1 # 第2级菜单
154
+ a-cascader-0-dropdown-item-杭州 # 杭州选项
155
+ ```
156
+
157
+ ### 运行时 Select
158
+
159
+ ```
160
+ a-select-0-dropdown # 下拉容器
161
+ a-select-0-dropdown-option-选项1 # 选项
162
+ a-select-0-dropdown-option-选项2 # 选项
163
+ ```
164
+
165
+ ### 运行时 TreeSelect
166
+
167
+ ```
168
+ a-tree-select-0-dropdown # 下拉容器
169
+ a-tree-select-0-dropdown-tree-root-1 # 根节点
170
+ a-tree-select-0-dropdown-tree-parent-1 # 父节点(展开后注入)
171
+ a-tree-select-0-dropdown-tree-my-leaf # 叶子节点
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 配置
177
+
178
+ ### 编译期配置
179
+
180
+ ```ts
181
+ import { createVueTestIdTransform } from 'vite-plugin-vue-testid'
182
+
183
+ createVueTestIdTransform({
184
+ /**
185
+ * 需要注入 testId 的组件标签列表(kebab-case)
186
+ * @default 见下方「默认支持的组件」
187
+ */
188
+ components: ['a-input', 'a-select', 'my-button'],
189
+
190
+ /**
191
+ * 自定义 testId 生成函数
192
+ * @param tagName 组件标签名
193
+ * @param count 全局递增序号
194
+ * @returns testId 字符串
195
+ */
196
+ generateId: (tagName, count) => `myapp-${tagName}-${count}`,
197
+
198
+ /**
199
+ * testId 属性名(可改为 data-cy 适配 Cypress)
200
+ * @default 'data-testid'
201
+ */
202
+ attributeName: 'data-cy',
203
+
204
+ /**
205
+ * 需要同时注入 id + testId 的组件
206
+ * @default ['a-date-picker', 'a-range-picker', 'a-time-picker', 'a-modal']
207
+ */
208
+ idComponents: ['a-date-picker', 'a-modal'],
209
+
210
+ /**
211
+ * id 属性名
212
+ * @default 'id'
213
+ */
214
+ idAttributeName: 'id',
215
+ })
216
+ ```
217
+
218
+ ### 运行时配置
219
+
220
+ ```ts
221
+ import { setupAntDropdownTestIds } from 'vite-plugin-vue-testid/runtime'
222
+
223
+ // 默认配置
224
+ const cleanup = setupAntDropdownTestIds()
225
+
226
+ // 自定义配置
227
+ const cleanup = setupAntDropdownTestIds({
228
+ /**
229
+ * testId 属性名,需与编译期配置一致
230
+ * @default 'data-testid'
231
+ */
232
+ attributeName: 'data-testid',
233
+
234
+ /**
235
+ * 自定义面板选择器 → 注入策略映射
236
+ * key: CSS 选择器
237
+ * value: 注入函数,设为 undefined 可禁用某个默认面板
238
+ */
239
+ panels: {
240
+ '.ant-picker-dropdown': undefined, // 禁用内置 Picker 面板注入
241
+ '.my-custom-dropdown': (ctx) => {
242
+ // ctx.container — 面板容器 HTMLElement
243
+ // ctx.trigger — 触发器 HTMLElement | null
244
+ // ctx.triggerTestId — 触发器的 testId 值
245
+ // ctx.attrName — testId 属性名
246
+ const prefix = `${ctx.triggerTestId}-dropdown`
247
+ ctx.container.setAttribute(ctx.attrName, prefix)
248
+ // ... 自定义注入逻辑
249
+ },
250
+ },
251
+ })
252
+
253
+ // 组件卸载时清理 Observer
254
+ onUnmounted(cleanup)
255
+ ```
256
+
257
+ ### 一次性手动注入
258
+
259
+ ```ts
260
+ import { injectCurrentDropdowns } from 'vite-plugin-vue-testid/runtime'
261
+
262
+ // SSR / hydration 场景下,不想用 MutationObserver 时直接调用
263
+ injectCurrentDropdowns({ attributeName: 'data-testid' })
264
+ ```
265
+
266
+ ---
267
+
268
+ ## 默认支持的组件
269
+
270
+ | 组件 | 编译期注入 | 运行时注入 |
271
+ |------|:---------:|:---------:|
272
+ | `a-input` | ✅ | — |
273
+ | `a-textarea` | ✅ | — |
274
+ | `a-input-number` | ✅ | — |
275
+ | `a-select` | ✅ | ✅ 下拉面板 |
276
+ | `a-cascader` | ✅ | ✅ 级联面板 |
277
+ | `a-tree-select` | ✅ | ✅ 树选择面板 |
278
+ | `a-radio-group` | ✅ | — |
279
+ | `a-checkbox-group` | ✅ | — |
280
+ | `a-date-picker` | ✅ id+testid | ✅ 日期面板 |
281
+ | `a-range-picker` | ✅ id+testid | ✅ 双面板 |
282
+ | `a-time-picker` | ✅ id+testid | ✅ 时间面板 |
283
+ | `a-switch` | ✅ | — |
284
+ | `a-slider` | ✅ | — |
285
+ | `a-rate` | ✅ | — |
286
+ | `a-upload` | ✅ | — |
287
+ | `a-form-item` | ✅ | — |
288
+ | `a-button` | ✅ | — |
289
+ | `a-modal` | ✅ id+testid | — |
290
+
291
+ ---
292
+
293
+ ## 自定义面板注入策略
294
+
295
+ 如果需要支持其他 UI 库的弹出面板,可以自定义策略:
296
+
297
+ ```ts
298
+ import type { PanelInjectStrategy } from 'vite-plugin-vue-testid/runtime'
299
+
300
+ // 示例:为 Element Plus 的弹出层注入 testId
301
+ const injectElDropdown: PanelInjectStrategy = (ctx) => {
302
+ const { container, triggerTestId, attrName } = ctx
303
+ const prefix = `${triggerTestId}-dropdown`
304
+
305
+ container.setAttribute(attrName, prefix)
306
+
307
+ container.querySelectorAll('.el-select-dropdown__item').forEach((item) => {
308
+ const el = item as HTMLElement
309
+ const text = el.textContent?.trim() || ''
310
+ el.setAttribute(attrName, `${prefix}-option-${text}`)
311
+ })
312
+ }
313
+
314
+ setupAntDropdownTestIds({
315
+ panels: {
316
+ '.el-select-dropdown': injectElDropdown,
317
+ '.el-cascader-dropdown': injectElDropdown,
318
+ },
319
+ })
320
+ ```
321
+
322
+ ---
323
+
324
+ ## API 参考
325
+
326
+ ### 编译期
327
+
328
+ | 导出 | 类型 | 说明 |
329
+ |------|------|------|
330
+ | `createVueTestIdTransform(opts?)` | `(node: RootNode \| TemplateChildNode) => void` | 创建 Vue 编译器 nodeTransform |
331
+ | `VueTestIdTransformOptions` | `interface` | 编译期配置类型 |
332
+
333
+ ### 运行时(`/runtime` 子路径)
334
+
335
+ | 导出 | 类型 | 说明 |
336
+ |------|------|------|
337
+ | `setupAntDropdownTestIds(opts?)` | `() => void` | 启动 MutationObserver,自动注入弹出面板 testId,返回清理函数 |
338
+ | `injectCurrentDropdowns(opts?)` | `void` | 一次性手动注入当前 DOM 中已有的弹出面板 |
339
+ | `RuntimeOptions` | `interface` | 运行时配置类型 |
340
+ | `PanelContext` | `interface` | 面板注入上下文类型 |
341
+ | `PanelInjectStrategy` | `(ctx: PanelContext) => void` | 面板注入策略函数类型 |
342
+
343
+ ---
344
+
345
+ ## 原理
346
+
347
+ ### 编译期
348
+
349
+ 通过 Vue 编译器的 `nodeTransform` 钩子在 AST 阶段遍历模板节点,匹配目标组件标签后直接追加 `data-testid` 属性节点。由于是编译期注入,对运行时没有性能影响。
350
+
351
+ ### 运行时
352
+
353
+ ant-design-vue 4.x 的 DatePicker / Select / Cascader 等组件使用 Teleport 将弹出层渲染到 `document.body`,编译期无法触及这些 DOM。运行时模块通过 `MutationObserver` 监听 body 下的 DOM 变化,匹配 ant-design 面板的 CSS class,自动为面板内元素注入与触发器关联的 testId。
354
+
355
+ 对于 DatePicker 这种 `inheritAttrs: false` 的组件,`data-testid` 在编译期注入后无法到达 DOM。插件通过同时注入 `id` 属性(该属性被 ant-design-vue 显式转发到内部 `<input>`)作为兜底标识。
356
+
357
+ ---
358
+
359
+ ## License
360
+
361
+ MIT
@@ -0,0 +1,64 @@
1
+ import type { RootNode, TemplateChildNode } from '@vue/compiler-core';
2
+ export interface VueTestIdTransformOptions {
3
+ /**
4
+ * 需要注入 testId 的组件标签列表(kebab-case)
5
+ * @default ant-design-vue 常用表单组件
6
+ */
7
+ components?: string[];
8
+ /**
9
+ * 自定义 testId 生成函数
10
+ * @param tagName 组件标签名
11
+ * @param count 全局递增序号
12
+ * @returns testId 字符串
13
+ */
14
+ generateId?: (tagName: string, count: number) => string;
15
+ /**
16
+ * testId 属性名
17
+ * @default 'data-testid'
18
+ */
19
+ attributeName?: string;
20
+ /**
21
+ * 需要同时注入 id + testId 的组件(如弹窗、日期选择器等 teleport 组件)
22
+ * @default ['a-date-picker', 'a-range-picker', 'a-time-picker', 'a-modal']
23
+ */
24
+ idComponents?: string[];
25
+ /**
26
+ * teleport 组件的 id 属性名
27
+ * @default 'id'
28
+ */
29
+ idAttributeName?: string;
30
+ }
31
+ /**
32
+ * 创建 Vue 编译器 nodeTransform 函数。
33
+ * 直接传给 `@vitejs/plugin-vue` 的 `template.compilerOptions.nodeTransforms`。
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * // vite.config.ts
38
+ * import vue from '@vitejs/plugin-vue'
39
+ * import { createVueTestIdTransform } from 'vite-plugin-vue-testid'
40
+ *
41
+ * export default defineConfig({
42
+ * plugins: [
43
+ * vue({
44
+ * template: {
45
+ * compilerOptions: {
46
+ * nodeTransforms: [createVueTestIdTransform()]
47
+ * }
48
+ * }
49
+ * })
50
+ * ]
51
+ * })
52
+ * ```
53
+ *
54
+ * @example 自定义配置
55
+ * ```ts
56
+ * createVueTestIdTransform({
57
+ * components: ['a-input', 'a-select', 'el-input'],
58
+ * generateId: (tag, n) => `myapp-${tag}-${n}`,
59
+ * attributeName: 'data-cy',
60
+ * })
61
+ * ```
62
+ */
63
+ export declare function createVueTestIdTransform(rawOpts?: VueTestIdTransformOptions): (node: RootNode | TemplateChildNode) => void;
64
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAIV,QAAQ,EAER,iBAAiB,EAClB,MAAM,oBAAoB,CAAA;AAI3B,MAAM,WAAW,yBAAyB;IACxC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IAErB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAA;IAEvD;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IAEvB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAuMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,CAAC,EAAE,yBAAyB,GAClC,CAAC,IAAI,EAAE,QAAQ,GAAG,iBAAiB,KAAK,IAAI,CAG9C"}
package/dist/index.js ADDED
@@ -0,0 +1,154 @@
1
+ // src/index.ts
2
+ import { ConstantTypes, NodeTypes } from "@vue/compiler-core";
3
+ var DEFAULT_COMPONENTS = [
4
+ "a-input",
5
+ "a-textarea",
6
+ "a-input-number",
7
+ "a-select",
8
+ "a-cascader",
9
+ "a-tree-select",
10
+ "a-radio-group",
11
+ "a-checkbox-group",
12
+ "a-date-picker",
13
+ "a-range-picker",
14
+ "a-time-picker",
15
+ "a-switch",
16
+ "a-slider",
17
+ "a-rate",
18
+ "a-upload",
19
+ "a-form-item",
20
+ "a-button",
21
+ "a-modal"
22
+ ];
23
+ var DEFAULT_ID_COMPONENTS = [
24
+ "a-date-picker",
25
+ "a-range-picker",
26
+ "a-time-picker",
27
+ "a-modal"
28
+ ];
29
+ function resolveOptions(raw) {
30
+ return {
31
+ components: raw?.components ?? DEFAULT_COMPONENTS,
32
+ generateId: raw?.generateId ?? ((tag, n) => `${tag}-${n}`),
33
+ attributeName: raw?.attributeName ?? "data-testid",
34
+ idComponents: raw?.idComponents ?? DEFAULT_ID_COMPONENTS,
35
+ idAttributeName: raw?.idAttributeName ?? "id"
36
+ };
37
+ }
38
+ function ensureSlotIndex(exp) {
39
+ if (!exp) return;
40
+ if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
41
+ if (!/\bindex\b/.test(exp.content)) {
42
+ exp.content = exp.content.replace(/\s*\}$/, ", index}");
43
+ }
44
+ } else if (exp.type === NodeTypes.COMPOUND_EXPRESSION) {
45
+ const hasIndex = exp.children.some((c) => {
46
+ if (typeof c === "string") return /\bindex\b/.test(c);
47
+ if (typeof c === "symbol") return false;
48
+ if (c.type === NodeTypes.TEXT || c.type === NodeTypes.SIMPLE_EXPRESSION) {
49
+ return /\bindex\b/.test(c.content);
50
+ }
51
+ return false;
52
+ });
53
+ if (hasIndex) return;
54
+ for (let i = exp.children.length - 1; i >= 0; i--) {
55
+ const c = exp.children[i];
56
+ if (typeof c === "string") {
57
+ exp.children[i] = c.replace(/}$/, ", index}");
58
+ break;
59
+ }
60
+ if (typeof c !== "symbol" && c.type === NodeTypes.TEXT) {
61
+ c.content = c.content.replace(/}$/, ", index}");
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ }
67
+ function hasExistingTestId(node, attrName) {
68
+ return node.props.some(
69
+ (p) => p.type === NodeTypes.ATTRIBUTE && p.name === attrName || p.type === NodeTypes.DIRECTIVE && p.name === "bind" && p.arg && p.arg.type === NodeTypes.SIMPLE_EXPRESSION && p.arg.content === attrName
70
+ );
71
+ }
72
+ function createStaticAttr(name, value, loc) {
73
+ return {
74
+ type: NodeTypes.ATTRIBUTE,
75
+ name,
76
+ nameLoc: loc,
77
+ value: { type: NodeTypes.TEXT, content: value, loc },
78
+ loc
79
+ };
80
+ }
81
+ function createBindDirective(attrName, exprContent, loc) {
82
+ return {
83
+ type: NodeTypes.DIRECTIVE,
84
+ name: "bind",
85
+ arg: {
86
+ type: NodeTypes.SIMPLE_EXPRESSION,
87
+ content: attrName,
88
+ isStatic: true,
89
+ constType: ConstantTypes.CAN_STRINGIFY,
90
+ loc
91
+ },
92
+ exp: {
93
+ type: NodeTypes.SIMPLE_EXPRESSION,
94
+ content: exprContent,
95
+ isStatic: false,
96
+ constType: ConstantTypes.NOT_CONSTANT,
97
+ loc
98
+ },
99
+ modifiers: [],
100
+ loc
101
+ };
102
+ }
103
+ function createASTTransform(opts) {
104
+ let count = 0;
105
+ let insideTableDepth = 0;
106
+ let insideBodyCellDepth = 0;
107
+ const componentSet = new Set(opts.components);
108
+ const idComponentSet = new Set(opts.idComponents);
109
+ return (node) => {
110
+ if (node.type !== NodeTypes.ELEMENT) return;
111
+ const tagName = node.tag.toLowerCase();
112
+ if (tagName === "a-table") {
113
+ insideTableDepth++;
114
+ return () => {
115
+ insideTableDepth--;
116
+ };
117
+ }
118
+ if (tagName === "template" && insideTableDepth > 0) {
119
+ const slotDir = node.props.find(
120
+ (p) => p.type === NodeTypes.DIRECTIVE && p.name === "slot" && p.arg && p.arg.type === NodeTypes.SIMPLE_EXPRESSION && p.arg.content === "bodyCell"
121
+ );
122
+ if (slotDir && slotDir.type === NodeTypes.DIRECTIVE) {
123
+ ensureSlotIndex(slotDir.exp);
124
+ insideBodyCellDepth++;
125
+ return () => {
126
+ insideBodyCellDepth--;
127
+ };
128
+ }
129
+ }
130
+ if (insideTableDepth > 0 && insideBodyCellDepth === 0) return;
131
+ if (!componentSet.has(tagName)) return;
132
+ if (hasExistingTestId(node, opts.attributeName)) return;
133
+ const testIdValue = opts.generateId(tagName, count++);
134
+ if (insideBodyCellDepth > 0) {
135
+ const expr = `\`\${column?.dataIndex || column?.key || 'cell'}-\${index}-${testIdValue}\``;
136
+ node.props.push(createBindDirective(opts.attributeName, expr, node.loc));
137
+ return;
138
+ }
139
+ if (idComponentSet.has(tagName)) {
140
+ node.props.push(createStaticAttr(opts.idAttributeName, testIdValue, node.loc));
141
+ node.props.push(createStaticAttr(opts.attributeName, testIdValue, node.loc));
142
+ return;
143
+ }
144
+ node.props.push(createStaticAttr(opts.attributeName, testIdValue, node.loc));
145
+ };
146
+ }
147
+ function createVueTestIdTransform(rawOpts) {
148
+ const opts = resolveOptions(rawOpts);
149
+ return createASTTransform(opts);
150
+ }
151
+ export {
152
+ createVueTestIdTransform
153
+ };
154
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { ConstantTypes, NodeTypes } from '@vue/compiler-core'\nimport type {\n AttributeNode,\n DirectiveNode,\n ExpressionNode,\n RootNode,\n SimpleExpressionNode,\n TemplateChildNode,\n} from '@vue/compiler-core'\n\n// ──────────────────────── Types ────────────────────────\n\nexport interface VueTestIdTransformOptions {\n /**\n * 需要注入 testId 的组件标签列表(kebab-case)\n * @default ant-design-vue 常用表单组件\n */\n components?: string[]\n\n /**\n * 自定义 testId 生成函数\n * @param tagName 组件标签名\n * @param count 全局递增序号\n * @returns testId 字符串\n */\n generateId?: (tagName: string, count: number) => string\n\n /**\n * testId 属性名\n * @default 'data-testid'\n */\n attributeName?: string\n\n /**\n * 需要同时注入 id + testId 的组件(如弹窗、日期选择器等 teleport 组件)\n * @default ['a-date-picker', 'a-range-picker', 'a-time-picker', 'a-modal']\n */\n idComponents?: string[]\n\n /**\n * teleport 组件的 id 属性名\n * @default 'id'\n */\n idAttributeName?: string\n}\n\ntype ResolvedOptions = Required<VueTestIdTransformOptions>\n\nconst DEFAULT_COMPONENTS = [\n 'a-input', 'a-textarea', 'a-input-number',\n 'a-select', 'a-cascader', 'a-tree-select',\n 'a-radio-group', 'a-checkbox-group',\n 'a-date-picker', 'a-range-picker', 'a-time-picker',\n 'a-switch', 'a-slider', 'a-rate', 'a-upload',\n 'a-form-item',\n 'a-button',\n 'a-modal',\n]\n\nconst DEFAULT_ID_COMPONENTS = [\n 'a-date-picker', 'a-range-picker', 'a-time-picker', 'a-modal',\n]\n\n// ──────────────────────── Helpers ────────────────────────\n\nfunction resolveOptions(raw?: VueTestIdTransformOptions): ResolvedOptions {\n return {\n components: raw?.components ?? DEFAULT_COMPONENTS,\n generateId: raw?.generateId ?? ((tag, n) => `${tag}-${n}`),\n attributeName: raw?.attributeName ?? 'data-testid',\n idComponents: raw?.idComponents ?? DEFAULT_ID_COMPONENTS,\n idAttributeName: raw?.idAttributeName ?? 'id',\n }\n}\n\n/**\n * 确保 slot props 解构表达式中包含 index。\n * 兼容 SIMPLE_EXPRESSION (type=4) 和 COMPOUND_EXPRESSION (type=8)。\n */\nfunction ensureSlotIndex(exp: ExpressionNode | undefined): void {\n if (!exp) return\n\n if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {\n if (!/\\bindex\\b/.test(exp.content)) {\n exp.content = exp.content.replace(/\\s*\\}$/, ', index}')\n }\n } else if (exp.type === NodeTypes.COMPOUND_EXPRESSION) {\n const hasIndex = exp.children.some(c => {\n if (typeof c === 'string') return /\\bindex\\b/.test(c)\n if (typeof c === 'symbol') return false\n if (c.type === NodeTypes.TEXT || c.type === NodeTypes.SIMPLE_EXPRESSION) {\n return /\\bindex\\b/.test(c.content)\n }\n return false\n })\n if (hasIndex) return\n\n for (let i = exp.children.length - 1; i >= 0; i--) {\n const c = exp.children[i]\n if (typeof c === 'string') {\n exp.children[i] = c.replace(/}$/, ', index}')\n break\n }\n if (typeof c !== 'symbol' && c.type === NodeTypes.TEXT) {\n c.content = c.content.replace(/}$/, ', index}')\n break\n }\n }\n }\n}\n\nfunction hasExistingTestId(\n node: { props: (AttributeNode | DirectiveNode)[] },\n attrName: string,\n): boolean {\n return node.props.some(\n p =>\n (p.type === NodeTypes.ATTRIBUTE && p.name === attrName) ||\n (p.type === NodeTypes.DIRECTIVE &&\n p.name === 'bind' &&\n p.arg &&\n (p.arg as SimpleExpressionNode).type === NodeTypes.SIMPLE_EXPRESSION &&\n (p.arg as SimpleExpressionNode).content === attrName),\n )\n}\n\nfunction createStaticAttr(name: string, value: string, loc: RootNode['loc']): AttributeNode {\n return {\n type: NodeTypes.ATTRIBUTE,\n name,\n nameLoc: loc,\n value: { type: NodeTypes.TEXT, content: value, loc },\n loc,\n }\n}\n\nfunction createBindDirective(\n attrName: string,\n exprContent: string,\n loc: RootNode['loc'],\n): DirectiveNode {\n return {\n type: NodeTypes.DIRECTIVE,\n name: 'bind',\n arg: {\n type: NodeTypes.SIMPLE_EXPRESSION,\n content: attrName,\n isStatic: true,\n constType: ConstantTypes.CAN_STRINGIFY,\n loc,\n },\n exp: {\n type: NodeTypes.SIMPLE_EXPRESSION,\n content: exprContent,\n isStatic: false,\n constType: ConstantTypes.NOT_CONSTANT,\n loc,\n },\n modifiers: [],\n loc,\n }\n}\n\n// ──────────────────────── Core Transform ────────────────────────\n\n/**\n * 创建 Vue 编译器 nodeTransform,自动为模板元素注入 data-testid。\n *\n * 特性:\n * - 自动跳过手动标注了 data-testid 的元素\n * - 支持 <a-table> 内 #bodyCell 插槽的动态 testId(行+列唯一)\n * - 自动补全 bodyCell slot props 中的 index 解构\n * - teleport 组件(日期选择器、弹窗)同时注入 id + data-testid\n */\nfunction createASTTransform(opts: ResolvedOptions) {\n let count = 0\n let insideTableDepth = 0\n let insideBodyCellDepth = 0\n\n const componentSet = new Set(opts.components)\n const idComponentSet = new Set(opts.idComponents)\n\n return (node: RootNode | TemplateChildNode) => {\n if (node.type !== NodeTypes.ELEMENT) return\n\n const tagName = node.tag.toLowerCase()\n\n // ──────────── <a-table> 上下文 ────────────\n if (tagName === 'a-table') {\n insideTableDepth++\n return () => { insideTableDepth-- }\n }\n\n // ──────────── <template #bodyCell> 上下文 ────────────\n if (tagName === 'template' && insideTableDepth > 0) {\n const slotDir = node.props.find(\n p =>\n p.type === NodeTypes.DIRECTIVE &&\n p.name === 'slot' &&\n p.arg &&\n (p.arg as SimpleExpressionNode).type === NodeTypes.SIMPLE_EXPRESSION &&\n (p.arg as SimpleExpressionNode).content === 'bodyCell',\n )\n if (slotDir && slotDir.type === NodeTypes.DIRECTIVE) {\n ensureSlotIndex(slotDir.exp)\n insideBodyCellDepth++\n return () => { insideBodyCellDepth-- }\n }\n }\n\n // table 内部但非 bodyCell → 跳过\n if (insideTableDepth > 0 && insideBodyCellDepth === 0) return\n\n // 非目标组件 → 跳过\n if (!componentSet.has(tagName)) return\n\n // 已有 testId → 跳过\n if (hasExistingTestId(node, opts.attributeName)) return\n\n const testIdValue = opts.generateId(tagName, count++)\n\n // ── bodyCell 内:动态 bind(引用 column.dataIndex + index) ──\n if (insideBodyCellDepth > 0) {\n const expr =\n `\\`\\${column?.dataIndex || column?.key || 'cell'}-\\${index}-${testIdValue}\\``\n node.props.push(createBindDirective(opts.attributeName, expr, node.loc))\n return\n }\n\n // ── teleport 组件:id + data-testid 双注入 ──\n if (idComponentSet.has(tagName)) {\n node.props.push(createStaticAttr(opts.idAttributeName, testIdValue, node.loc))\n node.props.push(createStaticAttr(opts.attributeName, testIdValue, node.loc))\n return\n }\n\n // ── 普通组件:静态 data-testid ──\n node.props.push(createStaticAttr(opts.attributeName, testIdValue, node.loc))\n }\n}\n\n// ──────────────────────── Public API ────────────────────────\n\n/**\n * 创建 Vue 编译器 nodeTransform 函数。\n * 直接传给 `@vitejs/plugin-vue` 的 `template.compilerOptions.nodeTransforms`。\n *\n * @example\n * ```ts\n * // vite.config.ts\n * import vue from '@vitejs/plugin-vue'\n * import { createVueTestIdTransform } from 'vite-plugin-vue-testid'\n *\n * export default defineConfig({\n * plugins: [\n * vue({\n * template: {\n * compilerOptions: {\n * nodeTransforms: [createVueTestIdTransform()]\n * }\n * }\n * })\n * ]\n * })\n * ```\n *\n * @example 自定义配置\n * ```ts\n * createVueTestIdTransform({\n * components: ['a-input', 'a-select', 'el-input'],\n * generateId: (tag, n) => `myapp-${tag}-${n}`,\n * attributeName: 'data-cy',\n * })\n * ```\n */\nexport function createVueTestIdTransform(\n rawOpts?: VueTestIdTransformOptions,\n): (node: RootNode | TemplateChildNode) => void {\n const opts = resolveOptions(rawOpts)\n return createASTTransform(opts)\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 运行时辅助模块:为 ant-design-vue teleport 弹出层(日期选择器、级联选择器等)
3
+ * 中的 DOM 元素注入 data-testid,解决编译期无法处理的定位问题。
4
+ *
5
+ * 使用方式(在 main.ts 或入口文件中调用一次即可):
6
+ * ```ts
7
+ * import { setupAntDropdownTestIds } from 'vite-plugin-vue-testid/runtime'
8
+ *
9
+ * setupAntDropdownTestIds()
10
+ * ```
11
+ */
12
+ export interface RuntimeOptions {
13
+ /**
14
+ * testId 属性名,需与编译期插件保持一致
15
+ * @default 'data-testid'
16
+ */
17
+ attributeName?: string;
18
+ /**
19
+ * 需要监听的 teleport 面板 CSS 选择器映射
20
+ * key: 面板容器选择器
21
+ * value: 注入策略(自定义函数 或 undefined 使用内置策略)
22
+ */
23
+ panels?: Record<string, PanelInjectStrategy | undefined>;
24
+ }
25
+ export interface PanelContext {
26
+ /** 面板容器 DOM 元素 */
27
+ container: HTMLElement;
28
+ /** 当前激活的触发器元素 */
29
+ trigger: HTMLElement | null;
30
+ /** 触发器的 testId 值 */
31
+ triggerTestId: string;
32
+ /** testId 属性名 */
33
+ attrName: string;
34
+ }
35
+ export type PanelInjectStrategy = (ctx: PanelContext) => void;
36
+ /**
37
+ * 为 teleport 弹出层面板注入 testId。
38
+ * 使用 MutationObserver 监听 DOM 新增节点,自动识别 ant-design 弹出面板。
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // main.ts
43
+ * import { setupAntDropdownTestIds } from 'vite-plugin-vue-testid/runtime'
44
+ *
45
+ * createApp(App).mount('#app')
46
+ * setupAntDropdownTestIds()
47
+ * ```
48
+ *
49
+ * testId 注入结果示例:
50
+ * ```
51
+ * // 日期面板(初始)
52
+ * a-date-picker-0-dropdown // 面板容器
53
+ * a-date-picker-0-dropdown-prev // 上月/上一级
54
+ * a-date-picker-0-dropdown-next // 下月/下一级
55
+ * a-date-picker-0-dropdown-header-date // header 容器
56
+ * a-date-picker-0-dropdown-header-month-btn // header 内的月份按钮("Jun"/"6月")
57
+ * a-date-picker-0-dropdown-header-year-btn // header 内的年份按钮("2026")
58
+ * a-date-picker-0-dropdown-date-2026-06-15 // 日期单元格
59
+ * a-date-picker-0-dropdown-ok // 确定按钮
60
+ *
61
+ * // 点击月份按钮 → 月面板(MutationObserver 自动重注入)
62
+ * a-date-picker-0-dropdown-header-month // header 容器
63
+ * a-date-picker-0-dropdown-header-year-btn // header 内的年份按钮
64
+ * a-date-picker-0-dropdown-month-06 // 6月按钮
65
+ *
66
+ * // 点击年份按钮 → 年面板
67
+ * a-date-picker-0-dropdown-header-year // header 容器
68
+ * a-date-picker-0-dropdown-year-2026 // 2026年按钮
69
+ * ```
70
+ */
71
+ export declare function setupAntDropdownTestIds(opts?: RuntimeOptions): () => void;
72
+ /**
73
+ * 手动为当前已存在或即将出现的弹出面板注入 testId(一次性补丁)。
74
+ * 适用于 SSR/hydration 或不想用 MutationObserver 的场景。
75
+ */
76
+ export declare function injectCurrentDropdowns(opts?: RuntimeOptions): void;
77
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,GAAG,SAAS,CAAC,CAAA;CACzD;AAED,MAAM,WAAW,YAAY;IAC3B,kBAAkB;IAClB,SAAS,EAAE,WAAW,CAAA;IACtB,iBAAiB;IACjB,OAAO,EAAE,WAAW,GAAG,IAAI,CAAA;IAC3B,oBAAoB;IACpB,aAAa,EAAE,MAAM,CAAA;IACrB,iBAAiB;IACjB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAA;AAyW7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,GAAE,cAAmB,GAAG,MAAM,IAAI,CA4C7E;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,GAAE,cAAmB,GAAG,IAAI,CAkBtE"}
@@ -0,0 +1,276 @@
1
+ // src/runtime.ts
2
+ function getPanelMode(panel) {
3
+ if (panel.classList.contains("ant-picker-month-panel")) return "month";
4
+ if (panel.classList.contains("ant-picker-year-panel")) return "year";
5
+ if (panel.classList.contains("ant-picker-decade-panel")) return "decade";
6
+ return "date";
7
+ }
8
+ var MONTH_NAMES = [
9
+ "jan",
10
+ "feb",
11
+ "mar",
12
+ "apr",
13
+ "may",
14
+ "jun",
15
+ "jul",
16
+ "aug",
17
+ "sep",
18
+ "oct",
19
+ "nov",
20
+ "dec"
21
+ ];
22
+ function injectHeaderViewButtons(panel, prefix, attrName, suffix) {
23
+ const headerView = panel.querySelector(".ant-picker-header-view");
24
+ if (!headerView) return;
25
+ const buttons = headerView.querySelectorAll("button");
26
+ buttons.forEach((btn) => {
27
+ const el = btn;
28
+ if (el.hasAttribute(attrName)) return;
29
+ const text = el.textContent?.trim().toLowerCase() || "";
30
+ let name;
31
+ if (MONTH_NAMES.some((m) => text.startsWith(m))) {
32
+ name = "header-month-btn";
33
+ } else if (/^\d{1,2}月?$/.test(text)) {
34
+ name = "header-month-btn";
35
+ } else if (/^\d{4}$/.test(text)) {
36
+ name = "header-year-btn";
37
+ } else if (/^\d{4}-\d{4}$/.test(text)) {
38
+ name = "header-decade-btn";
39
+ } else {
40
+ name = `header-btn-${text}`;
41
+ }
42
+ el.setAttribute(attrName, `${prefix}-${name}${suffix}`);
43
+ });
44
+ }
45
+ var _observedContainers = /* @__PURE__ */ new WeakSet();
46
+ function injectSinglePanel(panel, prefix, attrName, suffix) {
47
+ const mode = getPanelMode(panel);
48
+ const setAttr = (selector, name) => {
49
+ const el = panel.querySelector(selector);
50
+ if (el && !el.hasAttribute(attrName)) {
51
+ el.setAttribute(attrName, `${prefix}-${name}${suffix}`);
52
+ }
53
+ };
54
+ setAttr(".ant-picker-header-super-prev-btn", "super-prev");
55
+ setAttr(".ant-picker-header-prev-btn", "prev");
56
+ setAttr(".ant-picker-header-next-btn", "next");
57
+ setAttr(".ant-picker-header-super-next-btn", "super-next");
58
+ setAttr(".ant-picker-header-view", `header-${mode}`);
59
+ injectHeaderViewButtons(panel, prefix, attrName, suffix);
60
+ injectPanelCells(panel, prefix, attrName, suffix);
61
+ }
62
+ function injectPanelCells(panel, prefix, attrName, suffix) {
63
+ const mode = getPanelMode(panel);
64
+ const cells = panel.querySelectorAll(".ant-picker-cell-inner");
65
+ cells.forEach((cell) => {
66
+ const el = cell;
67
+ if (el.hasAttribute(attrName)) return;
68
+ const raw = (el.getAttribute("title") || el.textContent?.trim() || "").replace(/\s+/g, "-");
69
+ let label;
70
+ switch (mode) {
71
+ case "month":
72
+ label = raw.length >= 2 ? raw.slice(-2) : raw;
73
+ el.setAttribute(attrName, `${prefix}-month-${label}${suffix}`);
74
+ break;
75
+ case "year":
76
+ label = raw;
77
+ el.setAttribute(attrName, `${prefix}-year-${label}${suffix}`);
78
+ break;
79
+ case "decade":
80
+ label = raw;
81
+ el.setAttribute(attrName, `${prefix}-decade-${label}${suffix}`);
82
+ break;
83
+ default:
84
+ label = raw;
85
+ el.setAttribute(attrName, `${prefix}-date-${label}${suffix}`);
86
+ break;
87
+ }
88
+ });
89
+ }
90
+ function injectPickerPanel(ctx) {
91
+ const { container, triggerTestId, attrName } = ctx;
92
+ const prefix = `${triggerTestId}-dropdown`;
93
+ container.setAttribute(attrName, prefix);
94
+ const isRange = container.classList.contains("ant-picker-dropdown-range");
95
+ const injectAllPanels = () => {
96
+ const panels = container.querySelectorAll(".ant-picker-panel");
97
+ if (isRange && panels.length >= 2) {
98
+ injectSinglePanel(panels[0], prefix, attrName, "-left");
99
+ injectSinglePanel(panels[1], prefix, attrName, "-right");
100
+ } else {
101
+ const panel = panels[0] || container;
102
+ injectSinglePanel(panel, prefix, attrName, "");
103
+ }
104
+ const presets = container.querySelectorAll(".ant-picker-presets .ant-tag");
105
+ presets.forEach((tag) => {
106
+ const el = tag;
107
+ if (el.hasAttribute(attrName)) return;
108
+ const text = el.textContent?.trim() || "";
109
+ el.setAttribute(attrName, `${prefix}-preset-${text}`);
110
+ });
111
+ const timeColumns = container.querySelectorAll(".ant-picker-time-panel-column");
112
+ timeColumns.forEach((col, i) => {
113
+ const el = col;
114
+ if (el.hasAttribute(attrName)) return;
115
+ el.setAttribute(attrName, `${prefix}-time-column-${i}`);
116
+ const items = col.querySelectorAll("li");
117
+ items.forEach((item) => {
118
+ const li = item;
119
+ if (li.hasAttribute(attrName)) return;
120
+ const text = li.textContent?.trim() || "";
121
+ li.setAttribute(attrName, `${prefix}-time-${text}`);
122
+ });
123
+ });
124
+ const setFooterBtn = (selector, name) => {
125
+ const btn = container.querySelector(selector);
126
+ if (btn && !btn.hasAttribute(attrName)) {
127
+ btn.setAttribute(attrName, `${prefix}-${name}`);
128
+ }
129
+ };
130
+ setFooterBtn(".ant-picker-ok button", "ok");
131
+ setFooterBtn(".ant-picker-now button", "now");
132
+ setFooterBtn(".ant-picker-now-btn", "now");
133
+ };
134
+ injectAllPanels();
135
+ if (!_observedContainers.has(container)) {
136
+ _observedContainers.add(container);
137
+ let timer = null;
138
+ const observer = new MutationObserver(() => {
139
+ if (timer !== null) clearTimeout(timer);
140
+ timer = setTimeout(() => {
141
+ injectAllPanels();
142
+ }, 50);
143
+ });
144
+ observer.observe(container, { childList: true, subtree: true });
145
+ }
146
+ }
147
+ function injectCascaderPanel(ctx) {
148
+ const { container, triggerTestId, attrName } = ctx;
149
+ const prefix = `${triggerTestId}-dropdown`;
150
+ container.setAttribute(attrName, prefix);
151
+ const menus = container.querySelectorAll(".ant-cascader-menu");
152
+ menus.forEach((menu, i) => {
153
+ const el = menu;
154
+ el.setAttribute(attrName, `${prefix}-menu-${i}`);
155
+ const items = menu.querySelectorAll(".ant-cascader-menu-item");
156
+ items.forEach((item) => {
157
+ const li = item;
158
+ const text = li.textContent?.trim() || "";
159
+ li.setAttribute(attrName, `${prefix}-item-${text}`);
160
+ });
161
+ });
162
+ }
163
+ function injectSelectPanel(ctx) {
164
+ const { container, triggerTestId, attrName } = ctx;
165
+ const prefix = `${triggerTestId}-dropdown`;
166
+ container.setAttribute(attrName, prefix);
167
+ const injectAllItems = () => {
168
+ const options = container.querySelectorAll(".ant-select-item-option");
169
+ options.forEach((opt) => {
170
+ const el = opt;
171
+ if (el.hasAttribute(attrName)) return;
172
+ const title = el.getAttribute("title") || el.textContent?.trim() || "";
173
+ el.setAttribute(attrName, `${prefix}-option-${title}`);
174
+ });
175
+ const treeNodes = container.querySelectorAll(".ant-select-tree-node-content-wrapper");
176
+ treeNodes.forEach((node) => {
177
+ const el = node;
178
+ if (el.hasAttribute(attrName)) return;
179
+ const title = el.getAttribute("title") || el.textContent?.trim() || "";
180
+ el.setAttribute(attrName, `${prefix}-tree-${title}`);
181
+ });
182
+ };
183
+ injectAllItems();
184
+ if (!_observedContainers.has(container)) {
185
+ _observedContainers.add(container);
186
+ let timer = null;
187
+ const observer = new MutationObserver(() => {
188
+ if (timer !== null) clearTimeout(timer);
189
+ timer = setTimeout(() => {
190
+ injectAllItems();
191
+ }, 50);
192
+ });
193
+ observer.observe(container, { childList: true, subtree: true });
194
+ }
195
+ }
196
+ var DEFAULT_PANELS = {
197
+ ".ant-picker-dropdown": injectPickerPanel,
198
+ ".ant-cascader-dropdown": injectCascaderPanel,
199
+ ".ant-select-dropdown": injectSelectPanel
200
+ };
201
+ function getTriggerTestId(trigger, attrName) {
202
+ if (!trigger) return "unknown-picker";
203
+ return trigger.getAttribute(attrName) || trigger.getAttribute("id") || "unknown-picker";
204
+ }
205
+ function findActiveTrigger(attrName) {
206
+ const focused = document.querySelector(".ant-picker-focused");
207
+ if (focused) {
208
+ if (focused.hasAttribute(attrName)) return focused;
209
+ const inner = focused.querySelector(`[${attrName}]`);
210
+ if (inner) return inner;
211
+ const input = focused.querySelector("input[id]");
212
+ if (input) return input;
213
+ }
214
+ const selectOpen = document.querySelector(".ant-select-open");
215
+ if (selectOpen?.hasAttribute(attrName)) return selectOpen;
216
+ const cascader = document.querySelector(".ant-cascader-picker-focused");
217
+ if (cascader?.hasAttribute(attrName)) return cascader;
218
+ const expanded = document.querySelector(
219
+ `[${attrName}][aria-expanded="true"]`
220
+ );
221
+ if (expanded) return expanded;
222
+ return null;
223
+ }
224
+ function setupAntDropdownTestIds(opts = {}) {
225
+ const attrName = opts.attributeName ?? "data-testid";
226
+ const panels = { ...DEFAULT_PANELS, ...opts.panels };
227
+ const observer = new MutationObserver((mutations) => {
228
+ for (const mutation of mutations) {
229
+ for (const node of mutation.addedNodes) {
230
+ if (!(node instanceof HTMLElement)) continue;
231
+ for (const [selector, strategy] of Object.entries(panels)) {
232
+ const containers = node.matches(selector) ? [node] : Array.from(node.querySelectorAll(selector));
233
+ for (const container of containers) {
234
+ if (!(container instanceof HTMLElement)) continue;
235
+ if (container.hasAttribute(attrName)) continue;
236
+ if (strategy) {
237
+ queueMicrotask(() => {
238
+ const trigger = findActiveTrigger(attrName);
239
+ const triggerTestId = getTriggerTestId(trigger, attrName);
240
+ const ctx = {
241
+ container,
242
+ trigger,
243
+ triggerTestId,
244
+ attrName
245
+ };
246
+ strategy(ctx);
247
+ });
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ });
254
+ observer.observe(document.body, { childList: true, subtree: true });
255
+ return () => observer.disconnect();
256
+ }
257
+ function injectCurrentDropdowns(opts = {}) {
258
+ const attrName = opts.attributeName ?? "data-testid";
259
+ const panels = { ...DEFAULT_PANELS, ...opts.panels };
260
+ for (const [selector, strategy] of Object.entries(panels)) {
261
+ const containers = document.querySelectorAll(selector);
262
+ containers.forEach((container) => {
263
+ if (!(container instanceof HTMLElement)) return;
264
+ if (container.hasAttribute(attrName)) return;
265
+ if (!strategy) return;
266
+ const trigger = findActiveTrigger(attrName);
267
+ const triggerTestId = getTriggerTestId(trigger, attrName);
268
+ strategy({ container, trigger, triggerTestId, attrName });
269
+ });
270
+ }
271
+ }
272
+ export {
273
+ injectCurrentDropdowns,
274
+ setupAntDropdownTestIds
275
+ };
276
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * 运行时辅助模块:为 ant-design-vue teleport 弹出层(日期选择器、级联选择器等)\n * 中的 DOM 元素注入 data-testid,解决编译期无法处理的定位问题。\n *\n * 使用方式(在 main.ts 或入口文件中调用一次即可):\n * ```ts\n * import { setupAntDropdownTestIds } from 'vite-plugin-vue-testid/runtime'\n *\n * setupAntDropdownTestIds()\n * ```\n */\n\nexport interface RuntimeOptions {\n /**\n * testId 属性名,需与编译期插件保持一致\n * @default 'data-testid'\n */\n attributeName?: string\n\n /**\n * 需要监听的 teleport 面板 CSS 选择器映射\n * key: 面板容器选择器\n * value: 注入策略(自定义函数 或 undefined 使用内置策略)\n */\n panels?: Record<string, PanelInjectStrategy | undefined>\n}\n\nexport interface PanelContext {\n /** 面板容器 DOM 元素 */\n container: HTMLElement\n /** 当前激活的触发器元素 */\n trigger: HTMLElement | null\n /** 触发器的 testId 值 */\n triggerTestId: string\n /** testId 属性名 */\n attrName: string\n}\n\nexport type PanelInjectStrategy = (ctx: PanelContext) => void\n\n// ──────────────────────── 内置注入策略 ────────────────────────\n\n/**\n * 判断当前 PickerPanel 处于哪种展示模式。\n * ant-design 通过 CSS 类区分:date / month / year / decade\n */\nfunction getPanelMode(panel: Element): 'date' | 'month' | 'year' | 'decade' {\n if (panel.classList.contains('ant-picker-month-panel')) return 'month'\n if (panel.classList.contains('ant-picker-year-panel')) return 'year'\n if (panel.classList.contains('ant-picker-decade-panel')) return 'decade'\n return 'date'\n}\n\n/** 月份英文缩写 */\nconst MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun',\n 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']\n\n/**\n * 为 header-view 内部的独立按钮注入 testId\n * ant-design 中 header-view 在 date 模式下包含两个按钮:\n * - 月份按钮(如 \"Jun\" / \"6月\")→ header-month-btn\n * - 年份按钮(如 \"2026\") → header-year-btn\n */\nfunction injectHeaderViewButtons(\n panel: Element,\n prefix: string,\n attrName: string,\n suffix: string,\n): void {\n const headerView = panel.querySelector('.ant-picker-header-view')\n if (!headerView) return\n\n const buttons = headerView.querySelectorAll('button')\n buttons.forEach((btn) => {\n const el = btn as HTMLElement\n if (el.hasAttribute(attrName)) return\n\n const text = el.textContent?.trim().toLowerCase() || ''\n\n let name: string\n if (MONTH_NAMES.some((m) => text.startsWith(m))) {\n // 匹配 Jan/Feb/... 英文月份缩写\n name = 'header-month-btn'\n } else if (/^\\d{1,2}月?$/.test(text)) {\n // 匹配 \"1月\" \"2月\" ... \"12月\" 或纯数字 \"01\" \"1\" ~ \"12\"\n name = 'header-month-btn'\n } else if (/^\\d{4}$/.test(text)) {\n // 4 位数字年份如 \"2026\"\n name = 'header-year-btn'\n } else if (/^\\d{4}-\\d{4}$/.test(text)) {\n // 年代范围如 \"2020-2029\"\n name = 'header-decade-btn'\n } else {\n // 兜底:直接用文本内容\n name = `header-btn-${text}`\n }\n\n el.setAttribute(attrName, `${prefix}-${name}${suffix}`)\n })\n}\n\n/** 已绑定 MutationObserver 的容器,避免重复绑定 */\nconst _observedContainers = new WeakSet<Element>()\n\n/**\n * 为单个 PickerPanel 注入全部 testId:\n * - header 按钮(super-prev/prev/next/super-next)\n * - header-view(带模式后缀:header-date / header-month / header-year)\n * - header-view 内部按钮(header-month-btn / header-year-btn)\n * - 单元格(模式区分:date-xxx / month-xx / year-xxxx 等)\n */\nfunction injectSinglePanel(\n panel: Element,\n prefix: string,\n attrName: string,\n suffix: '' | '-left' | '-right',\n): void {\n const mode = getPanelMode(panel)\n\n const setAttr = (selector: string, name: string) => {\n const el = panel.querySelector(selector) as HTMLElement | null\n if (el && !el.hasAttribute(attrName)) {\n el.setAttribute(attrName, `${prefix}-${name}${suffix}`)\n }\n }\n\n setAttr('.ant-picker-header-super-prev-btn', 'super-prev')\n setAttr('.ant-picker-header-prev-btn', 'prev')\n setAttr('.ant-picker-header-next-btn', 'next')\n setAttr('.ant-picker-header-super-next-btn', 'super-next')\n setAttr('.ant-picker-header-view', `header-${mode}`)\n\n injectHeaderViewButtons(panel, prefix, attrName, suffix)\n injectPanelCells(panel, prefix, attrName, suffix)\n}\n\n/**\n * 根据面板当前模式注入单元格 testId\n *\n * date → data-testid=\"{prefix}-date-2026-06-15{suffix}\"\n * month → data-testid=\"{prefix}-month-01{suffix}\"\n * year → data-testid=\"{prefix}-year-2026{suffix}\"\n * decade → data-testid=\"{prefix}-decade-2020-2029{suffix}\"\n */\nfunction injectPanelCells(\n panel: Element,\n prefix: string,\n attrName: string,\n suffix: string,\n): void {\n const mode = getPanelMode(panel)\n const cells = panel.querySelectorAll('.ant-picker-cell-inner')\n\n cells.forEach((cell) => {\n const el = cell as HTMLElement\n if (el.hasAttribute(attrName)) return\n\n const raw = (el.getAttribute('title') || el.textContent?.trim() || '').replace(/\\s+/g, '-')\n let label: string\n\n switch (mode) {\n case 'month':\n // antd 月份单元格 title 一般是 \"2026-01\",取后两位即可\n label = raw.length >= 2 ? raw.slice(-2) : raw\n el.setAttribute(attrName, `${prefix}-month-${label}${suffix}`)\n break\n case 'year':\n label = raw\n el.setAttribute(attrName, `${prefix}-year-${label}${suffix}`)\n break\n case 'decade':\n label = raw\n el.setAttribute(attrName, `${prefix}-decade-${label}${suffix}`)\n break\n default:\n // date 模式\n label = raw\n el.setAttribute(attrName, `${prefix}-date-${label}${suffix}`)\n break\n }\n })\n}\n\n/**\n * ant-design-vue DatePicker / RangePicker / TimePicker 面板策略\n *\n * DatePicker:单面板,直接注入\n * RangePicker:左右双面板,分别标记 left / right\n * TimePicker:时间列,逐项注入\n */\nfunction injectPickerPanel(ctx: PanelContext): void {\n const { container, triggerTestId, attrName } = ctx\n const prefix = `${triggerTestId}-dropdown`\n\n // 面板容器本身\n container.setAttribute(attrName, prefix)\n\n const isRange = container.classList.contains('ant-picker-dropdown-range')\n\n /**\n * 注入容器内所有子面板(header+单元格)\n * 同时注入顶部/底部固定元素(preset、footer buttons 等)\n */\n const injectAllPanels = () => {\n const panels = container.querySelectorAll('.ant-picker-panel')\n if (isRange && panels.length >= 2) {\n injectSinglePanel(panels[0], prefix, attrName, '-left')\n injectSinglePanel(panels[1], prefix, attrName, '-right')\n } else {\n const panel = panels[0] || container\n injectSinglePanel(panel, prefix, attrName, '')\n }\n\n // preset 快捷范围标签\n const presets = container.querySelectorAll('.ant-picker-presets .ant-tag')\n presets.forEach((tag) => {\n const el = tag as HTMLElement\n if (el.hasAttribute(attrName)) return\n const text = el.textContent?.trim() || ''\n el.setAttribute(attrName, `${prefix}-preset-${text}`)\n })\n\n // 时间选择器的时间列\n const timeColumns = container.querySelectorAll('.ant-picker-time-panel-column')\n timeColumns.forEach((col, i) => {\n const el = col as HTMLElement\n if (el.hasAttribute(attrName)) return\n el.setAttribute(attrName, `${prefix}-time-column-${i}`)\n const items = col.querySelectorAll('li')\n items.forEach((item) => {\n const li = item as HTMLElement\n if (li.hasAttribute(attrName)) return\n const text = li.textContent?.trim() || ''\n li.setAttribute(attrName, `${prefix}-time-${text}`)\n })\n })\n\n // 底部按钮\n const setFooterBtn = (selector: string, name: string) => {\n const btn = container.querySelector(selector) as HTMLElement | null\n if (btn && !btn.hasAttribute(attrName)) {\n btn.setAttribute(attrName, `${prefix}-${name}`)\n }\n }\n setFooterBtn('.ant-picker-ok button', 'ok')\n setFooterBtn('.ant-picker-now button', 'now')\n setFooterBtn('.ant-picker-now-btn', 'now')\n }\n\n // 首次注入\n injectAllPanels()\n\n // ── 容器级 MutationObserver ──\n // 绑定到 dropdown 容器而非单个 panel,这样即便 ant-design\n // 在 date↔month↔year 切换时替换了 panel 元素,也能检测到变化并重注入\n if (!_observedContainers.has(container)) {\n _observedContainers.add(container)\n\n let timer: ReturnType<typeof setTimeout> | null = null\n\n const observer = new MutationObserver(() => {\n if (timer !== null) clearTimeout(timer)\n timer = setTimeout(() => {\n injectAllPanels()\n }, 50) // 等 Vue nextTick + DOM 全部更新\n })\n\n observer.observe(container, { childList: true, subtree: true })\n }\n}\n\n/**\n * ant-design-vue Cascader 面板策略\n */\nfunction injectCascaderPanel(ctx: PanelContext): void {\n const { container, triggerTestId, attrName } = ctx\n const prefix = `${triggerTestId}-dropdown`\n\n container.setAttribute(attrName, prefix)\n\n const menus = container.querySelectorAll('.ant-cascader-menu')\n menus.forEach((menu, i) => {\n const el = menu as HTMLElement\n el.setAttribute(attrName, `${prefix}-menu-${i}`)\n const items = menu.querySelectorAll('.ant-cascader-menu-item')\n items.forEach((item) => {\n const li = item as HTMLElement\n const text = li.textContent?.trim() || ''\n li.setAttribute(attrName, `${prefix}-item-${text}`)\n })\n })\n}\n\n/**\n * ant-design-vue Select / TreeSelect 下拉面板策略\n */\nfunction injectSelectPanel(ctx: PanelContext): void {\n const { container, triggerTestId, attrName } = ctx\n const prefix = `${triggerTestId}-dropdown`\n\n container.setAttribute(attrName, prefix)\n\n /** 注入容器内所有选项/树节点的 testId */\n const injectAllItems = () => {\n const options = container.querySelectorAll('.ant-select-item-option')\n options.forEach((opt) => {\n const el = opt as HTMLElement\n if (el.hasAttribute(attrName)) return\n const title = el.getAttribute('title') || el.textContent?.trim() || ''\n el.setAttribute(attrName, `${prefix}-option-${title}`)\n })\n\n const treeNodes = container.querySelectorAll('.ant-select-tree-node-content-wrapper')\n treeNodes.forEach((node) => {\n const el = node as HTMLElement\n if (el.hasAttribute(attrName)) return\n const title = el.getAttribute('title') || el.textContent?.trim() || ''\n el.setAttribute(attrName, `${prefix}-tree-${title}`)\n })\n }\n\n // 首次注入\n injectAllItems()\n\n // TreeSelect 展开/折叠子节点时会动态增删 DOM,需要 Observer 持续注入\n if (!_observedContainers.has(container)) {\n _observedContainers.add(container)\n\n let timer: ReturnType<typeof setTimeout> | null = null\n\n const observer = new MutationObserver(() => {\n if (timer !== null) clearTimeout(timer)\n timer = setTimeout(() => {\n injectAllItems()\n }, 50)\n })\n\n observer.observe(container, { childList: true, subtree: true })\n }\n}\n\n// ──────────────────────── 默认面板配置 ────────────────────────\n\nconst DEFAULT_PANELS: Record<string, PanelInjectStrategy> = {\n '.ant-picker-dropdown': injectPickerPanel,\n '.ant-cascader-dropdown': injectCascaderPanel,\n '.ant-select-dropdown': injectSelectPanel,\n}\n\n// ──────────────────────── Observer ────────────────────────\n\n/**\n * 获取触发器上的 testId 值。\n * ant-design-vue 4.x DatePicker 由于 inheritAttrs: false,data-testid 被丢弃,\n * 只有 id 被转发到了内部 input。因为编译期插件同时注入 id + data-testid(值相同),\n * 优先读 testid,兜底读 id。\n */\nfunction getTriggerTestId(trigger: HTMLElement | null, attrName: string): string {\n if (!trigger) return 'unknown-picker'\n return trigger.getAttribute(attrName) || trigger.getAttribute('id') || 'unknown-picker'\n}\n\n/**\n * 查找当前激活的触发器元素\n */\nfunction findActiveTrigger(attrName: string): HTMLElement | null {\n // 日期选择器:有 .ant-picker-focused 类\n const focused = document.querySelector('.ant-picker-focused') as HTMLElement | null\n if (focused) {\n // 1) focused 根元素自身有 testid\n if (focused.hasAttribute(attrName)) return focused\n // 2) 内部子元素有 testid(ant-design-vue 4.x 通过 getDataOrAriaProps 转发到 input)\n const inner = focused.querySelector(`[${attrName}]`) as HTMLElement | null\n if (inner) return inner\n // 3) ant-design-vue 4.x DatePicker: data-testid 被丢弃(不在 Picker 的 declared props 中),\n // 但 id 属性被显式转发到了内部 <input>。由于插件同时注入 id + data-testid 且值相同,\n // 可以通过 input[id] 找到触发器\n const input = focused.querySelector('input[id]') as HTMLElement | null\n if (input) return input\n }\n\n // Select / TreeSelect:展开时根 div 有 .ant-select-open 类,且 data-testid 正常到达 DOM\n const selectOpen = document.querySelector('.ant-select-open') as HTMLElement | null\n if (selectOpen?.hasAttribute(attrName)) return selectOpen\n\n // 级联选择器\n const cascader = document.querySelector('.ant-cascader-picker-focused') as HTMLElement | null\n if (cascader?.hasAttribute(attrName)) return cascader\n\n // 通用:查找有 testId 的最近打开的面板触发器\n // ant-design 通常在展开面板的元素上添加 aria-expanded=\"true\"\n const expanded = document.querySelector(\n `[${attrName}][aria-expanded=\"true\"]`,\n ) as HTMLElement | null\n if (expanded) return expanded\n\n return null\n}\n\n/**\n * 为 teleport 弹出层面板注入 testId。\n * 使用 MutationObserver 监听 DOM 新增节点,自动识别 ant-design 弹出面板。\n *\n * @example\n * ```ts\n * // main.ts\n * import { setupAntDropdownTestIds } from 'vite-plugin-vue-testid/runtime'\n *\n * createApp(App).mount('#app')\n * setupAntDropdownTestIds()\n * ```\n *\n * testId 注入结果示例:\n * ```\n * // 日期面板(初始)\n * a-date-picker-0-dropdown // 面板容器\n * a-date-picker-0-dropdown-prev // 上月/上一级\n * a-date-picker-0-dropdown-next // 下月/下一级\n * a-date-picker-0-dropdown-header-date // header 容器\n * a-date-picker-0-dropdown-header-month-btn // header 内的月份按钮(\"Jun\"/\"6月\")\n * a-date-picker-0-dropdown-header-year-btn // header 内的年份按钮(\"2026\")\n * a-date-picker-0-dropdown-date-2026-06-15 // 日期单元格\n * a-date-picker-0-dropdown-ok // 确定按钮\n *\n * // 点击月份按钮 → 月面板(MutationObserver 自动重注入)\n * a-date-picker-0-dropdown-header-month // header 容器\n * a-date-picker-0-dropdown-header-year-btn // header 内的年份按钮\n * a-date-picker-0-dropdown-month-06 // 6月按钮\n *\n * // 点击年份按钮 → 年面板\n * a-date-picker-0-dropdown-header-year // header 容器\n * a-date-picker-0-dropdown-year-2026 // 2026年按钮\n * ```\n */\nexport function setupAntDropdownTestIds(opts: RuntimeOptions = {}): () => void {\n const attrName = opts.attributeName ?? 'data-testid'\n const panels = { ...DEFAULT_PANELS, ...opts.panels }\n\n const observer = new MutationObserver((mutations) => {\n for (const mutation of mutations) {\n for (const node of mutation.addedNodes) {\n if (!(node instanceof HTMLElement)) continue\n\n // 检查新增节点匹配哪种面板选择器\n for (const [selector, strategy] of Object.entries(panels)) {\n const containers = node.matches(selector)\n ? [node]\n : Array.from(node.querySelectorAll(selector))\n\n for (const container of containers) {\n if (!(container instanceof HTMLElement)) continue\n if (container.hasAttribute(attrName)) continue // 已注入过\n\n // 等一个微任务让 CSS 类生效,再找触发器\n if (strategy) {\n queueMicrotask(() => {\n const trigger = findActiveTrigger(attrName)\n const triggerTestId = getTriggerTestId(trigger, attrName)\n\n const ctx: PanelContext = {\n container,\n trigger,\n triggerTestId,\n attrName,\n }\n strategy(ctx)\n })\n }\n }\n }\n }\n }\n })\n\n observer.observe(document.body, { childList: true, subtree: true })\n\n // 返回清理函数\n return () => observer.disconnect()\n}\n\n/**\n * 手动为当前已存在或即将出现的弹出面板注入 testId(一次性补丁)。\n * 适用于 SSR/hydration 或不想用 MutationObserver 的场景。\n */\nexport function injectCurrentDropdowns(opts: RuntimeOptions = {}): void {\n const attrName = opts.attributeName ?? 'data-testid'\n const panels = { ...DEFAULT_PANELS, ...opts.panels }\n\n for (const [selector, strategy] of Object.entries(panels)) {\n const containers = document.querySelectorAll(selector)\n containers.forEach((container) => {\n if (!(container instanceof HTMLElement)) return\n if (container.hasAttribute(attrName)) return\n\n if (!strategy) return\n\n const trigger = findActiveTrigger(attrName)\n const triggerTestId = getTriggerTestId(trigger, attrName)\n\n strategy({ container, trigger, triggerTestId, attrName })\n })\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "vite-plugin-vue-testid",
3
+ "version": "1.0.0",
4
+ "description": "Vite plugin to auto-inject data-testid into Vue component templates for E2E testing",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./runtime": {
15
+ "import": "./dist/runtime.js",
16
+ "types": "./dist/runtime.d.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsup && tsc -p tsconfig.build.json",
24
+ "dev": "tsup --watch",
25
+ "prepublishOnly": "pnpm build"
26
+ },
27
+ "keywords": [
28
+ "vite",
29
+ "plugin",
30
+ "vue",
31
+ "testid",
32
+ "e2e",
33
+ "testing",
34
+ "ant-design-vue"
35
+ ],
36
+ "license": "MIT",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "peerDependencies": {
41
+ "vite": ">=5.0.0",
42
+ "vue": ">=3.3.0"
43
+ },
44
+ "dependencies": {
45
+ "@vue/compiler-core": "^3.5.0"
46
+ },
47
+ "devDependencies": {
48
+ "tsup": "^8.5.1",
49
+ "typescript": "~6.0.2",
50
+ "vite": "^8.0.12"
51
+ }
52
+ }