@kkeve/suite 0.0.22 → 0.0.23

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.
@@ -31,7 +31,8 @@ function hopToCss(t, v) {
31
31
  * - selector → locator(css)
32
32
  * - class → locator('.a.b')
33
33
  * - includes/text → locator('text=...')(在当前范围内找包含该文本的元素)
34
- * - label → getByLabel(表单控件按可见标签定位,平台 label hop 37 处)
34
+ * - label → 平台历史 DSL 的 label 是 HTML 标签选择器(tbody/tr/div 等),
35
+ * 现代表单「按可见 label 找控件」语义只在取值看起来不是标签名时兜底
35
36
  * - iframe-url → frameLocator(进入 iframe 上下文,src 部分匹配)
36
37
  * - javascript/source → 跳过(javascript 仅 WRITE_GLOBAL 场景由 engine.evaluate 处理)
37
38
  *
@@ -68,10 +69,25 @@ function applyHops(page, scope, hops) {
68
69
  cur = cur.locator('text=' + text.replace(/\\/g, '\\\\').replace(/"/g, '\\"'));
69
70
  }
70
71
  else if (t === 'label') {
71
- // 表单控件按可见标签定位(平台 label hop)。Playwright getByLabel 在
72
- // Locator/FrameLocator 上同样可用,语义与「找 label 文本对应的输入控件」一致。
72
+ // 平台历史 DSL 的 label 是 HTML 标签选择器:tbody → locator('tbody')。
73
+ // 若值不是标签名(如“用户名/确定”),保留按可见 label 找控件语义,避免
74
+ // 历史或新 DSL 里依赖 label 文本的定位直接失效。
73
75
  const label = String(v).trim();
74
- cur = cur.getByLabel(label, { exact: false });
76
+ const tagRe = /^[A-Za-z][A-Za-z0-9-]*$/;
77
+ const knownTag = tagRe.test(label) && [
78
+ 'a', 'abbr', 'address', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo',
79
+ 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'cite', 'code', 'col',
80
+ 'colgroup', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl',
81
+ 'dt', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1',
82
+ 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe',
83
+ 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'link', 'main', 'map', 'mark',
84
+ 'menu', 'meta', 'meter', 'nav', 'noscript', 'object', 'ol', 'optgroup', 'option',
85
+ 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp',
86
+ 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style',
87
+ 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th',
88
+ 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video', 'wbr',
89
+ ].includes(label.toLowerCase());
90
+ cur = knownTag ? cur.locator(label) : cur.getByLabel(label, { exact: false });
75
91
  }
76
92
  else {
77
93
  throw new Error(`Unknown hop type '${t}' in chain: ${JSON.stringify(hop)}`);
@@ -94,6 +94,9 @@ export interface EngineAdapter {
94
94
  expectTextEquals(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
95
95
  /** 断言:元素文本不等于(平台 EXPECTED_VALUE ctype=text cexpression=not.eq) */
96
96
  expectTextNotEquals(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
97
+ /** 断言:表单元素当前值等于(input/textarea/select 的 value,而非文本内容)。
98
+ * 与 expectTextEquals 的区别:后者读的是元素文本,对 input 取不到用户输入的值。 */
99
+ expectValue(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
97
100
  /** 断言:元素数量 */
98
101
  expectCount(count: number, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
99
102
  /** 断言:元素集合中「文本非空」的个数(平台 EXPECTED_VALUE ctype=index,历史语义为计数) */
@@ -12,7 +12,7 @@
12
12
  * selector/id/shadow-dom → CSS
13
13
  * class → .a.b(空格分隔多 class)
14
14
  * includes/text → 文本包含,取「包含该文本的最深(最小)元素」
15
- * label → 可见 label 关联的表单控件
15
+ * label → HTML 标签名按标签选择器;非标签名回退「可见 label 关联控件」
16
16
  * iframe-url → 同源 iframe 内继续;跨源标记 cross-origin-iframe
17
17
  * javascript/source/'' → 跳过
18
18
  * hop.index → 1-based;-1/0/非数字表示不取序号
@@ -13,7 +13,7 @@
13
13
  * selector/id/shadow-dom → CSS
14
14
  * class → .a.b(空格分隔多 class)
15
15
  * includes/text → 文本包含,取「包含该文本的最深(最小)元素」
16
- * label → 可见 label 关联的表单控件
16
+ * label → HTML 标签名按标签选择器;非标签名回退「可见 label 关联控件」
17
17
  * iframe-url → 同源 iframe 内继续;跨源标记 cross-origin-iframe
18
18
  * javascript/source/'' → 跳过
19
19
  * hop.index → 1-based;-1/0/非数字表示不取序号
@@ -28,7 +28,7 @@ exports.locatorSource = locatorSource;
28
28
  function locatorSource() {
29
29
  return `(() => {
30
30
  "use strict";
31
- if (window.__keveLocatorSource === "v1") return "cached";
31
+ if (window.__keveLocatorSource === "v5") return "cached";
32
32
 
33
33
  function classToCss(v) {
34
34
  return String(v).trim().split(/\\s+/).filter(Boolean).map(function (c) {
@@ -69,9 +69,45 @@ function locatorSource() {
69
69
  return d;
70
70
  }
71
71
 
72
- /** label 文本 → 关联控件:label[for] / 控件嵌套于 label / aria-label */
72
+ /** 归一化用于文本定位的句子:统一空白与半/全角标点,并忽略成对的中文引号装饰 */
73
+ function normalizeText(str) {
74
+ return String(str == null ? "" : str)
75
+ .normalize("NFKC")
76
+ .replace(/[「」『』【】《》“”‘’]/g, "")
77
+ .replace(/\\s+/g, " ")
78
+ .trim();
79
+ }
80
+
81
+ var HTML_TAGS = {
82
+ "a": 1, "abbr": 1, "address": 1, "article": 1, "aside": 1, "audio": 1, "b": 1, "base": 1,
83
+ "bdi": 1, "bdo": 1, "blockquote": 1, "body": 1, "br": 1, "button": 1, "canvas": 1, "caption": 1,
84
+ "cite": 1, "code": 1, "col": 1, "colgroup": 1, "data": 1, "datalist": 1, "dd": 1, "del": 1,
85
+ "details": 1, "dfn": 1, "dialog": 1, "div": 1, "dl": 1, "dt": 1, "em": 1, "embed": 1,
86
+ "fieldset": 1, "figcaption": 1, "figure": 1, "footer": 1, "form": 1, "h1": 1, "h2": 1,
87
+ "h3": 1, "h4": 1, "h5": 1, "h6": 1, "head": 1, "header": 1, "hgroup": 1, "hr": 1, "html": 1,
88
+ "i": 1, "iframe": 1, "img": 1, "input": 1, "ins": 1, "kbd": 1, "label": 1, "legend": 1,
89
+ "li": 1, "link": 1, "main": 1, "map": 1, "mark": 1, "menu": 1, "meta": 1, "meter": 1,
90
+ "nav": 1, "noscript": 1, "object": 1, "ol": 1, "optgroup": 1, "option": 1, "output": 1,
91
+ "p": 1, "picture": 1, "pre": 1, "progress": 1, "q": 1, "rp": 1, "rt": 1, "ruby": 1, "s": 1,
92
+ "samp": 1, "script": 1, "section": 1, "select": 1, "slot": 1, "small": 1, "source": 1,
93
+ "span": 1, "strong": 1, "style": 1, "sub": 1, "summary": 1, "sup": 1, "table": 1, "tbody": 1,
94
+ "td": 1, "template": 1, "textarea": 1, "tfoot": 1, "th": 1, "thead": 1, "time": 1, "title": 1,
95
+ "tr": 1, "track": 1, "u": 1, "ul": 1, "var": 1, "video": 1, "wbr": 1,
96
+ };
97
+
98
+ /** label 语义:HTML 标签名 → 标签选择器(含最近祖先回退);否则按 label 文本找关联控件 */
73
99
  function byLabel(root, text) {
74
- var needle = String(text).trim();
100
+ var raw = String(text == null ? "" : text).trim();
101
+ if (/^[A-Za-z][A-Za-z0-9-]*$/.test(raw) && HTML_TAGS[raw.toLowerCase()]) {
102
+ var tagOut = queryAllDeep(root, raw);
103
+ // includes 常收敛到行内最深文本节点;
104
+ // 若目标是祖先标签,直接按后代查会落空,补充最近同名祖先回退。
105
+ var ancestor = null;
106
+ if (root && root.nodeType === 1 && root.closest) ancestor = root.closest(raw);
107
+ if (ancestor && tagOut.indexOf(ancestor) < 0) tagOut.unshift(ancestor);
108
+ return tagOut;
109
+ }
110
+ var needle = raw;
75
111
  var labels = queryAllDeep(root, "label");
76
112
  for (var i = 0; i < labels.length; i += 1) {
77
113
  var lab = labels[i];
@@ -85,16 +121,16 @@ function locatorSource() {
85
121
  if (candidates[c].id === forId) { byId = candidates[c]; break; }
86
122
  }
87
123
  }
88
- if (byId) return byId;
124
+ if (byId) return [byId];
89
125
  }
90
126
  var nested = queryAll(lab, "input,textarea,select,button,[role]")[0];
91
- if (nested) return nested;
127
+ if (nested) return [nested];
92
128
  }
93
129
  var aria = queryAllDeep(root, "[aria-label]");
94
130
  for (var a = 0; a < aria.length; a += 1) {
95
- if (String(aria[a].getAttribute("aria-label") || "").indexOf(needle) >= 0) return aria[a];
131
+ if (String(aria[a].getAttribute("aria-label") || "").indexOf(needle) >= 0) return [aria[a]];
96
132
  }
97
- return null;
133
+ return [];
98
134
  }
99
135
 
100
136
  /**
@@ -133,25 +169,41 @@ function locatorSource() {
133
169
  for (var s = 0; s < scopeEls.length; s += 1) {
134
170
  var scope = scopeEls[s];
135
171
  if (type === "includes" || type === "text") {
136
- var needle = String(value).trim();
172
+ var needle = normalizeText(value);
137
173
  if (!needle) continue;
174
+ // Playwright text= 会让「元素自身直挂文本」也能命中;这里除后代外把
175
+ // scope 本身也作为候选,避免 selector → text 链把直挂文本落空。
138
176
  var all = queryAllDeep(scope, "*");
139
177
  var hits = [];
178
+ if (normalizeText(scope.textContent).indexOf(needle) >= 0) hits.push(scope);
140
179
  for (var i = 0; i < all.length; i += 1) {
141
- if (String(all[i].textContent || "").indexOf(needle) >= 0) hits.push(all[i]);
180
+ if (normalizeText(all[i].textContent).indexOf(needle) >= 0) hits.push(all[i]);
142
181
  }
143
182
  if (!hits.length) continue;
144
- // 「最小元素」= 命中里最深的一个(与 Playwright text= 命中叶子节点一致)
145
- var best = hits[0], bestDepth = depthOf(best);
146
- for (var h = 1; h < hits.length; h += 1) {
147
- var d = depthOf(hits[h]);
148
- if (d > bestDepth) { bestDepth = d; best = hits[h]; }
183
+ // 文本匹配默认取命中里最深的一个(与 Playwright text= 命中叶子节点一致),
184
+ // 但页面可能残留隐藏弹窗里的禁用按钮,这里优先从「可见且未禁用」的命中里选,
185
+ // 没有可见候选时才回退到全部命中,避免 text=确定 点到隐藏禁用按钮。
186
+ var candidates = hits.filter(function (el) {
187
+ if (el.disabled) return false;
188
+ try {
189
+ var style = window.getComputedStyle(el);
190
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
191
+ var rect = el.getBoundingClientRect();
192
+ if (rect.width === 0 && rect.height === 0) return false;
193
+ } catch (e) { /* default to enabled candidate */ }
194
+ return true;
195
+ });
196
+ if (!candidates.length) candidates = hits;
197
+ var best = candidates[0], bestDepth = depthOf(best);
198
+ for (var h = 1; h < candidates.length; h += 1) {
199
+ var d = depthOf(candidates[h]);
200
+ if (d > bestDepth) { bestDepth = d; best = candidates[h]; }
149
201
  }
150
202
  picked.push(best);
151
203
  } else if (type === "label") {
152
- var ctl = byLabel(scope, value);
153
- if (!ctl) continue;
154
- picked.push(ctl);
204
+ var ctlMatches = byLabel(scope, value);
205
+ if (!ctlMatches.length) continue;
206
+ picked = picked.concat(ctlMatches);
155
207
  } else if (type === "role") {
156
208
  var spec = value && typeof value === "object" ? value : { role: value };
157
209
  picked = picked.concat(resolveByRoleIn([scope], spec.role, spec.name));
@@ -161,7 +213,7 @@ function locatorSource() {
161
213
  });
162
214
  picked = picked.concat(refHits);
163
215
  } else if (type === "has-text") {
164
- if (String(scope.textContent || "").indexOf(String(value)) >= 0) picked.push(scope);
216
+ if (normalizeText(scope.textContent).indexOf(normalizeText(value)) >= 0) picked.push(scope);
165
217
  } else if (type === "has-sub") {
166
218
  if (resolveWithin(scope, value).length) picked.push(scope);
167
219
  } else if (type === "not-has-sub") {
@@ -317,7 +369,7 @@ function locatorSource() {
317
369
  accessibleName: accessibleName,
318
370
  implicitRole: implicitRole,
319
371
  };
320
- window.__keveLocatorSource = "v1";
372
+ window.__keveLocatorSource = "v5";
321
373
  return "ok";
322
374
  })()`;
323
375
  }
@@ -62,6 +62,8 @@ export interface DslStep {
62
62
  expectation?: DslExpectation[];
63
63
  /** 拖拽目标 / 嵌套上下文 */
64
64
  child?: any;
65
+ /** MOVE_ELEMENT 等算子的嵌套子动作(如 hover 行后点击「更多」) */
66
+ children?: Array<Record<string, any>>;
65
67
  urlPart?: string;
66
68
  }
67
69
  /** 变量表行(按环境列取值) */