vite-plugin-vue-testid 1.1.3 → 1.1.5

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 CHANGED
@@ -6,15 +6,21 @@ A Vite plugin that auto-injects `data-testid` into Vue component templates via c
6
6
 
7
7
  ## Features
8
8
 
9
+ - **Compile-time three-layer injection** — Prioritized counter architecture ensures stable, unique testids:
10
+ - **v-for dynamic injection** (最高) — uses loop index variable for per-iteration uniqueness without runtime dedup
11
+ - **Conditional block sub-counters** (第二) — v-if/v-else/v-show blocks own independent counters, child testids prefixed with parent's testid for stability
12
+ - **Global counter** (第三) — cross-template shared counter with used-id set for uniqueness
9
13
  - **Full runtime coverage** — MutationObserver-based injection for all ant-design-vue component roots, sub-elements, and teleported panels, no compile-time config required
10
- - **Optional compile-time enhancement** — Inject stable, unique testids into custom business components (e.g., `<MyInput />`) at compile time, automatically passed through to inner antd components via `inheritAttrs`
14
+ - **Vue plugin bridge** — `createTestIdBridge()` reads compile-time testid from VNode.props and writes to `$el`, bypassing `inheritAttrs: false`
11
15
  - **Multi-root component fix** — Auto-adds `v-bind="$attrs"` to the first antd root element in multi-root child components, ensuring testid pass-through
12
16
  - **Custom prefixCls** — Full support for ConfigProvider's custom CSS class prefix
13
17
  - **Custom component mapping** — Extendable matcher rules for third-party UI libraries or custom components
14
18
  - **Full panel coverage** — DatePicker (date/month/year/decade), RangePicker, TimePicker, Cascader, Select, TreeSelect
15
19
  - **Sub-element injection** — InputNumber internal input & up/down buttons, Slider handles, Rate stars, Upload file input, Tabs tabs/panes
16
20
  - **Menu sub-elements** — Text-based testids for menu-item, sub-menu, and menu-item-group
17
- - **Preserves existing testIds** — Elements with manually assigned `data-testid` are skipped
21
+ - **Event listener marking** — `{tag}-event-{names}-{n}` format for elements with event handlers, making interactive elements easy to locate
22
+ - **Configurable prefix** — `testIdPrefix` option to prepend a common prefix to all compile-time testids
23
+ - **Preserves existing testIds** — Elements with manually assigned `data-testid` are skipped (all 3 layers are idempotent)
18
24
  - **MutationObserver dynamic watch** — Panel switching, tree node expand/collapse, and async component loading all auto-trigger re-injection
19
25
  - **One-shot injection mode** — SSR/hydration friendly, call directly without MutationObserver
20
26
 
@@ -59,31 +65,42 @@ app.mount('#app')
59
65
  setupAntTestIds()
60
66
  ```
61
67
 
62
- ### Mode B: Compile-time + Runtime (for custom components needing stable testids)
68
+ ### Mode B: Compile-time + Runtime (recommended for stable, predictable testids)
63
69
 
64
70
  ```ts
65
71
  // vite.config.ts
66
72
  import { defineConfig } from 'vite'
67
73
  import vue from '@vitejs/plugin-vue'
68
- import { testIdTransforms } from 'vite-plugin-vue-testid'
74
+ import { testIdTransforms, vueTestIdPlugin } from 'vite-plugin-vue-testid'
69
75
 
70
76
  export default defineConfig({
71
77
  plugins: [
72
78
  vue({
73
79
  template: {
74
80
  compilerOptions: {
75
- nodeTransforms: testIdTransforms()
81
+ nodeTransforms: testIdTransforms({
82
+ testIdPrefix: 'static-', // optional: common prefix
83
+ debug: false, // optional: debug logging
84
+ })
76
85
  }
77
86
  }
78
- })
87
+ }),
88
+ vueTestIdPlugin(),
79
89
  ]
80
90
  })
81
91
  ```
82
92
 
83
93
  ```ts
84
- // main.ts — same as Mode A
94
+ // main.ts
95
+ import { createApp } from 'vue'
96
+ import App from './App.vue'
97
+ import { createTestIdBridge } from 'vite-plugin-vue-testid/plugin'
85
98
  import { setupAntTestIds } from 'vite-plugin-vue-testid/runtime'
86
- setupAntTestIds()
99
+
100
+ const app = createApp(App)
101
+ app.use(createTestIdBridge()) // compile-time testid → DOM
102
+ app.mount('#app')
103
+ setupAntTestIds() // panels / sub-elements / fallback
87
104
  ```
88
105
 
89
106
  ### Mode C: Compile-time only (panels not injected, not recommended)
@@ -234,23 +251,73 @@ a-sub-menu-Submenu # submenu
234
251
  a-menu-item-group-Group-Title # menu item group
235
252
  ```
236
253
 
237
- ### Compile-time: Custom Business Components
254
+ ### Compile-time: Three-Layer Injection
255
+
256
+ The compile-time transform uses three priority-ordered injection layers:
257
+
258
+ | Priority | Layer | Format | Example |
259
+ |----------|-------|--------|---------|
260
+ | 1st (highest) | v-for with index | `{tag}_in_for-{i0}[_{i1}]` | `a-card_in_for-0_1` |
261
+ | 2nd | Conditional block child | `{parentTestId}__{tag}-{n}` | `a-button-1__a-input-0` |
262
+ | 3rd | Global counter | `{tag}-{n}` | `a-input-2` |
238
263
 
264
+ #### v-for Dynamic Injection
265
+
266
+ ```html
267
+ <!-- Source -->
268
+ <a-card v-for="(item, i) in list" :key="i"> ... </a-card>
269
+
270
+ <!-- Compiled (dynamic expression, index auto-resolves per iteration) -->
271
+ <a-card v-for="(item, i) in list" :key="i" data-testid="a-card_in_for-{{i}}"> ... </a-card>
272
+
273
+ <!-- Without index variable → static suffix -->
274
+ <!-- Source -->
275
+ <a-card v-for="item in list" :key="item.id"> ... </a-card>
276
+ <!-- Compiled -->
277
+ <a-card v-for="item in list" :key="item.id" data-testid="a-card-in_for-0"> ... </a-card>
278
+ ```
279
+
280
+ #### Conditional Block Sub-counters
281
+
282
+ ```html
283
+ <!-- Source -->
284
+ <a-card v-if="visible">
285
+ <a-input />
286
+ <a-button>Submit</a-button>
287
+ </a-card>
288
+ <a-card v-else>
289
+ <a-input />
290
+ </a-card>
291
+
292
+ <!-- Compiled (each conditional branch gets independent sub-counter) -->
293
+ <a-card v-if="visible" data-testid="a-card-0">
294
+ <a-input data-testid="a-card-0__a-input-0" />
295
+ <a-button data-testid="a-card-0__a-button-event-click-0">Submit</a-button>
296
+ </a-card>
297
+ <a-card v-else data-testid="a-card-1">
298
+ <a-input data-testid="a-card-1__a-input-0" />
299
+ </a-card>
239
300
  ```
240
- Format: {tagName}-{perTemplateIndex}
241
301
 
302
+ Child elements inside v-if/v-else/v-show blocks use `{parentTestId}__{tag}-{n}` format, ensuring:
303
+ - Testids are **stable** — parent element count changes don't affect child indices
304
+ - **Predictable** — testers can locate nested elements by chaining parent→child testids
305
+
306
+ #### Global Counter
307
+
308
+ ```html
242
309
  <!-- Source -->
243
- <MyInput />
244
- <MySelect />
245
- <MyInput />
310
+ <a-input placeholder="Name" />
311
+ <a-button>Save</a-button>
312
+ <a-input placeholder="Email" />
246
313
 
247
314
  <!-- Compiled -->
248
- <MyInput data-testid="MyInput-0" />
249
- <MySelect data-testid="MySelect-0" />
250
- <MyInput data-testid="MyInput-1" />
315
+ <a-input placeholder="Name" data-testid="a-input-0" />
316
+ <a-button data-testid="a-button-event-click-0">Save</a-button>
317
+ <a-input placeholder="Email" data-testid="a-input-1" />
251
318
  ```
252
319
 
