@testid/antd-testid-runtime 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/dist/index.d.mts +343 -0
- package/dist/index.d.ts +343 -0
- package/dist/index.js +656 -0
- package/dist/index.mjs +613 -0
- package/package.json +27 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// src/config/testMark.ts
|
|
2
|
+
var INTERACTIVE_TAGS = /* @__PURE__ */ new Set([
|
|
3
|
+
"button",
|
|
4
|
+
"a-button",
|
|
5
|
+
"input",
|
|
6
|
+
"a-input",
|
|
7
|
+
"a-input-number",
|
|
8
|
+
"select",
|
|
9
|
+
"a-select",
|
|
10
|
+
"textarea",
|
|
11
|
+
"a-textarea",
|
|
12
|
+
"a-checkbox",
|
|
13
|
+
"a-radio",
|
|
14
|
+
"a-switch"
|
|
15
|
+
]);
|
|
16
|
+
var defaultConfig = {
|
|
17
|
+
enable: true,
|
|
18
|
+
globalPrefix: "",
|
|
19
|
+
compilePrefix: "static_",
|
|
20
|
+
runtimePagePrefix: "dynamic_",
|
|
21
|
+
popupPrefixMap: {
|
|
22
|
+
modal: "modal_",
|
|
23
|
+
drawer: "drawer_",
|
|
24
|
+
select: "select_",
|
|
25
|
+
datePicker: "datePicker_",
|
|
26
|
+
popconfirm: "popconfirm_",
|
|
27
|
+
dropdown: "dropdown_",
|
|
28
|
+
tooltip: "tooltip_"
|
|
29
|
+
},
|
|
30
|
+
ignoreTags: ["script", "style", "svg", "br", "img", "iframe"],
|
|
31
|
+
ignoreClass: ["no-test-mark", "hidden"],
|
|
32
|
+
onlyInteractive: true,
|
|
33
|
+
resetInstanceOnRouteChange: true,
|
|
34
|
+
resetPopupCounterOnRouteChange: true
|
|
35
|
+
};
|
|
36
|
+
function mergeConfig(userConfig) {
|
|
37
|
+
if (!userConfig) {
|
|
38
|
+
const cfg = { ...defaultConfig };
|
|
39
|
+
return applyGlobalPrefix(cfg);
|
|
40
|
+
}
|
|
41
|
+
const merged = {
|
|
42
|
+
...defaultConfig,
|
|
43
|
+
...userConfig,
|
|
44
|
+
// popupPrefixMap 需要深度合并: 允许用户只覆盖部分浮层前缀
|
|
45
|
+
popupPrefixMap: {
|
|
46
|
+
...defaultConfig.popupPrefixMap,
|
|
47
|
+
...userConfig.popupPrefixMap || {}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return applyGlobalPrefix(merged);
|
|
51
|
+
}
|
|
52
|
+
function applyGlobalPrefix(cfg) {
|
|
53
|
+
const g = cfg.globalPrefix;
|
|
54
|
+
if (!g) return cfg;
|
|
55
|
+
const prefix = `${g}_`;
|
|
56
|
+
const prepend = (val) => val.startsWith(prefix) ? val : `${prefix}${val}`;
|
|
57
|
+
return {
|
|
58
|
+
...cfg,
|
|
59
|
+
compilePrefix: prepend(cfg.compilePrefix),
|
|
60
|
+
runtimePagePrefix: prepend(cfg.runtimePagePrefix),
|
|
61
|
+
popupPrefixMap: Object.fromEntries(
|
|
62
|
+
Object.entries(cfg.popupPrefixMap).map(([k, v]) => [k, prepend(v)])
|
|
63
|
+
)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
var globalConfig = { ...defaultConfig };
|
|
67
|
+
function initConfig(custom) {
|
|
68
|
+
globalConfig = mergeConfig(custom);
|
|
69
|
+
}
|
|
70
|
+
function getConfig() {
|
|
71
|
+
return globalConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/utils/testIdAnchorCounter.ts
|
|
75
|
+
var anchorCounterMap = /* @__PURE__ */ new Map();
|
|
76
|
+
function buildAnchorKey(anchorTestId, componentName, tagName) {
|
|
77
|
+
return `${anchorTestId}__${componentName}__${tagName}`;
|
|
78
|
+
}
|
|
79
|
+
function getNextAnchorLocalIndex(anchorTestId, componentName, tagName) {
|
|
80
|
+
const key = buildAnchorKey(anchorTestId, componentName, tagName);
|
|
81
|
+
const current = anchorCounterMap.get(key) ?? 0;
|
|
82
|
+
anchorCounterMap.set(key, current + 1);
|
|
83
|
+
return current;
|
|
84
|
+
}
|
|
85
|
+
function resetAllAnchorCounters() {
|
|
86
|
+
anchorCounterMap.clear();
|
|
87
|
+
}
|
|
88
|
+
function getAnchorCounterMap() {
|
|
89
|
+
return anchorCounterMap;
|
|
90
|
+
}
|
|
91
|
+
function parseBaseKey(baseKey) {
|
|
92
|
+
const match = baseKey.match(
|
|
93
|
+
/^common_comp_(.+?)_tag_(.+?)_(\d+)$/
|
|
94
|
+
);
|
|
95
|
+
if (!match) return null;
|
|
96
|
+
return {
|
|
97
|
+
componentName: match[1],
|
|
98
|
+
tagName: match[2],
|
|
99
|
+
templateIndex: match[3]
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function buildAnchorTestId(anchorTestId, componentName, tagName, localIndex) {
|
|
103
|
+
return `${anchorTestId}__${componentName}_${tagName}_${localIndex}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/utils/testIdPopupCounter.ts
|
|
107
|
+
var popupCounters = {
|
|
108
|
+
modal: 0,
|
|
109
|
+
drawer: 0,
|
|
110
|
+
select: 0,
|
|
111
|
+
datePicker: 0,
|
|
112
|
+
popconfirm: 0,
|
|
113
|
+
dropdown: 0,
|
|
114
|
+
tooltip: 0
|
|
115
|
+
};
|
|
116
|
+
function getNextPopupId(type) {
|
|
117
|
+
const key = type in popupCounters ? type : "modal";
|
|
118
|
+
const id = popupCounters[key];
|
|
119
|
+
popupCounters[key] += 1;
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
function resetAllPopupCounters() {
|
|
123
|
+
Object.keys(popupCounters).forEach(
|
|
124
|
+
(key) => popupCounters[key] = 0
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
function resetPopupCounter(type) {
|
|
128
|
+
if (type in popupCounters) {
|
|
129
|
+
popupCounters[type] = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function getPopupCounterSnapshot() {
|
|
133
|
+
return { ...popupCounters };
|
|
134
|
+
}
|
|
135
|
+
function buildPopupTestId(type, tag, counterId) {
|
|
136
|
+
const config = getConfig();
|
|
137
|
+
const prefix = config.popupPrefixMap[type] || `${type}_`;
|
|
138
|
+
return `${prefix}${tag}_${counterId}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/utils/testIdObserver.ts
|
|
142
|
+
var POPUP_CLASS_MAP = {
|
|
143
|
+
modal: ".ant-modal",
|
|
144
|
+
drawer: ".ant-drawer",
|
|
145
|
+
select: ".ant-select-dropdown",
|
|
146
|
+
datePicker: ".ant-picker-dropdown",
|
|
147
|
+
popconfirm: ".ant-popover.ant-popconfirm",
|
|
148
|
+
dropdown: ".ant-dropdown",
|
|
149
|
+
tooltip: ".ant-tooltip"
|
|
150
|
+
};
|
|
151
|
+
var TestIdObserver = class {
|
|
152
|
+
constructor() {
|
|
153
|
+
// ==========================================================
|
|
154
|
+
// MutationObserver 回调
|
|
155
|
+
// ==========================================================
|
|
156
|
+
/**
|
|
157
|
+
* MutationObserver 回调 (箭头函数绑定 this)
|
|
158
|
+
*/
|
|
159
|
+
this.handleMutations = (mutations) => {
|
|
160
|
+
const config = getConfig();
|
|
161
|
+
if (!config.enable) return;
|
|
162
|
+
for (const mutation of mutations) {
|
|
163
|
+
mutation.addedNodes.forEach((node) => {
|
|
164
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
165
|
+
this.processNodeRecursive(node);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
this.state = {
|
|
170
|
+
observer: null,
|
|
171
|
+
isRunning: false,
|
|
172
|
+
currentRoute: "",
|
|
173
|
+
dynamicCounter: /* @__PURE__ */ new Map()
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// ==========================================================
|
|
177
|
+
// 生命周期
|
|
178
|
+
// ==========================================================
|
|
179
|
+
/**
|
|
180
|
+
* 启动 MutationObserver
|
|
181
|
+
*/
|
|
182
|
+
start() {
|
|
183
|
+
if (this.state.isRunning) return;
|
|
184
|
+
if (typeof MutationObserver === "undefined") return;
|
|
185
|
+
const config = getConfig();
|
|
186
|
+
if (!config.enable) return;
|
|
187
|
+
this.state.observer = new MutationObserver(this.handleMutations);
|
|
188
|
+
this.state.observer.observe(document.body, {
|
|
189
|
+
childList: true,
|
|
190
|
+
// 监听子节点增删
|
|
191
|
+
subtree: true
|
|
192
|
+
// 监听所有后代节点
|
|
193
|
+
});
|
|
194
|
+
this.state.isRunning = true;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 停止 Observer
|
|
198
|
+
*/
|
|
199
|
+
stop() {
|
|
200
|
+
this.state.observer?.disconnect();
|
|
201
|
+
this.state.observer = null;
|
|
202
|
+
this.state.isRunning = false;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 更新当前路由 (路由切换时调用)
|
|
206
|
+
*/
|
|
207
|
+
setRoute(routeName) {
|
|
208
|
+
this.state.currentRoute = routeName;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 全量扫描当前 DOM (用于启动时兜底, 处理 Observer 启动前已渲染的节点)
|
|
212
|
+
*/
|
|
213
|
+
fullScan() {
|
|
214
|
+
const app = document.getElementById("app");
|
|
215
|
+
if (app) {
|
|
216
|
+
this.processNodeRecursive(app);
|
|
217
|
+
}
|
|
218
|
+
Array.from(document.body.children).forEach((child) => {
|
|
219
|
+
if (child.id !== "app") {
|
|
220
|
+
this.processNodeRecursive(child);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// ==========================================================
|
|
225
|
+
// 节点处理决策树
|
|
226
|
+
// ==========================================================
|
|
227
|
+
/**
|
|
228
|
+
* 递归处理元素节点及其子节点
|
|
229
|
+
*/
|
|
230
|
+
processNodeRecursive(el) {
|
|
231
|
+
const stack = [el];
|
|
232
|
+
while (stack.length > 0) {
|
|
233
|
+
const node = stack.pop();
|
|
234
|
+
this.processSingleNode(node);
|
|
235
|
+
const children = node.children;
|
|
236
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
237
|
+
stack.push(children[i]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* 处理单个节点
|
|
243
|
+
*
|
|
244
|
+
* 决策优先级:
|
|
245
|
+
* 0. 已有 data-testid → 跳过
|
|
246
|
+
* 1. 带 data-test-base-key → 公共组件实例 (锚点定位)
|
|
247
|
+
* 2. body 直系浮层 → 匹配浮层类型 → 独立前缀
|
|
248
|
+
* 3. #app 内普通节点 → dynamic
|
|
249
|
+
*/
|
|
250
|
+
processSingleNode(node) {
|
|
251
|
+
const config = getConfig();
|
|
252
|
+
if (node.hasAttribute("data-testid")) return;
|
|
253
|
+
if (this.shouldIgnore(node, config)) return;
|
|
254
|
+
const baseKey = node.getAttribute("data-test-base-key");
|
|
255
|
+
if (baseKey) {
|
|
256
|
+
this.handleBaseKeyNode(node, baseKey, config);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const popupType = this.detectPopupType(node);
|
|
260
|
+
if (popupType) {
|
|
261
|
+
this.handlePopupNode(node, popupType);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (this.isInsideApp(node)) {
|
|
265
|
+
this.handleDynamicNode(node, config);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// ==========================================================
|
|
269
|
+
// 处理: 公共组件 base-key 节点 (锚点定位)
|
|
270
|
+
// ==========================================================
|
|
271
|
+
/**
|
|
272
|
+
* 处理公共组件 base-key 节点 — 锚点定位方案
|
|
273
|
+
*
|
|
274
|
+
* 流程:
|
|
275
|
+
* 1. 解析 baseKey → { componentName, tagName, templateIndex }
|
|
276
|
+
* 2. 查找最近锚点 → anchorTestId (祖先元素上的 data-testid)
|
|
277
|
+
* 3. 锚点下局部计数 → localIndex
|
|
278
|
+
* 4. 拼接: {anchorTestId}__{componentName}_{tagName}_{localIndex}
|
|
279
|
+
* 5. 移除 data-test-base-key
|
|
280
|
+
*/
|
|
281
|
+
handleBaseKeyNode(node, baseKey, _config) {
|
|
282
|
+
const parsed = parseBaseKey(baseKey);
|
|
283
|
+
if (!parsed) return;
|
|
284
|
+
const anchorTestId = this.findAnchor(node);
|
|
285
|
+
const localIndex = getNextAnchorLocalIndex(
|
|
286
|
+
anchorTestId,
|
|
287
|
+
parsed.componentName,
|
|
288
|
+
parsed.tagName
|
|
289
|
+
);
|
|
290
|
+
const testId = buildAnchorTestId(
|
|
291
|
+
anchorTestId,
|
|
292
|
+
parsed.componentName,
|
|
293
|
+
parsed.tagName,
|
|
294
|
+
localIndex
|
|
295
|
+
);
|
|
296
|
+
node.setAttribute("data-testid", testId);
|
|
297
|
+
node.removeAttribute("data-test-base-key");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 向上遍历 DOM 树,找到第一个带 data-testid 属性的祖先元素作为锚点
|
|
301
|
+
*
|
|
302
|
+
* 如果一直找到 #app / body 都没有,返回 "__root" 作为兜底
|
|
303
|
+
*/
|
|
304
|
+
findAnchor(node) {
|
|
305
|
+
let parent = node.parentElement;
|
|
306
|
+
while (parent) {
|
|
307
|
+
const tid = parent.getAttribute("data-testid");
|
|
308
|
+
if (tid) {
|
|
309
|
+
return tid;
|
|
310
|
+
}
|
|
311
|
+
if (parent.id === "app" || parent === document.body) {
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
parent = parent.parentElement;
|
|
315
|
+
}
|
|
316
|
+
return "__root";
|
|
317
|
+
}
|
|
318
|
+
// ==========================================================
|
|
319
|
+
// 处理: 浮层节点
|
|
320
|
+
// ==========================================================
|
|
321
|
+
/**
|
|
322
|
+
* 处理 Antd 浮层根节点
|
|
323
|
+
*
|
|
324
|
+
* @param node - 浮层根节点元素
|
|
325
|
+
* @param type - 浮层类型
|
|
326
|
+
*/
|
|
327
|
+
handlePopupNode(node, type) {
|
|
328
|
+
const counterId = getNextPopupId(type);
|
|
329
|
+
const tag = this.getSimpleTag(node);
|
|
330
|
+
const testId = buildPopupTestId(type, tag, counterId);
|
|
331
|
+
node.setAttribute("data-testid", testId);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 通过 CSS class 匹配浮层类型
|
|
335
|
+
*
|
|
336
|
+
* @param node - 待检测节点
|
|
337
|
+
* @returns 浮层类型或 null (非 Antd 浮层)
|
|
338
|
+
*/
|
|
339
|
+
detectPopupType(node) {
|
|
340
|
+
if (node.parentElement !== document.body) return null;
|
|
341
|
+
const classStr = node.className || "";
|
|
342
|
+
if (typeof classStr !== "string") return null;
|
|
343
|
+
const entries = Object.entries(POPUP_CLASS_MAP);
|
|
344
|
+
for (const [type, selector] of entries) {
|
|
345
|
+
const classes = selector.replace(/^\./, "").split(".");
|
|
346
|
+
const allMatch = classes.every(
|
|
347
|
+
(cls) => classStr.split(/\s+/).includes(cls)
|
|
348
|
+
);
|
|
349
|
+
if (allMatch) {
|
|
350
|
+
return type;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
// ==========================================================
|
|
356
|
+
// 处理: 页面动态节点
|
|
357
|
+
// ==========================================================
|
|
358
|
+
/**
|
|
359
|
+
* 处理页面内动态新增节点 (v-for / 异步 v-if 等)
|
|
360
|
+
*
|
|
361
|
+
* ID 格式: ${runtimePagePrefix}${route}_${tag}_${counter}
|
|
362
|
+
*/
|
|
363
|
+
handleDynamicNode(node, config) {
|
|
364
|
+
if (config.onlyInteractive && !this.isInteractive(node)) return;
|
|
365
|
+
const tag = this.getSimpleTag(node);
|
|
366
|
+
const route = this.state.currentRoute || "unknown";
|
|
367
|
+
const key = `${route}_${tag}`;
|
|
368
|
+
const current = this.state.dynamicCounter.get(key) ?? 0;
|
|
369
|
+
this.state.dynamicCounter.set(key, current + 1);
|
|
370
|
+
const testId = `${config.runtimePagePrefix}${route}_${tag}_${current}`;
|
|
371
|
+
node.setAttribute("data-testid", testId);
|
|
372
|
+
}
|
|
373
|
+
// ==========================================================
|
|
374
|
+
// 辅助方法
|
|
375
|
+
// ==========================================================
|
|
376
|
+
/**
|
|
377
|
+
* 检测元素是否在 #app 容器内
|
|
378
|
+
*/
|
|
379
|
+
isInsideApp(node) {
|
|
380
|
+
const app = document.getElementById("app");
|
|
381
|
+
if (!app) return false;
|
|
382
|
+
return app.contains(node);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* 获取元素的简化标签名
|
|
386
|
+
*
|
|
387
|
+
* 处理 Antd 组件前缀: a-button → button, a-input → input
|
|
388
|
+
*/
|
|
389
|
+
getSimpleTag(node) {
|
|
390
|
+
return node.tagName.toLowerCase().replace(/^a-/, "");
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* 判断是否可交互元素
|
|
394
|
+
*
|
|
395
|
+
* 可交互特征:
|
|
396
|
+
* - 交互类标签: button, input, select, textarea
|
|
397
|
+
* - onclick 属性
|
|
398
|
+
* - role="button" / role="checkbox" / role="radio"
|
|
399
|
+
* - cursor:pointer (不检测,因为可能从 CSS 继承,误判率高)
|
|
400
|
+
*/
|
|
401
|
+
isInteractive(node) {
|
|
402
|
+
const tag = node.tagName.toLowerCase();
|
|
403
|
+
const interactiveTags = [
|
|
404
|
+
"button",
|
|
405
|
+
"a-button",
|
|
406
|
+
"input",
|
|
407
|
+
"a-input",
|
|
408
|
+
"a-input-number",
|
|
409
|
+
"select",
|
|
410
|
+
"a-select",
|
|
411
|
+
"textarea",
|
|
412
|
+
"a-textarea"
|
|
413
|
+
];
|
|
414
|
+
if (interactiveTags.includes(tag)) return true;
|
|
415
|
+
if (node.hasAttribute("onclick")) return true;
|
|
416
|
+
const role = node.getAttribute("role");
|
|
417
|
+
if (role === "button" || role === "checkbox" || role === "radio" || role === "switch") {
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
if (node.hasAttribute("tabindex")) return true;
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* 检查是否应跳过 (ignoreTags / ignoreClass)
|
|
425
|
+
*/
|
|
426
|
+
shouldIgnore(node, config) {
|
|
427
|
+
const tag = node.tagName.toLowerCase();
|
|
428
|
+
if (config.ignoreTags.includes(tag)) return true;
|
|
429
|
+
const classStr = node.className;
|
|
430
|
+
if (typeof classStr === "string") {
|
|
431
|
+
for (const cls of config.ignoreClass) {
|
|
432
|
+
if (classStr.split(/\s+/).includes(cls)) return true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// src/utils/testIdChecker.ts
|
|
440
|
+
var GROUP_LABELS = {
|
|
441
|
+
custom: "[\u4E1A\u52A1\u624B\u52A8 ID]",
|
|
442
|
+
static: "[\u7F16\u8BD1\u9759\u6001 ID]",
|
|
443
|
+
dynamic: "[\u9875\u9762\u52A8\u6001 ID]",
|
|
444
|
+
modal: "[Modal \u6D6E\u5C42]",
|
|
445
|
+
drawer: "[Drawer \u6D6E\u5C42]",
|
|
446
|
+
select: "[Select \u6D6E\u5C42]",
|
|
447
|
+
datePicker: "[DatePicker \u6D6E\u5C42]",
|
|
448
|
+
popconfirm: "[Popconfirm \u6D6E\u5C42]",
|
|
449
|
+
dropdown: "[Dropdown \u6D6E\u5C42]",
|
|
450
|
+
tooltip: "[Tooltip \u6D6E\u5C42]"
|
|
451
|
+
};
|
|
452
|
+
var GROUP_SUGGESTIONS = {
|
|
453
|
+
custom: "\u4E1A\u52A1\u4EE3\u7801\u4E2D\u5B58\u5728\u624B\u5199\u56FA\u5B9A\u91CD\u590D data-testid\uFF0C\u8BF7\u68C0\u67E5\u76F8\u5173\u6A21\u677F\u4EE3\u7801",
|
|
454
|
+
static: "\u951A\u70B9\u5C40\u90E8\u8BA1\u6570\u5668\u5F02\u5E38\u6216\u9875\u9762\u7F16\u8BD1\u671F testid \u51B2\u7A81\uFF0C\u68C0\u67E5\u951A\u70B9\u67E5\u627E\u903B\u8F91",
|
|
455
|
+
dynamic: "\u9875\u9762\u52A8\u6001\u8282\u70B9\u5144\u5F1F\u7D22\u5F15\u8BA1\u7B97\u903B\u8F91\u7F3A\u9677\uFF0C\u68C0\u67E5 dynamicCounter \u91CD\u7F6E\u903B\u8F91",
|
|
456
|
+
modal: "Modal \u72EC\u7ACB\u8BA1\u6570\u5668\u81EA\u589E\u903B\u8F91\u5931\u6548\uFF0C\u68C0\u67E5 getNextPopupId \u8C03\u7528",
|
|
457
|
+
drawer: "Drawer \u72EC\u7ACB\u8BA1\u6570\u5668\u81EA\u589E\u903B\u8F91\u5931\u6548",
|
|
458
|
+
select: "Select \u72EC\u7ACB\u8BA1\u6570\u5668\u81EA\u589E\u903B\u8F91\u5931\u6548",
|
|
459
|
+
datePicker: "DatePicker \u72EC\u7ACB\u8BA1\u6570\u5668\u81EA\u589E\u903B\u8F91\u5931\u6548",
|
|
460
|
+
popconfirm: "Popconfirm \u72EC\u7ACB\u8BA1\u6570\u5668\u81EA\u589E\u903B\u8F91\u5931\u6548",
|
|
461
|
+
dropdown: "Dropdown \u72EC\u7ACB\u8BA1\u6570\u5668\u81EA\u589E\u903B\u8F91\u5931\u6548",
|
|
462
|
+
tooltip: "Tooltip \u72EC\u7ACB\u8BA1\u6570\u5668\u81EA\u589E\u903B\u8F91\u5931\u6548"
|
|
463
|
+
};
|
|
464
|
+
var TestIdChecker = class {
|
|
465
|
+
/**
|
|
466
|
+
* 执行全量重复检测
|
|
467
|
+
*
|
|
468
|
+
* 步骤:
|
|
469
|
+
* 1. 查询所有带 data-testid 属性的 DOM 节点
|
|
470
|
+
* 2. 按 testid 前缀分组 (custom / static / dynamic / modal / ...)
|
|
471
|
+
* 3. 组内统计出现次数 > 1 的 testid
|
|
472
|
+
* 4. 控制台按分组输出告警信息
|
|
473
|
+
*
|
|
474
|
+
* @returns 是否有重复 ID
|
|
475
|
+
*/
|
|
476
|
+
static check() {
|
|
477
|
+
const allElements = document.querySelectorAll("[data-testid]");
|
|
478
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
479
|
+
allElements.forEach((el) => {
|
|
480
|
+
const testId = el.getAttribute("data-testid");
|
|
481
|
+
const group = this.classifyTestId(testId);
|
|
482
|
+
if (!groupMap.has(group)) {
|
|
483
|
+
groupMap.set(group, /* @__PURE__ */ new Map());
|
|
484
|
+
}
|
|
485
|
+
const idMap = groupMap.get(group);
|
|
486
|
+
if (!idMap.has(testId)) {
|
|
487
|
+
idMap.set(testId, []);
|
|
488
|
+
}
|
|
489
|
+
idMap.get(testId).push(el);
|
|
490
|
+
});
|
|
491
|
+
let hasDuplicate = false;
|
|
492
|
+
groupMap.forEach((idMap, group) => {
|
|
493
|
+
const duplicates = [];
|
|
494
|
+
idMap.forEach((elements, testId) => {
|
|
495
|
+
if (elements.length > 1) {
|
|
496
|
+
duplicates.push({ testId, count: elements.length, elements });
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
if (duplicates.length > 0) {
|
|
500
|
+
hasDuplicate = true;
|
|
501
|
+
this.reportGroupDuplicates(group, duplicates);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
return hasDuplicate;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* 执行检测并返回统计摘要 (不输出控制台)
|
|
508
|
+
*/
|
|
509
|
+
static getStats() {
|
|
510
|
+
const allElements = document.querySelectorAll("[data-testid]");
|
|
511
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
512
|
+
allElements.forEach((el) => {
|
|
513
|
+
const testId = el.getAttribute("data-testid");
|
|
514
|
+
const group = this.classifyTestId(testId);
|
|
515
|
+
if (!groupMap.has(group)) {
|
|
516
|
+
groupMap.set(group, /* @__PURE__ */ new Map());
|
|
517
|
+
}
|
|
518
|
+
const idMap = groupMap.get(group);
|
|
519
|
+
if (!idMap.has(testId)) {
|
|
520
|
+
idMap.set(testId, []);
|
|
521
|
+
}
|
|
522
|
+
idMap.get(testId).push(el);
|
|
523
|
+
});
|
|
524
|
+
const stats = [];
|
|
525
|
+
groupMap.forEach((idMap, group) => {
|
|
526
|
+
let total = 0;
|
|
527
|
+
let unique = 0;
|
|
528
|
+
let duplicates = 0;
|
|
529
|
+
idMap.forEach((elements) => {
|
|
530
|
+
total += elements.length;
|
|
531
|
+
unique++;
|
|
532
|
+
if (elements.length > 1) {
|
|
533
|
+
duplicates++;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
stats.push({ group, total, unique, duplicates });
|
|
537
|
+
});
|
|
538
|
+
return stats.sort((a, b) => b.total - a.total);
|
|
539
|
+
}
|
|
540
|
+
// ==========================================================
|
|
541
|
+
// 私有方法
|
|
542
|
+
// ==========================================================
|
|
543
|
+
/**
|
|
544
|
+
* 按 testid 前缀分类
|
|
545
|
+
*/
|
|
546
|
+
static classifyTestId(testId) {
|
|
547
|
+
if (!testId.includes("_")) return "custom";
|
|
548
|
+
const firstUnderscore = testId.indexOf("_");
|
|
549
|
+
const prefix = testId.substring(0, firstUnderscore + 1);
|
|
550
|
+
if (prefix === "static_") return "static";
|
|
551
|
+
if (prefix === "dynamic_") return "dynamic";
|
|
552
|
+
const popupTypes = [
|
|
553
|
+
"modal",
|
|
554
|
+
"drawer",
|
|
555
|
+
"select",
|
|
556
|
+
"datePicker",
|
|
557
|
+
"popconfirm",
|
|
558
|
+
"dropdown",
|
|
559
|
+
"tooltip"
|
|
560
|
+
];
|
|
561
|
+
for (const type of popupTypes) {
|
|
562
|
+
if (prefix === `${type}_`) return type;
|
|
563
|
+
}
|
|
564
|
+
return "custom";
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* 控制台告警输出 (按分组格式化)
|
|
568
|
+
*/
|
|
569
|
+
static reportGroupDuplicates(group, duplicates) {
|
|
570
|
+
const groupLabel = GROUP_LABELS[group] || group;
|
|
571
|
+
const suggestion = GROUP_SUGGESTIONS[group] || "\u672A\u77E5\u539F\u56E0\uFF0C\u8BF7\u8054\u7CFB\u5F00\u53D1\u6392\u67E5";
|
|
572
|
+
console.group(
|
|
573
|
+
`%c[testid-checker] %c${groupLabel} %c\u68C0\u6D4B\u5230 ${duplicates.length} \u4E2A\u91CD\u590D ID`,
|
|
574
|
+
"color: #ff6b6b; font-weight: bold;",
|
|
575
|
+
"color: #ffa500;",
|
|
576
|
+
"color: #999;"
|
|
577
|
+
);
|
|
578
|
+
duplicates.forEach((d) => {
|
|
579
|
+
console.log(
|
|
580
|
+
`%c \u2717 ${d.testId} %c(\u51FA\u73B0 ${d.count} \u6B21)`,
|
|
581
|
+
"color: #ff6b6b;",
|
|
582
|
+
"color: #999;"
|
|
583
|
+
);
|
|
584
|
+
d.elements.forEach((el) => {
|
|
585
|
+
const tag = el.tagName.toLowerCase();
|
|
586
|
+
const id = el.id ? `#${el.id}` : "";
|
|
587
|
+
const cls = typeof el.className === "string" ? "." + el.className.split(/\s+/).slice(0, 3).join(".") : "";
|
|
588
|
+
console.log(` \u2192 <${tag}${id}${cls}>`, el);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
console.log(`%c \u{1F4A1} \u5EFA\u8BAE: ${suggestion}`, "color: #4ecdc4;");
|
|
592
|
+
console.groupEnd();
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
export {
|
|
596
|
+
INTERACTIVE_TAGS,
|
|
597
|
+
TestIdChecker,
|
|
598
|
+
TestIdObserver,
|
|
599
|
+
buildAnchorTestId,
|
|
600
|
+
buildPopupTestId,
|
|
601
|
+
defaultConfig,
|
|
602
|
+
getAnchorCounterMap,
|
|
603
|
+
getConfig,
|
|
604
|
+
getNextAnchorLocalIndex,
|
|
605
|
+
getNextPopupId,
|
|
606
|
+
getPopupCounterSnapshot,
|
|
607
|
+
initConfig,
|
|
608
|
+
mergeConfig,
|
|
609
|
+
parseBaseKey,
|
|
610
|
+
resetAllAnchorCounters,
|
|
611
|
+
resetAllPopupCounters,
|
|
612
|
+
resetPopupCounter
|
|
613
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@testid/antd-testid-runtime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "运行时兜底打标模块 — MutationObserver + 锚点计数器 + 浮层计数器 + ID 重复检测",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --dts --format esm,cjs --clean",
|
|
20
|
+
"dev": "tsup src/index.ts --dts --format esm,cjs --watch"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsup": "^8.0.0",
|
|
25
|
+
"typescript": "^5.3.0"
|
|
26
|
+
}
|
|
27
|
+
}
|