253
- > **Design note**: The compile-time transform only injects testids on **custom business components** (passed through to inner antd components via `inheritAttrs`), NOT on antd components directly. All antd component testids are handled by the runtime layer (global counter ensures uniqueness).
320
+ > **Design note**: Global counters are shared across all templates, with a used-id Set to skip already-occupied testids. Conditional block sub-counters are independent adding/removing elements within a v-if block does not shift other elements' testids.
254
321
 
255
322
  ---
256
323
 
@@ -279,6 +346,45 @@ testIdTransforms({
279
346
  * @default []
280
347
  */
281
348
  customPrefixes: ['myapp-', 'el-'],
349
+
350
+ /**
351
+ * Common prefix prepended to all compile-time testids
352
+ * e.g., 'static-' produces 'static-a-button-0'
353
+ * @default ''
354
+ */
355
+ testIdPrefix: 'static-',
356
+
357
+ /**
358
+ * Enable v-for dynamic injection (requires index variable)
359
+ * @default true
360
+ */
361
+ injectForLoops: true,
362
+
363
+ /**
364
+ * Enable event listener marking on elements with @click, @input, etc.
365
+ * Produces '{tag}-event-{names}-{n}' format
366
+ * @default true
367
+ */
368
+ injectEventElements: true,
369
+
370
+ /**
371
+ * Enable debug logging
372
+ * @default false
373
+ */
374
+ debug: false,
375
+ })
376
+ ```
377
+
378
+ ### Vite Plugin Options
379
+
380
+ ```ts
381
+ import { vueTestIdPlugin } from 'vite-plugin-vue-testid'
382
+
383
+ vueTestIdPlugin({
384
+ attributeName: 'data-testid',
385
+ testIdPrefix: 'static-',
386
+ prefixCls: 'ant',
387
+ debug: false,
282
388
  })
283
389
  ```
284
390
 
@@ -377,12 +483,13 @@ injectCurrentDropdowns({ prefixCls: 'ant' })
377
483
 
378
484
  > Components with `id` injection (Select, Cascader, DatePicker, RangePicker, TimePicker, Modal, Upload) also have the `id` attribute set, with the same value as testid.
379
485
 
380
- ### Compile-time (custom business components)
486
+ ### Compile-time
381
487
 
382
488
  The compile-time transform injects testids on components matching:
383
- - Custom components with `tagType === Component` (e.g., `<MyInput />`)
489
+ - Any component with `tagType === Component` (e.g., `<MyInput />`)
490
+ - Components matching `antPrefix` (default: `a-*`) — with three-layer counter architecture
384
491
  - Components matching any prefix in `customPrefixes`
385
- - **Excluding** antd components (`a-*`), which are handled by the runtime
492
+ - **Excluding** native HTML elements, `<slot>`, `<template>`, and text-only components
386
493
 
387
494
  ---
388
495
 
@@ -428,7 +535,13 @@ setupAntTestIds({
428
535
  | `createTestIdTransforms(opts?)` | `() => NodeTransform[]` | Same as above, full export name |
429
536
  | `createTestIdInjectTransform(opts?)` | `() => NodeTransform` | Create only the testid inject transform (without multi-root fix) |
430
537
  | `createMultiRootFixTransform(opts?)` | `() => NodeTransform` | Create only the multi-root fix transform |
431
- | `TransformOptions` | `interface` | `{ attributeName?, antPrefix?, customPrefixes? }` |
538
+ | `TransformOptions` | `interface` | `{ attributeName?, antPrefix?, customPrefixes?, testIdPrefix?, injectForLoops?, injectEventElements?, debug? }` |
539
+
540
+ ### Vue Plugin Bridge (`/plugin` subpath)
541
+
542
+ | Export | Type | Description |
543
+ |--------|------|-------------|
544
+ | `createTestIdBridge(opts?)` | `Plugin` | Vue 3 plugin — reads testid from VNode.props and writes to `$el` |
432
545
 
433
546
  ### Runtime (`/runtime` subpath)
434
547
 
@@ -444,7 +557,8 @@ setupAntTestIds({
444
557
 
445
558
  | Export | Type | Description |
446
559
  |--------|------|-------------|
447
- | `vueTestIdPlugin(opts?)` | `Plugin` | Vite plugin (placeholder, currently no functionality) |
560
+ | `vueTestIdPlugin(opts?)` | `Plugin` | Vite plugin — config placeholder; actual injection via `testIdTransforms()` (compile-time) + `setupAntTestIds()` (runtime) |
561
+ | `VueTestIdPluginOptions` | `interface` | `{ attributeName?, testIdPrefix?, prefixCls?, debug? }` |
448
562
 
449
563
  ### Deprecated
450
564
 
@@ -470,7 +584,17 @@ Panel containers also have their own MutationObserver to handle child node chang
470
584
 
471
585
  ### Compile-time (Optional)
472
586
 
473
- Uses Vue's compiler `nodeTransform` hook at AST stage to inject `data-testid="{tagName}-{index}"` on custom business components. Since injection happens in the parent template, testids are automatically passed through to the child component's root element via Vue's `fallthroughAttrs` mechanism.
587
+ Uses Vue's compiler `nodeTransform` hook at the AST stage with a **three-layer counter/priority architecture**:
588
+
589
+ 1. **v-for Dynamic Injection** — When a component inside `v-for` has an index variable (e.g., `v-for="(item, i) in list"`), a dynamic JS expression is generated using the index variable chain (e.g., `a-card_in_for-{{i}}`). This produces unique per-iteration testids without runtime deduplication. Without index, falls back to `{tag}-in_for-{n}` static suffix.
590
+
591
+ 2. **Conditional Block Sub-counters** — v-if / v-else-if / v-else / v-show blocks own independent per-block counters. Child elements use `{parentTestId}__{tag}-{n}` format, where `parentTestId` is the containing block's testid. Adding/removing elements inside a conditional block does not affect testids in other blocks or outside.
592
+
593
+ 3. **Global Counter** — Non-conditional elements use a cross-template global counter with a `usedIds` Set to automatically skip already-occupied testids.
594
+
595
+ All three layers check for existing `data-testid` before injecting (idempotent). Native HTML elements, `<slot>`, `<template>`, and text-only components are skipped.
596
+
597
+ Since injection happens in the parent template, testids are automatically passed through to the child component's root element via Vue's `fallthroughAttrs` mechanism.
474
598
 
475
599
  For multi-root components (Fragment), `inheritAttrs` is not effective. `createMultiRootFixTransform` automatically adds `v-bind="$attrs"` to the first antd component root element to ensure pass-through.
476
600
 
package/dist/index.d.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * 为 Vue (ant-design-vue) 组件的 DOM 元素自动注入 data-testid 属性。
5
5
  *
6
- * 默认提供全运行时方案,可选启用编译期 transform 以获得稳定的、页面内唯一的 testid。
6
+ * 三层注入架构("铁三角"):
7
+ * 1. 编译期 transform — 为模板中的 antd/自定义组件注入稳定的 testid
8
+ * 2. Vue 插件桥接 — 将编译期注入的 testid 从 VNode.props 写入 DOM
9
+ * 3. 运行时 MutationObserver — 兜底覆盖弹层面板、子元素及动态内容
7
10
  *
8
11
  * === 使用方式 A:纯运行时 ===
9
12
  *
@@ -19,7 +22,7 @@
19
22
  * setupAntTestIds()
20
23
  * ```
21
24
  *
22
- * === 使用方式 B:编译期 + 运行时(推荐,获得唯一稳定 testid) ===
25
+ * === 使用方式 B:编译器 + Vue 插件 + 运行时(推荐,稳定 testid) ===
23
26
  *
24
27
  * ```ts
25
28
  * // vite.config.ts
@@ -30,7 +33,11 @@
30
33
  * vue({
31
34
  * template: {
32
35
  * compilerOptions: {
33
- * nodeTransforms: testIdTransforms()
36
+ * nodeTransforms: testIdTransforms({
37
+ * antPrefix: 'a-',
38
+ * testIdPrefix: 'static-', // 可选:公共前缀
39
+ * debug: false, // 可选:调试日志
40
+ * })
34
41
  * }
35
42
  * }
36
43
  * }),
@@ -38,9 +45,14 @@
38
45
  * ]
39
46
  * })
40
47
  *
41
- * // main.ts (与 A 相同)
48
+ * // main.ts
49
+ * import { createTestIdBridge } from 'vite-plugin-vue-testid/plugin'
42
50
  * import { setupAntTestIds } from 'vite-plugin-vue-testid/runtime'
43
- * setupAntTestIds()
51
+ *
52
+ * const app = createApp(App)
53
+ * app.use(createTestIdBridge()) // 编译器 testid → DOM(稳定)
54
+ * app.mount('#app')
55
+ * setupAntTestIds() // 面板 / 子元素 / 兜底
44
56
  * ```
45
57
  *
46
58
  * === 使用方式 C:仅编译期(无运行时,面板不会注入 testid) ===
@@ -63,20 +75,50 @@
63
75
  * })
64
76
  * // main.ts 不需要调用 setupAntTestIds()
65
77
  * ```
78
+ *
79
+ * === 编译期 testid 格式说明 ===
80
+ *
81
+ * | 场景 | 格式 | 示例 |
82
+ * |-----------------------------|----------------------------------------------|---------------------------------------|
83
+ * | 普通组件 | `{tag}-{n}` | `a-button-0`, `a-input-2` |
84
+ * | v-if/v-show 条件块的子组件 | `{parentTestId}__{tag}-{n}` | `a-button-1__a-input-0` |
85
+ * | v-for + index(动态) | `{tag}_in_for-{i0}[_{i1}]` | `a-card_in_for-0_1` |
86
+ * | v-for 内部组件(无 index) | `{tag}-in_for-{n}` | `a-button-in_for-0` |
87
+ * | 事件监听组件 | `{tag}-event-{names}-{n}` | `a-button-event-click-0` |
88
+ *
89
+ * 全局计数器跨所有模板共享,条件块拥有独立子计数器,确保条件渲染的组件
90
+ * 增删不影响外部组件的 testid 稳定性。
66
91
  */
67
92
  import type { Plugin } from 'vite';
68
93
  export type { RuntimeOptions, PanelContext, PanelInjectStrategy } from './runtime';
69
94
  export type { TransformOptions } from './transform';
95
+ export type { TestIdBridgeOptions } from './plugin';
70
96
  import { createTestIdTransforms } from './transform';
71
97
  /** 等同于 `createTestIdTransforms()`,更短的导出名 */
72
98
  export declare const testIdTransforms: typeof createTestIdTransforms;
73
99
  export { createTestIdTransforms, createTestIdInjectTransform, createMultiRootFixTransform, } from './transform';
100
+ export { createTestIdBridge } from './plugin';
74
101
  export interface VueTestIdPluginOptions {
75
102
  /**
76
103
  * testId 属性名
77
104
  * @default 'data-testid'
78
105
  */
79
106
  attributeName?: string;
107
+ /**
108
+ * 所有 testid 的公共前缀
109
+ * @default ''
110
+ */
111
+ testIdPrefix?: string;
112
+ /**
113
+ * ant-design-vue CSS 类名前缀,对应 ConfigProvider 的 prefixCls
114
+ * @default 'ant'
115
+ */
116
+ prefixCls?: string;
117
+ /**
118
+ * 启用调试日志
119
+ * @default false
120
+ */
121
+ debug?: boolean;
80
122
  }
81
123
  /**
82
124
  * Vite 插件。
@@ -84,8 +126,11 @@ export interface VueTestIdPluginOptions {
84
126
  * 当前作为配置占位符存在;实际的 testid 注入由以下组件完成:
85
127
  * - 编译期:`testIdTransforms()` → 注册到 `vue()` 的 compilerOptions
86
128
  * - 运行时:`setupAntTestIds()` → 在 main.ts 中调用
129
+ *
130
+ * 可以通过 `config` 钩子将 testid 配置注入到构建上下文中,
131
+ * 供运行时与编译期共享配置(如 prefixCls、attributeName 等)。
87
132
  */
88
- export declare function vueTestIdPlugin(_opts?: VueTestIdPluginOptions): Plugin;
133
+ export declare function vueTestIdPlugin(opts?: VueTestIdPluginOptions): Plugin;
89
134
  /**
90
135
  * @deprecated 此函数已废弃。请使用新的编译期 API:
91
136
  *
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAIlC,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAA;AAClF,YAAY,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAEpD,4CAA4C;AAC5C,eAAO,MAAM,gBAAgB,+BAAyB,CAAA;AAEtD,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,2BAA2B,GAC5B,MAAM,aAAa,CAAA;AAIpB,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,CAAC,EAAE,sBAAsB,GAAG,MAAM,CAItE;AAID;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,IAAI,CASrD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0FG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAIlC,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAA;AAClF,YAAY,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AACnD,YAAY,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AAEnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAEpD,4CAA4C;AAC5C,eAAO,MAAM,gBAAgB,+BAAyB,CAAA;AAEtD,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,2BAA2B,GAC5B,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAI7C,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IAErB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,IAAI,CAAC,EAAE,sBAAsB,GAAG,MAAM,CAYrE;AAID;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,IAAI,CASrD"}
package/dist/index.js CHANGED
@@ -134,6 +134,30 @@ var NATIVE_TAGS = /* @__PURE__ */ new Set([
134
134
  "var",
135
135
  "wbr"
136
136
  ]);
137
+ var globalCounters = {};
138
+ var globalUsedIds = {};
139
+ function generateUniqueId(basePattern) {
140
+ let idx = globalCounters[basePattern] || 0;
141
+ let candidate;
142
+ do {
143
+ candidate = basePattern + "-" + idx;
144
+ idx++;
145
+ } while (globalUsedIds[candidate]);
146
+ globalCounters[basePattern] = idx;
147
+ globalUsedIds[candidate] = true;
148
+ return candidate;
149
+ }
150
+ function generateSubUniqueId(basePattern, state) {
151
+ let idx = state.counter[basePattern] || 0;
152
+ let candidate;
153
+ do {
154
+ candidate = basePattern + "-" + idx;
155
+ idx++;
156
+ } while (state.usedIds[candidate]);
157
+ state.counter[basePattern] = idx;
158
+ state.usedIds[candidate] = true;
159
+ return candidate;
160
+ }
137
161
  function isAntdComponent(tag, antPrefix) {
138
162
  if (!tag.startsWith(antPrefix)) return false;
139
163
  if (tag === antPrefix.slice(0, -1)) return false;
@@ -143,7 +167,7 @@ function isTargetComponent(el, antPrefix, customPrefixes) {
143
167
  const { tag, tagType } = el;
144
168
  if (tagType === ElementTypes.ELEMENT && NATIVE_TAGS.has(tag)) return false;
145
169
  if (tagType === ElementTypes.SLOT || tagType === ElementTypes.TEMPLATE) return false;
146
- if (isAntdComponent(tag, antPrefix)) return false;
170
+ if (isAntdComponent(tag, antPrefix)) return true;
147
171
  if (tagType === ElementTypes.COMPONENT) return true;
148
172
  if (customPrefixes.some((p) => tag.startsWith(p))) return true;
149
173
  return false;
@@ -177,7 +201,6 @@ function createVBindAttrsDirective(loc) {
177
201
  content: "$attrs",
178
202
  isStatic: true,
179
203
  constType: 3,
180
- // CAN_STRINGIFY
181
204
  loc: loc || {}
182
205
  },
183
206
  exp: {
@@ -185,13 +208,127 @@ function createVBindAttrsDirective(loc) {
185
208
  content: "$attrs",
186
209
  isStatic: false,
187
210
  constType: 0,
188
- // NOT_CONSTANT
189
211
  loc: loc || {}
190
212
  },
191
213
  modifiers: [],
192
214
  loc: loc || {}
193
215
  };
194
216
  }
217
+ function hasVShow(el) {
218
+ return el.props.some(
219
+ (p) => p.type === NodeTypes.DIRECTIVE && p.name === "show"
220
+ );
221
+ }
222
+ function hasEventListeners(el) {
223
+ return el.props.some(
224
+ (p) => p.type === NodeTypes.DIRECTIVE && p.name === "on"
225
+ );
226
+ }
227
+ function getEventNames(el) {
228
+ const names = [];
229
+ for (const p of el.props) {
230
+ if (p.type === NodeTypes.DIRECTIVE && p.name === "on" && p.arg) {
231
+ const arg = p.arg;
232
+ if (arg.content) names.push(arg.content);
233
+ }
234
+ }
235
+ return names.sort().join("_").slice(0, 40) || "unknown";
236
+ }
237
+ function injectTestId(el, attrName, testId, applyPrefix, debug) {
238
+ const finalTestId = applyPrefix(testId);
239
+ el.props.push(createStaticAttr(attrName, finalTestId, el.loc));
240
+ if (debug) {
241
+ console.log("[testid] INJECT", el.tag, "\u2192", finalTestId);
242
+ }
243
+ }
244
+ function buildVForTestIdExpression(tag, indexChain, testIdPrefix, extra) {
245
+ let expr = "";
246
+ if (testIdPrefix) {
247
+ expr = "'" + testIdPrefix + "' + ";
248
+ }
249
+ expr += "'" + tag + "_in_for-'";
250
+ for (let k = 0; k < indexChain.length; k++) {
251
+ if (k > 0) {
252
+ expr += " + '_'";
253
+ }
254
+ expr += " + " + indexChain[k];
255
+ }
256
+ if (extra) {
257
+ expr += " + '" + extra + "'";
258
+ }
259
+ return expr;
260
+ }
261
+ function injectDynamicForTestId(el, attrName, tag, indexChain, testIdPrefix, extra, debug) {
262
+ const expression = buildVForTestIdExpression(tag, indexChain, testIdPrefix, extra);
263
+ el.props.push({
264
+ type: NodeTypes.DIRECTIVE,
265
+ name: "bind",
266
+ arg: {
267
+ type: NodeTypes.SIMPLE_EXPRESSION,
268
+ content: attrName,
269
+ isStatic: true,
270
+ constType: 3,
271
+ loc: el.loc
272
+ },
273
+ exp: {
274
+ type: NodeTypes.SIMPLE_EXPRESSION,
275
+ content: expression,
276
+ isStatic: false,
277
+ constType: 0,
278
+ loc: el.loc
279
+ },
280
+ modifiers: [],
281
+ loc: el.loc
282
+ });
283
+ if (debug) {
284
+ console.log("[testid] VFOR-DYN", tag, "\u2192", attrName, "=", expression);
285
+ }
286
+ }
287
+ function findConditionalAncestor(parentMap, branchStateMap, condStateMap, el) {
288
+ let current = parentMap.get(el);
289
+ while (current) {
290
+ if (current.type === NodeTypes.IF_BRANCH) {
291
+ const state = branchStateMap.get(current);
292
+ if (state) return state;
293
+ }
294
+ if (current.type === NodeTypes.ELEMENT) {
295
+ const state = condStateMap.get(current);
296
+ if (state) return state;
297
+ }
298
+ current = parentMap.get(current);
299
+ }
300
+ return null;
301
+ }
302
+ function getVForIndexChain(parentMap, el) {
303
+ const chain = [];
304
+ let current = parentMap.get(el);
305
+ while (current) {
306
+ if (current.type === NodeTypes.FOR) {
307
+ const forNode = current;
308
+ const idxNode = forNode.objectIndexAlias;
309
+ if (idxNode && idxNode.type === NodeTypes.SIMPLE_EXPRESSION) {
310
+ const idxName = idxNode.content;
311
+ if (idxName) {
312
+ chain.push(idxName);
313
+ } else {
314
+ return null;
315
+ }
316
+ } else {
317
+ return null;
318
+ }
319
+ }
320
+ current = parentMap.get(current);
321
+ }
322
+ return chain.length > 0 ? chain.reverse() : null;
323
+ }
324
+ function isInsideVFor(parentMap, el) {
325
+ let current = parentMap.get(el);
326
+ while (current) {
327
+ if (current.type === NodeTypes.FOR) return true;
328
+ current = parentMap.get(current);
329
+ }
330
+ return false;
331
+ }
195
332
  function createMultiRootFixTransform(opts = {}) {
196
333
  const { antPrefix = "a-" } = opts;
197
334
  return (node, _context) => {
@@ -218,40 +355,178 @@ function createTestIdInjectTransform(opts = {}) {
218
355
  const {
219
356
  attributeName = "data-testid",
220
357
  antPrefix = "a-",
221
- customPrefixes = []
358
+ customPrefixes = [],
359
+ testIdPrefix = "",
360
+ injectForLoops = true,
361
+ injectEventElements = true,
362
+ debug = false
222
363
  } = opts;
223
- const templateCounters = /* @__PURE__ */ new WeakMap();
364
+ function applyPrefix(id) {
365
+ return testIdPrefix ? testIdPrefix + id : id;
366
+ }
367
+ const parentMap = /* @__PURE__ */ new WeakMap();
368
+ const branchStateMap = /* @__PURE__ */ new WeakMap();
369
+ const condStateMap = /* @__PURE__ */ new WeakMap();
224
370
  return (node, context) => {
225
- if (node.type === NodeTypes.ROOT) {
226
- templateCounters.set(node, /* @__PURE__ */ new Map());
371
+ const children = node.children;
372
+ if (children) {
373
+ for (const child of children) {
374
+ if (typeof child === "object" && child !== null) {
375
+ parentMap.set(child, node);
376
+ }
377
+ }
378
+ }
379
+ if (node.type === NodeTypes.IF) {
380
+ const ifNode = node;
381
+ for (const branch of ifNode.branches) {
382
+ parentMap.set(branch, node);
383
+ if (branch.children) {
384
+ for (const child of branch.children) {
385
+ if (typeof child === "object" && child !== null) {
386
+ parentMap.set(child, branch);
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+ if (node.type === NodeTypes.IF) {
393
+ const ifNode = node;
394
+ let hasTarget = false;
395
+ let firstTag = "";
396
+ for (const branch of ifNode.branches) {
397
+ for (const child of branch.children) {
398
+ if (child.type === NodeTypes.ELEMENT) {
399
+ const el2 = child;
400
+ if (isTargetComponent(el2, antPrefix, customPrefixes) && !hasExistingTestId(el2, attributeName)) {
401
+ hasTarget = true;
402
+ firstTag = el2.tag;
403
+ break;
404
+ }
405
+ }
406
+ }
407
+ if (hasTarget) break;
408
+ }
409
+ if (hasTarget) {
410
+ const testId = generateUniqueId(firstTag);
411
+ const state = {
412
+ counter: {},
413
+ usedIds: {},
414
+ testId
415
+ };
416
+ for (const branch of ifNode.branches) {
417
+ branchStateMap.set(branch, state);
418
+ }
419
+ if (debug) {
420
+ console.log("[testid] IF-BLOCK", firstTag, "\u2192", testId, "(branches:", ifNode.branches.length, ")");
421
+ }
422
+ }
227
423
  return;
228
424
  }
229
425
  if (node.type !== NodeTypes.ELEMENT) return;
230
426
  const el = node;
231
427
  if (!isTargetComponent(el, antPrefix, customPrefixes)) return;
232
428
  if (hasExistingTestId(el, attributeName)) return;
233
- const counters = templateCounters.get(context.root);
234
- const tagName = el.tag;
235
- const index = counters.get(tagName) ?? 0;
236
- counters.set(tagName, index + 1);
237
- const testId = `${tagName}-${index}`;
238
- el.props.push(createStaticAttr(attributeName, testId, el.loc));
429
+ if (hasVShow(el)) {
430
+ const testId = generateUniqueId(el.tag);
431
+ injectTestId(el, attributeName, testId, applyPrefix, debug);
432
+ condStateMap.set(el, {
433
+ counter: {},
434
+ usedIds: {},
435
+ testId
436
+ });
437
+ if (debug) {
438
+ console.log("[testid] V-SHOW", el.tag, "\u2192", testId);
439
+ }
440
+ return;
441
+ }
442
+ if (injectForLoops) {
443
+ const indexChain = getVForIndexChain(parentMap, el);
444
+ if (indexChain && indexChain.length > 0) {
445
+ if (injectEventElements && hasEventListeners(el)) {
446
+ const eventNames = getEventNames(el);
447
+ injectDynamicForTestId(el, attributeName, el.tag, indexChain, testIdPrefix, "-event-" + eventNames, debug);
448
+ } else {
449
+ injectDynamicForTestId(el, attributeName, el.tag, indexChain, testIdPrefix, "", debug);
450
+ }
451
+ return;
452
+ }
453
+ }
454
+ const insideFor = injectForLoops && isInsideVFor(parentMap, el);
455
+ const condState = findConditionalAncestor(parentMap, branchStateMap, condStateMap, el);
456
+ if (injectEventElements && hasEventListeners(el)) {
457
+ const eventNames = getEventNames(el);
458
+ const scope = insideFor ? "-in_for" : "";
459
+ if (condState) {
460
+ const condEventId = generateSubUniqueId(
461
+ condState.testId + "__" + el.tag + "-event-" + eventNames + scope,
462
+ condState
463
+ );
464
+ injectTestId(el, attributeName, condEventId, applyPrefix, debug);
465
+ if (debug) console.log("[testid] SUB ", el.tag, "\u2192", condEventId, "(parent:", condState.testId, ")");
466
+ } else {
467
+ const globalEventId = generateUniqueId(el.tag + "-event-" + eventNames + scope);
468
+ injectTestId(el, attributeName, globalEventId, applyPrefix, debug);
469
+ if (debug) console.log("[testid] GLOBAL", el.tag, "\u2192", globalEventId);
470
+ }
471
+ return;
472
+ }
473
+ const forScope = insideFor ? "-in_for" : "";
474
+ if (condState) {
475
+ const condNormalId = generateSubUniqueId(
476
+ condState.testId + "__" + el.tag + forScope,
477
+ condState
478
+ );
479
+ injectTestId(el, attributeName, condNormalId, applyPrefix, debug);
480
+ if (debug) console.log("[testid] SUB ", el.tag, "\u2192", condNormalId, "(parent:", condState.testId, ")");
481
+ } else {
482
+ const globalNormalId = generateUniqueId(el.tag + forScope);
483
+ injectTestId(el, attributeName, globalNormalId, applyPrefix, debug);
484
+ if (debug) console.log("[testid] GLOBAL", el.tag, "\u2192", globalNormalId);
485
+ }
239
486
  };
240
487
  }
241
488
  function createTestIdTransforms(opts = {}) {
242
489
  return [
243
- // multiRootFix 必须先注册,因为它的 binding 应出现在
244
- // testId inject 之前(编译顺序无关,但逻辑上先修复再注入更清晰)
245
490
  createMultiRootFixTransform(opts),
246
491
  createTestIdInjectTransform(opts)
247
492
  ];
248
493
  }
249
494
 
495
+ // src/plugin.ts
496
+ import { getCurrentInstance } from "vue";
497
+ function createTestIdBridge(opts = {}) {
498
+ const attrName = opts.attributeName ?? "data-testid";
499
+ return {
500
+ install(app) {
501
+ app.mixin({
502
+ mounted() {
503
+ const instance = getCurrentInstance();
504
+ if (!instance) return;
505
+ const testId = instance.vnode.props?.[attrName];
506
+ if (!testId) return;
507
+ const el = instance.vnode.el;
508
+ if (el instanceof HTMLElement && !el.hasAttribute(attrName)) {
509
+ el.setAttribute(attrName, testId);
510
+ if (typeof window !== "undefined" && window.__TESTID_DEBUG) {
511
+ console.debug("[testid-bridge] compile testid \u2192 DOM:", testId, el.tagName, el.className);
512
+ }
513
+ }
514
+ }
515
+ });
516
+ }
517
+ };
518
+ }
519
+
250
520
  // src/index.ts
251
521
  var testIdTransforms = createTestIdTransforms;
252
- function vueTestIdPlugin(_opts) {
522
+ function vueTestIdPlugin(opts) {
253
523
  return {
254
- name: "vite-plugin-vue-testid"
524
+ name: "vite-plugin-vue-testid",
525
+ config() {
526
+ if (opts?.debug) {
527
+ console.log("[vite-plugin-vue-testid] plugin initialized with opts:", opts);
528
+ }
529
+ }
255
530
  };
256
531
  }
257
532
  function createVueTestIdTransform() {
@@ -265,6 +540,7 @@ function createVueTestIdTransform() {
265
540
  }
266
541
  export {
267
542
  createMultiRootFixTransform,
543
+ createTestIdBridge,
268
544
  createTestIdInjectTransform,
269
545
  createTestIdTransforms,
270
546
  createVueTestIdTransform,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Vue 插件:读取编译器注入的 data-testid 属性,手动写入组件的根 DOM 元素。
3
+ *
4
+ * 为什么需要:
5
+ * 编译器在模板中为每个 (antd + 自定义) 组件生成稳定的 {tagName}-{index} testid,
6
+ * 但这些 testid 通过 $attrs 传递。部分 ant-design-vue 组件(Modal、Menu 等)
7
+ * 使用了 inheritAttrs: false,或将其 DOM 根通过 teleport 渲染到 body 下,
8
+ * 导致 testid 无法自动到达目标 DOM。
9
+ *
10
+ * 本插件在组件 mounted 时读取 $attrs['data-testid'],手动写入 $el,
11
+ * 绕过 inheritAttrs 限制,确保编译期生成的稳定 testid 最终出现在 DOM 上。
12
+ *
13
+ * 使用方式:
14
+ * ```ts
15
+ * // main.ts
16
+ * import { createTestIdBridge } from 'vite-plugin-vue-testid/plugin'
17
+ *
18
+ * createApp(App).mount('#app')
19
+ * app.use(createTestIdBridge())
20
+ * app.use(createTestIdBridge({ attributeName: 'data-testid' }))
21
+ * ```
22
+ */
23
+ import { type App } from 'vue';
24
+ export interface TestIdBridgeOptions {
25
+ /**
26
+ * testId 属性名
27
+ * @default 'data-testid'
28
+ */
29
+ attributeName?: string;
30
+ }
31
+ export declare function createTestIdBridge(opts?: TestIdBridgeOptions): {
32
+ install(app: App): void;
33
+ };
34
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAsB,KAAK,GAAG,EAAE,MAAM,KAAK,CAAA;AAElD,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,GAAE,mBAAwB;iBAIhD,GAAG;EAyBnB"}
package/dist/plugin.js ADDED
@@ -0,0 +1,27 @@
1
+ // src/plugin.ts
2
+ import { getCurrentInstance } from "vue";
3
+ function createTestIdBridge(opts = {}) {
4
+ const attrName = opts.attributeName ?? "data-testid";
5
+ return {
6
+ install(app) {
7
+ app.mixin({
8
+ mounted() {
9
+ const instance = getCurrentInstance();
10
+ if (!instance) return;
11
+ const testId = instance.vnode.props?.[attrName];
12
+ if (!testId) return;
13
+ const el = instance.vnode.el;
14
+ if (el instanceof HTMLElement && !el.hasAttribute(attrName)) {
15
+ el.setAttribute(attrName, testId);
16
+ if (typeof window !== "undefined" && window.__TESTID_DEBUG) {
17
+ console.debug("[testid-bridge] compile testid \u2192 DOM:", testId, el.tagName, el.className);
18
+ }
19
+ }
20
+ }
21
+ });
22
+ }
23
+ };
24
+ }
25
+ export {
26
+ createTestIdBridge
27
+ };
package/dist/runtime.d.ts CHANGED
@@ -61,11 +61,14 @@ export type PanelInjectStrategy = (ctx: PanelContext) => void;
61
61
  * 启动全运行时 testid 注入。
62
62
  *
63
63
  * 覆盖范围:
64
- * - 所有 ant-design-vue 组件根元素(input / select / button / picker / ...)
65
- * - teleport 弹出层面板(日期选择器 / 级联选择器 / 下拉选择器)
64
+ * - 所有 ant-design-vue 组件根元素(input / select / button / picker / auto-complete / mentions / transfer ...)
65
+ * - teleport 弹出层面板(日期选择器 / 级联选择器 / 下拉选择器 / 提及)
66
66
  * - Menu 子元素(inheritAttrs:false 兜底)
67
67
  * - InputNumber 内部输入框及增减按钮
68
68
  * - Slider handle、Rate 星星、Upload 内部 file input
69
+ * - AutoComplete 搜索输入框及清除按钮
70
+ * - Mentions textarea 及下拉面板选项
71
+ * - Transfer 左右面板 / 列表项 / 复选框 / 移除按钮 / 操作区
69
72
  *
70
73
  * @returns 清理函数,调用后停止监听
71
74
  *
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEnC;;;;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;AAosB7D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,IAAI,GAAE,cAAmB,GAAG,MAAM,IAAI,CAgFrE;AAED;;GAEG;AACH,eAAO,MAAM,uBAAuB,wBAAkB,CAAA;AAMtD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,GAAE,cAAmB,GAAG,IAAI,CAmCtE"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEnC;;;;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;AAs+B7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,eAAe,CAAC,IAAI,GAAE,cAAmB,GAAG,MAAM,IAAI,CA6FrE;AAED;;GAEG;AACH,eAAO,MAAM,uBAAuB,wBAAkB,CAAA;AAMtD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,GAAE,cAAmB,GAAG,IAAI,CA+CtE"}
package/dist/runtime.js CHANGED
@@ -8,10 +8,14 @@ function buildComponentMatchers(pc) {
8
8
  { selector: sel(pc, "input-affix-wrapper"), prefix: "a-input" },
9
9
  { selector: sel(pc, "input-textarea"), prefix: "a-textarea" },
10
10
  { selector: sel(pc, "input-number"), prefix: "a-input-number" },
11
+ // ── AutoComplete(必须在 select 之前,因为同时也有 .ant-select 类)──
12
+ { selector: sel(pc, "select-auto-complete"), prefix: "a-auto-complete", withId: true },
11
13
  // ── 选择器 ──
12
14
  { selector: sel(pc, "select"), prefix: "a-select", withId: true },
13
15
  { selector: sel(pc, "cascader"), prefix: "a-cascader", withId: true },
14
16
  { selector: sel(pc, "tree-select"), prefix: "a-tree-select" },
17
+ // ── Mentions ──
18
+ { selector: sel(pc, "mentions"), prefix: "a-mentions", withId: true },
15
19
  // ── 日期/时间 ──
16
20
  { selector: sel(pc, "picker"), prefix: "a-date-picker", withId: true },
17
21
  // ── 表单组件 ──
@@ -32,6 +36,9 @@ function buildComponentMatchers(pc) {
32
36
  { selector: sel(pc, "menu"), prefix: "a-menu" },
33
37
  // ── 标签页 ──
34
38
  { selector: sel(pc, "tabs"), prefix: "a-tabs" },
39
+ { selector: sel(pc, "pagination"), prefix: "a-pagination" },
40
+ // ── Transfer ──
41
+ { selector: sel(pc, "transfer"), prefix: "a-transfer" },
35
42
  // ── 纯 input / textarea(必须排在最后)──
36
43
  { selector: `input.${cls(pc, "input")}`, prefix: "a-input" },
37
44
  { selector: `textarea.${cls(pc, "input")}`, prefix: "a-textarea" }
@@ -64,6 +71,9 @@ function injectComponentRoots(root, matchers, attrName, counter, skipSelectors)
64
71
  if (isInnerInput(el, skipSelectors)) continue;
65
72
  const testId = `${prefix}-${counter.value++}`;
66
73
  el.setAttribute(attrName, testId);
74
+ if (typeof window !== "undefined" && window.__TESTID_DEBUG) {
75
+ console.debug("[testid-runtime] counter inject:", testId, el);
76
+ }
67
77
  if (withId && !el.hasAttribute("id")) {
68
78
  el.setAttribute("id", testId);
69
79
  }
@@ -272,11 +282,42 @@ function makeInjectSelectPanel(pc) {
272
282
  }
273
283
  };
274
284
  }
285
+ function makeInjectMentionsPanel(pc) {
286
+ return (ctx) => {
287
+ const { container, triggerTestId, attrName } = ctx;
288
+ const prefix = `${triggerTestId}-dropdown`;
289
+ container.setAttribute(attrName, prefix);
290
+ const injectAllItems = () => {
291
+ const items = container.querySelectorAll(sel(pc, "mentions-dropdown-menu-item"));
292
+ items.forEach((item) => {
293
+ const el = item;
294
+ if (hasAttrCompat(el, attrName)) return;
295
+ const text = el.textContent?.trim() || "";
296
+ if (text) {
297
+ el.setAttribute(attrName, `${prefix}-option-${text}`);
298
+ }
299
+ });
300
+ };
301
+ injectAllItems();
302
+ if (!_observedContainers.has(container)) {
303
+ _observedContainers.add(container);
304
+ let timer = null;
305
+ const observer = new MutationObserver(() => {
306
+ if (timer !== null) clearTimeout(timer);
307
+ timer = setTimeout(() => {
308
+ injectAllItems();
309
+ }, 50);
310
+ });
311
+ observer.observe(container, { childList: true, subtree: true });
312
+ }
313
+ };
314
+ }
275
315
  function buildPanels(pc) {
276
316
  return {
277
317
  [sel(pc, "picker-dropdown")]: makeInjectPickerPanel(pc),
278
318
  [sel(pc, "cascader-dropdown")]: makeInjectCascaderPanel(pc),
279
- [sel(pc, "select-dropdown")]: makeInjectSelectPanel(pc)
319
+ [sel(pc, "select-dropdown")]: makeInjectSelectPanel(pc),
320
+ [sel(pc, "mentions-dropdown")]: makeInjectMentionsPanel(pc)
280
321
  };
281
322
  }
282
323
  function injectMenuItems(attrName, root = document, pc) {
@@ -348,6 +389,14 @@ function findActiveTrigger(attrName, pc) {
348
389
  const found = resolveTriggerInContainer(cascader, attrName);
349
390
  if (found) return found;
350
391
  }
392
+ const mentionsDropdown = document.querySelector(sel(pc, "mentions-dropdown"));
393
+ if (mentionsDropdown) {
394
+ const mentionsRoot = document.querySelector(sel(pc, "mentions"));
395
+ if (mentionsRoot) {
396
+ const found = resolveTriggerInContainer(mentionsRoot, attrName);
397
+ if (found) return found;
398
+ }
399
+ }
351
400
  const expanded = document.querySelector(
352
401
  `[id][aria-expanded="true"], [data-testid][aria-expanded="true"], [data-testId][aria-expanded="true"]`
353
402
  );
@@ -480,6 +529,150 @@ function injectTabsSubElements(root, attrName, pc) {
480
529
  }
481
530
  }
482
531
  }
532
+ function injectPaginationSubElements(root, attrName, pc) {
533
+ const paginations = root.matches(`${sel(pc, "pagination")}[${attrName}]`) ? [root] : Array.from(root.querySelectorAll(`${sel(pc, "pagination")}[${attrName}]`));
534
+ for (const pagination of paginations) {
535
+ if (!(pagination instanceof HTMLElement)) continue;
536
+ const testId = getAttrCompat(pagination, attrName) || "";
537
+ if (!testId) continue;
538
+ const prevEl = pagination.querySelector(sel(pc, "pagination-prev"));
539
+ if (prevEl && !hasAttrCompat(prevEl, attrName)) {
540
+ prevEl.setAttribute(attrName, `${testId}-prev`);
541
+ }
542
+ const nextEl = pagination.querySelector(sel(pc, "pagination-next"));
543
+ if (nextEl && !hasAttrCompat(nextEl, attrName)) {
544
+ nextEl.setAttribute(attrName, `${testId}-next`);
545
+ }
546
+ pagination.querySelectorAll(sel(pc, "pagination-item")).forEach((item) => {
547
+ const el = item;
548
+ if (hasAttrCompat(el, attrName)) return;
549
+ const pageNum = el.textContent?.trim() || "";
550
+ if (pageNum) {
551
+ el.setAttribute(attrName, `${testId}-item-${pageNum}`);
552
+ }
553
+ });
554
+ const jumpPrev = pagination.querySelector(sel(pc, "pagination-jump-prev"));
555
+ if (jumpPrev && !hasAttrCompat(jumpPrev, attrName)) {
556
+ jumpPrev.setAttribute(attrName, `${testId}-jump-prev`);
557
+ }
558
+ const jumpNext = pagination.querySelector(sel(pc, "pagination-jump-next"));
559
+ if (jumpNext && !hasAttrCompat(jumpNext, attrName)) {
560
+ jumpNext.setAttribute(attrName, `${testId}-jump-next`);
561
+ }
562
+ const sizeChanger = pagination.querySelector(sel(pc, "pagination-options-size-changer"));
563
+ if (sizeChanger && !hasAttrCompat(sizeChanger, attrName)) {
564
+ sizeChanger.setAttribute(attrName, `${testId}-size-changer`);
565
+ }
566
+ const quickJumper = pagination.querySelector(sel(pc, "pagination-options-quick-jumper"));
567
+ if (quickJumper) {
568
+ const tryQuickJumperInput = () => {
569
+ const input = quickJumper.querySelector("input");
570
+ if (input && !hasAttrCompat(input, attrName)) {
571
+ input.setAttribute(attrName, `${testId}-quick-jumper`);
572
+ return true;
573
+ }
574
+ return false;
575
+ };
576
+ if (!tryQuickJumperInput()) {
577
+ const subObs = new MutationObserver(() => {
578
+ if (tryQuickJumperInput()) subObs.disconnect();
579
+ });
580
+ subObs.observe(quickJumper, { childList: true, subtree: true });
581
+ }
582
+ }
583
+ const totalText = pagination.querySelector(sel(pc, "pagination-total-text"));
584
+ if (totalText && !hasAttrCompat(totalText, attrName)) {
585
+ totalText.setAttribute(attrName, `${testId}-total`);
586
+ }
587
+ }
588
+ }
589
+ function injectAutoCompleteSubElements(root, attrName, pc) {
590
+ const wrappers = root.matches(`${sel(pc, "select-auto-complete")}[${attrName}]`) ? [root] : Array.from(root.querySelectorAll(`${sel(pc, "select-auto-complete")}[${attrName}]`));
591
+ for (const wrapper of wrappers) {
592
+ if (!(wrapper instanceof HTMLElement)) continue;
593
+ const testId = getAttrCompat(wrapper, attrName) || "";
594
+ if (!testId) continue;
595
+ const searchInput = wrapper.querySelector(
596
+ `${sel(pc, "select-selection-search")} input`
597
+ );
598
+ if (searchInput && !hasAttrCompat(searchInput, attrName)) {
599
+ searchInput.setAttribute(attrName, `${testId}-input`);
600
+ }
601
+ const clearBtn = wrapper.querySelector(sel(pc, "select-clear"));
602
+ if (clearBtn && !hasAttrCompat(clearBtn, attrName)) {
603
+ clearBtn.setAttribute(attrName, `${testId}-clear`);
604
+ }
605
+ }
606
+ }
607
+ function injectMentionsSubElements(root, attrName, pc) {
608
+ const mentions = root.matches(`${sel(pc, "mentions")}[${attrName}]`) ? [root] : Array.from(root.querySelectorAll(`${sel(pc, "mentions")}[${attrName}]`));
609
+ for (const wrapper of mentions) {
610
+ if (!(wrapper instanceof HTMLElement)) continue;
611
+ const testId = getAttrCompat(wrapper, attrName) || "";
612
+ if (!testId) continue;
613
+ const textarea = wrapper.querySelector(":scope > textarea");
614
+ if (textarea && !hasAttrCompat(textarea, attrName)) {
615
+ textarea.setAttribute(attrName, `${testId}-input`);
616
+ }
617
+ }
618
+ }
619
+ function injectTransferSubElements(root, attrName, pc) {
620
+ const transfers = root.matches(`${sel(pc, "transfer")}[${attrName}]`) ? [root] : Array.from(root.querySelectorAll(`${sel(pc, "transfer")}[${attrName}]`));
621
+ for (const transfer of transfers) {
622
+ if (!(transfer instanceof HTMLElement)) continue;
623
+ const testId = getAttrCompat(transfer, attrName) || "";
624
+ if (!testId) continue;
625
+ const lists = transfer.querySelectorAll(`:scope > ${sel(pc, "transfer-list")}`);
626
+ const directions = ["left", "right"];
627
+ for (let i = 0; i < Math.min(lists.length, 2); i++) {
628
+ const list = lists[i];
629
+ const dir = directions[i];
630
+ const listTestId = `${testId}-list-${dir}`;
631
+ if (!hasAttrCompat(list, attrName)) {
632
+ list.setAttribute(attrName, listTestId);
633
+ }
634
+ const headerCheckbox = list.querySelector(
635
+ `${sel(pc, "transfer-list-header")} ${sel(pc, "transfer-list-checkbox")}`
636
+ );
637
+ if (headerCheckbox && !hasAttrCompat(headerCheckbox, attrName)) {
638
+ headerCheckbox.setAttribute(attrName, `${listTestId}-check-all`);
639
+ }
640
+ const searchInput = list.querySelector(
641
+ `${sel(pc, "transfer-list-body-search-wrapper")} input`
642
+ );
643
+ if (searchInput && !hasAttrCompat(searchInput, attrName)) {
644
+ searchInput.setAttribute(attrName, `${listTestId}-search`);
645
+ }
646
+ const items = list.querySelectorAll(sel(pc, "transfer-list-content-item"));
647
+ items.forEach((item) => {
648
+ const el = item;
649
+ if (hasAttrCompat(el, attrName)) return;
650
+ const text = (el.querySelector(sel(pc, "transfer-list-content-item-text"))?.textContent || el.textContent || "").trim().replace(/\s+/g, "-");
651
+ if (text) {
652
+ el.setAttribute(attrName, `${listTestId}-item-${text}`);
653
+ }
654
+ const checkbox = el.querySelector(
655
+ sel(pc, "transfer-list-checkbox")
656
+ );
657
+ if (checkbox && !hasAttrCompat(checkbox, attrName) && text) {
658
+ checkbox.setAttribute(attrName, `${listTestId}-item-check-${text}`);
659
+ }
660
+ const removeBtn = el.querySelector(
661
+ sel(pc, "transfer-list-content-item-remove")
662
+ );
663
+ if (removeBtn && !hasAttrCompat(removeBtn, attrName) && text) {
664
+ removeBtn.setAttribute(attrName, `${listTestId}-item-remove-${text}`);
665
+ }
666
+ });
667
+ }
668
+ const operation = transfer.querySelector(
669
+ `:scope > ${sel(pc, "transfer-operation")}`
670
+ );
671
+ if (operation && !hasAttrCompat(operation, attrName)) {
672
+ operation.setAttribute(attrName, `${testId}-operation`);
673
+ }
674
+ }
675
+ }
483
676
  function setupAntTestIds(opts = {}) {
484
677
  const attrName = opts.attributeName ?? "data-testid";
485
678
  const pc = opts.prefixCls ?? "ant";
@@ -507,6 +700,10 @@ function setupAntTestIds(opts = {}) {
507
700
  }
508
701
  injectSubElements(node, attrName, pc);
509
702
  injectTabsSubElements(node, attrName, pc);
703
+ injectPaginationSubElements(node, attrName, pc);
704
+ injectAutoCompleteSubElements(node, attrName, pc);
705
+ injectMentionsSubElements(node, attrName, pc);
706
+ injectTransferSubElements(node, attrName, pc);
510
707
  }
511
708
  let pending = false;
512
709
  let pendingNodes = [];
@@ -529,8 +726,8 @@ function setupAntTestIds(opts = {}) {
529
726
  requestAnimationFrame(flushPending);
530
727
  }
531
728
  });
532
- observer.observe(document.body, { childList: true, subtree: true });
533
729
  processNode(document.body);
730
+ observer.observe(document.body, { childList: true, subtree: true });
534
731
  return () => {
535
732
  observer.disconnect();
536
733
  pendingNodes = [];
@@ -558,7 +755,11 @@ function injectCurrentDropdowns(opts = {}) {
558
755
  }
559
756
  injectSubElements(document.body, attrName, pc);
560
757
  injectTabsSubElements(document.body, attrName, pc);
758
+ injectPaginationSubElements(document.body, attrName, pc);
561
759
  injectMenuItems(attrName, document, pc);
760
+ injectAutoCompleteSubElements(document.body, attrName, pc);
761
+ injectMentionsSubElements(document.body, attrName, pc);
762
+ injectTransferSubElements(document.body, attrName, pc);
562
763
  }
563
764
  function mergeMatchers(defaults, customs) {
564
765
  if (!customs) return defaults;
@@ -1,14 +1,18 @@
1
1
  /**
2
- * 编译期模板 transform
2
+ * 编译期模板 transform — 三层计数器/注入架构。
3
+ *
4
+ * 仿照 vant-testid-webpack-plugin 的设计,为 Vue 3 编译器提供:
5
+ *
6
+ * 1. v-for + index 动态注入(最高优先级)— 利用 index 变量生成每轮迭代唯一 testid
7
+ * 2. 条件块子计数器 — v-if / v-else-if / v-else / v-show 拥有独立子计数器,
8
+ * 子元素以 `{parentTestId}__{tag}-{n}` 格式注入,不受外部元素影响
9
+ * 3. 全局计数器 — 非条件块元素使用跨模板全局计数器,保证 testid 唯一
3
10
  *
4
11
  * 两个独立的 NodeTransform:
5
- * 1. multiRootFixTransform — 多根子组件上自动补 v-bind="$attrs"
6
- * 确保 data-testid 能穿透 Fragment 落到 antd 组件 DOM 上。
7
- * 2. testIdInjectTransform — 在父模板中为每个组件使用点注入唯一的
8
- * data-testid="{tagName}-{index}",保证同一页面中同类型组件的
9
- * testid 全局唯一且稳定。
12
+ * - multiRootFixTransform — 多根组件自动补 v-bind="$attrs"
13
+ * - testIdInjectTransform — testid 注入
10
14
  *
11
- * 两个 transform 均通过 compileTime: true 下的 createTestIdTransforms() 一键获得。
15
+ * 通过 createTestIdTransforms() 一键获得。
12
16
  */
13
17
  import type { NodeTransform } from '@vue/compiler-core';
14
18
  export interface TransformOptions {
@@ -18,29 +22,57 @@ export interface TransformOptions {
18
22
  antPrefix?: string;
19
23
  /** 额外需要注入 testid 的自定义组件前缀 */
20
24
  customPrefixes?: string[];
25
+ /**
26
+ * 所有编译期注入的 testid 前添加的公共前缀。
27
+ * 例如设置为 `"static-"`,则 `a-button-0` 变为 `static-a-button-0`。
28
+ * @default ''
29
+ */
30
+ testIdPrefix?: string;
31
+ /**
32
+ * 是否为 v-for 元素使用特殊格式 `{tag}-in_for-{n}`
33
+ * @default true
34
+ */
35
+ injectForLoops?: boolean;
36
+ /**
37
+ * 是否为绑定了事件监听(@click 等)的元素使用特殊格式
38
+ * `{tag}-event-{eventNames}-{n}`
39
+ * @default true
40
+ */
41
+ injectEventElements?: boolean;
42
+ /**
43
+ * 启用调试日志(编译时在终端输出每个注入的 testid)
44
+ * @default false
45
+ */
46
+ debug?: boolean;
21
47
  }
22
48
  /**
23
49
  * 转换多根子组件的模板,自动给第一个 antd 组件根元素加 v-bind="$attrs"。
24
- *
25
- * 为什么需要:多根组件(Fragment)的 inheritAttrs 不生效,父模板编译期
26
- * 注入的 data-testid 属性会被丢弃。本 transform 确保至少一个 antd 组件根
27
- * 能收到透传属性。
28
- *
29
- * 安全策略:
30
- * - 先检查用户是否已手动写了任一 v-bind="$attrs",有则不动
31
- * - 只在模板是多根且至少有一个 antd 组件时才注入
32
50
  */
33
51
  export declare function createMultiRootFixTransform(opts?: TransformOptions): NodeTransform;
34
52
  /**
35
53
  * 在模板编译期为所有(antd + 自定义)组件注入唯一的 data-testid。
36
54
  *
37
- * 格式:`{tagName}-{perTemplateIndex}`
38
- * 例:`myinput-0`, `myinput-1`, `a-select-0`, `a-button-2`
55
+ * 三层计数器/注入架构:
56
+ *
57
+ * **v-for 动态注入**(最高优先级)— v-for 有显式 index 变量时,生成运行时
58
+ * 动态表达式(如 `'a-button_in_for-'+i+'_'+j`),每轮迭代自动产生唯一 testid。
59
+ *
60
+ * **条件块子计数器** — v-if / v-else-if / v-else / v-show 元素创建独立子计数器,
61
+ * 其子元素以 `{parentTestId}__{tag}-{n}` 格式注入。
62
+ *
63
+ * **全局计数器** — 非条件块元素使用跨模板全局计数器,格式为 `{tag}-{n}`。
39
64
  *
40
- * key 设计:
41
- * - tagName 保持原始大小写,确保一眼认出组件来源
42
- * - index 是严格按模板 AST 中的出现顺序递增的,不同模板各自重置
43
- * - 如果用户已经手动写了 data-testid,则跳过(不覆盖)
65
+ * 格式一览:
66
+ *
67
+ * | 元素类型 | 格式 |
68
+ * |-----------------------------|----------------------------------------------------|
69
+ * | v-if/v-show 条件块的子元素 | `{parentTestId}__{tag}-event-{names}-{n}` 或 `{parentTestId}__{tag}-{n}` |
70
+ * | v-for + index(动态) | `{tag}_in_for-{i0}[_{i1}]-event-{names}` 或 `{tag}_in_for-{i0}[_{i1}]` |
71
+ * | 顶层事件监听元素 | `{tag}-event-{names}-{n}` |
72
+ * | v-for 内部元素(无 index) | `{tag}-in_for-{n}` |
73
+ * | 普通组件 | `{tag}-{n}` |
74
+ *
75
+ * 所有 testid 前后缀均可通过 `testIdPrefix` 选项配置。
44
76
  */
45
77
  export declare function createTestIdInjectTransform(opts?: TransformOptions): NodeTransform;
46
78
  /**
@@ -64,6 +96,17 @@ export declare function createTestIdInjectTransform(opts?: TransformOptions): No
64
96
  * ]
65
97
  * })
66
98
  * ```
99
+ *
100
+ * 配置选项:
101
+ * ```ts
102
+ * testIdTransforms({
103
+ * antPrefix: 'a-', // antd 组件前缀
104
+ * testIdPrefix: 'static-', // testid 公共前缀
105
+ * injectForLoops: true, // v-for 标记
106
+ * injectEventElements: true, // 事件监听元素标记
107
+ * debug: false, // 调试日志
108
+ * })
109
+ * ```
67
110
  */
68
111
  export declare function createTestIdTransforms(opts?: TransformOptions): NodeTransform[];
69
112
  //# sourceMappingURL=transform.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../src/transform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EACV,aAAa,EAMd,MAAM,oBAAoB,CAAA;AAgD3B,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,sCAAsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6BAA6B;IAC7B,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;CAC1B;AAyFD;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,GAAE,gBAAqB,GAAG,aAAa,CA8BtF;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,GAAE,gBAAqB,GAAG,aAAa,CAqCtF;AAID;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,GAAE,gBAAqB,GAAG,aAAa,EAAE,CAOnF"}
1
+ {"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../src/transform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EACV,aAAa,EAWd,MAAM,oBAAoB,CAAA;AAgD3B,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB,sCAAsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB,6BAA6B;IAC7B,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;IAEzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IAErB;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IAExB;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAE7B;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAuVD;;GAEG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,GAAE,gBAAqB,GAAG,aAAa,CA6BtF;AAID;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,GAAE,gBAAqB,GAAG,aAAa,CAmMtF;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,GAAE,gBAAqB,GAAG,aAAa,EAAE,CAKnF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-vue-testid",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Vite plugin to auto-inject data-testid into Vue component templates for E2E testing",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,6 +14,10 @@
14
14
  "./runtime": {
15
15
  "import": "./dist/runtime.js",
16
16
  "types": "./dist/runtime.d.ts"
17
+ },
18
+ "./plugin": {
19
+ "import": "./dist/plugin.js",
20
+ "types": "./dist/plugin.d.ts"
17
21
  }
18
22
  },
19
23
  "files": [
@@ -49,4 +53,4 @@
49
53
  "typescript": "~6.0.2",
50
54
  "vite": "^8.0.12"
51
55
  }
52
- }
56
+ }