@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.
@@ -94,7 +94,7 @@ class CypressEngine {
94
94
  * 导航会销毁注入,所以这里每次校验 `__keveOpsSource` 版本号(幂等且极廉价)。
95
95
  */
96
96
  async ensureReady() {
97
- const has = await this.cdp.evaluateInAut('Boolean(window.__keveOpsSource === "v1" && window.__keveNetLogSource === "v1" && window.__keveLocatorSource === "v1")').catch(() => false);
97
+ const has = await this.cdp.evaluateInAut('Boolean(window.__keveOpsSource === "v7" && window.__keveNetLogSource === "v1" && window.__keveLocatorSource === "v5")').catch(() => false);
98
98
  if (has)
99
99
  return;
100
100
  const { locatorSource } = await Promise.resolve().then(() => __importStar(require('../dsl/locatorSource.js')));
@@ -209,11 +209,15 @@ class CypressEngine {
209
209
  // ── 交互 ─────────────────────────────────────────────────────────
210
210
  async click(...refs) {
211
211
  const r = normalizeRefs(refs);
212
- await this.withWait(() => this.opsCall('click', r, false));
212
+ // 默认 10s 重试窗口在 wujie 微前端嵌套页面下不够:实测弹层级联渲染(cascader
213
+ // menu 等)可能被主线程长任务挤到 10s 外才出现,click 命中前的定位重试因此
214
+ // 提前耗尽,fn 抛错掉进 Agent Re-Act 并在 120s 后被判 blocked(参见 S699
215
+ // 线上用例排查,task-local-699-*)。这里放宽到 20s,只影响 click/dblClick。
216
+ await this.withWait(() => this.opsCall('click', r, false), 20000);
213
217
  }
214
218
  async dblClick(...refs) {
215
219
  const r = normalizeRefs(refs);
216
- await this.withWait(() => this.opsCall('click', r, true));
220
+ await this.withWait(() => this.opsCall('click', r, true), 20000);
217
221
  }
218
222
  /** 强制点击:CDP 下没有 actionability 检查,语义等同于 click */
219
223
  async forceClick(...refs) {
@@ -358,6 +362,22 @@ class CypressEngine {
358
362
  }
359
363
  });
360
364
  }
365
+ /**
366
+ * 表单元素当前值断言(input/textarea/select 的 value)。
367
+ *
368
+ * 与 expectTextEquals 的区别是取值口径:input 的 textContent 恒为空,
369
+ * 用文本断言校验用户输入会永远失败;这里读 DOM value,才是「输入框里
370
+ * 确实有这个值」的证据。
371
+ */
372
+ async expectValue(expected, ...refs) {
373
+ const r = normalizeRefs(refs);
374
+ await this.withWait(async () => {
375
+ const actual = await this.opsCall('value', r);
376
+ if (actual !== String(expected)) {
377
+ throw new Error(`断言失败:元素值应等于「${expected}」,实际为「${actual}」`);
378
+ }
379
+ });
380
+ }
361
381
  async expectCount(count, ...refs) {
362
382
  const r = normalizeRefs(refs);
363
383
  await this.withWait(async () => {
@@ -305,7 +305,7 @@ class CyAgentPage {
305
305
  // ── 页内 agent 能力注入 ────────────────────────────────────────────
306
306
  async ensureAgent() {
307
307
  const ready = await this.cdp
308
- .evaluateInAut('Boolean(window.__keveAgentSource === "v1")')
308
+ .evaluateInAut('Boolean(window.__keveAgentSource === "v7")')
309
309
  .catch(() => false);
310
310
  if (ready)
311
311
  return;
@@ -24,7 +24,7 @@ exports.agentBootstrapSource = agentBootstrapSource;
24
24
  function agentDomSource() {
25
25
  return `(() => {
26
26
  "use strict";
27
- if (window.__keveAgentSource === "v1") return "cached";
27
+ if (window.__keveAgentSource === "v7") return "cached";
28
28
 
29
29
  var MAX_NAME = 120;
30
30
  var MAX_LINES = 1500;
@@ -141,7 +141,16 @@ function agentDomSource() {
141
141
  }
142
142
 
143
143
  function allText(el) {
144
- return String(el.textContent || "").replace(/\\s+/g, " ").trim();
144
+ return normalizeText(el.textContent);
145
+ }
146
+
147
+ /** 归一化用于文本匹配的句子:统一空白与半/全角标点,并忽略成对的中文引号装饰 */
148
+ function normalizeText(str) {
149
+ return String(str == null ? "" : str)
150
+ .normalize("NFKC")
151
+ .replace(/[「」『』【】《》“”‘’]/g, "")
152
+ .replace(/\\s+/g, " ")
153
+ .trim();
145
154
  }
146
155
 
147
156
  function isVisible(el) {
@@ -320,7 +329,7 @@ function agentDomSource() {
320
329
 
321
330
  /** 文本匹配取「包含该文本的最深元素」,与 locatorSource 的 includes 跳一致 */
322
331
  function textIn(scopes, needle, exact) {
323
- var want = String(needle == null ? "" : needle).replace(/\\s+/g, " ").trim();
332
+ var want = normalizeText(needle);
324
333
  if (!want) return [];
325
334
  var hits = [];
326
335
  for (var s = 0; s < scopes.length; s += 1) {
@@ -329,19 +338,46 @@ function agentDomSource() {
329
338
  ? queryAllDeep(document, "*")
330
339
  : queryAllDeep(scope, "*");
331
340
  var local = [];
341
+ // 与 locatorSource 的 includes/text 一致:scope 自身也可直挂目标文本
342
+ if (scope !== document) {
343
+ var selfText = allText(scope);
344
+ if (selfText && (exact ? selfText === want : selfText.indexOf(want) >= 0)) local.push(scope);
345
+ }
332
346
  for (var i = 0; i < pool.length; i += 1) {
333
347
  var text = allText(pool[i]);
334
348
  if (!text) continue;
335
349
  if (exact ? text === want : text.indexOf(want) >= 0) local.push(pool[i]);
336
350
  }
351
+ var outer = [];
337
352
  for (var j = 0; j < local.length; j += 1) {
338
353
  var el = local[j];
339
354
  var hasInnerMatch = false;
340
355
  for (var k = 0; k < local.length; k += 1) {
341
356
  if (k !== j && el.contains(local[k])) { hasInnerMatch = true; break; }
342
357
  }
343
- if (!hasInnerMatch) hits.push(el);
358
+ if (!hasInnerMatch) outer.push(el);
344
359
  }
360
+ if (!outer.length) continue;
361
+ // 与 locatorSource 保持一致:优先可见且未禁用的最深元素,
362
+ // 否则回退全部命中,避免隐藏弹窗中的禁用按钮抢走 text 命中。
363
+ var visible = outer.filter(function (el) {
364
+ if (el.disabled) return false;
365
+ try {
366
+ var style = window.getComputedStyle(el);
367
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
368
+ var rect = el.getBoundingClientRect();
369
+ if (rect.width === 0 && rect.height === 0) return false;
370
+ } catch (e) { /* enable fallback */ }
371
+ return true;
372
+ });
373
+ var pool = visible.length ? visible : outer;
374
+ var deepest = pool[0], maxDepth = -1;
375
+ for (var p = 0; p < pool.length; p += 1) {
376
+ var dd = 0, cur = pool[p];
377
+ while (cur && cur.parentElement) { dd += 1; cur = cur.parentElement; }
378
+ if (dd > maxDepth) { maxDepth = dd; deepest = pool[p]; }
379
+ }
380
+ hits.push(deepest);
345
381
  }
346
382
  return dedupe(hits);
347
383
  }
@@ -437,14 +473,37 @@ function agentDomSource() {
437
473
  el.dispatchEvent(new MouseEvent(type, base));
438
474
  }
439
475
 
476
+ /** 对应 pageOps.dragTapTarget:KwaiBI v-drag-no-tap 要求点中 c-toolbar-item */
477
+ function dragTapTarget(el) {
478
+ if (!el) return null;
479
+ var cur = el.nodeType === 1 ? el : (el.parentElement || null);
480
+ while (cur) {
481
+ var cls = String(cur.getAttribute && cur.getAttribute("class") || "");
482
+ if (cls.indexOf("c-toolbar-item") >= 0) return cur;
483
+ cur = cur.parentElement;
484
+ }
485
+ if (el.nodeType === 1 && el.querySelector) {
486
+ var found = el.querySelector(".c-toolbar-item");
487
+ if (found) return found;
488
+ }
489
+ return null;
490
+ }
491
+
440
492
  function clickEl(el, dbl) {
493
+ var _orig = el;
494
+ el = dragTapTarget(el) || interactiveTarget(el);
495
+ // 与 pageOps 一致:真实点击前先有 mouseover/mousemove,
496
+ // 否则 hover 才展开的菜单(el-dropdown-selfdefine 等)不会打开。
497
+ if (_orig && _orig.scrollIntoView) _orig.scrollIntoView({ block: "center" });
498
+ if (_orig) { fire(_orig, "mouseover"); fire(_orig, "mouseenter"); fire(_orig, "mousemove"); }
441
499
  if (el.scrollIntoView) el.scrollIntoView({ block: "center", inline: "center" });
442
500
  if (el.focus) { try { el.focus(); } catch (e) { /* 忽略 */ } }
443
501
  fire(el, "mousedown");
444
502
  fire(el, "mouseup");
503
+ // 与 pageOps.clickEl 保持一致:el.click() 已派发 click,不能再补合成 click,
504
+ // 否则一次点击会触发两遍处理逻辑(弹窗类元素会叠出两层)。
445
505
  if (typeof el.click === "function") el.click();
446
506
  else fire(el, "click");
447
- fire(el, "click");
448
507
  if (dbl) {
449
508
  fire(el, "mousedown", { detail: 2 });
450
509
  fire(el, "mouseup", { detail: 2 });
@@ -452,6 +511,27 @@ function agentDomSource() {
452
511
  }
453
512
  }
454
513
 
514
+ /** 文本定位常命中按钮内零尺寸 span;点击类动作应上溯到最近可交互祖先 */
515
+ function interactiveTarget(el) {
516
+ function clickable(node) {
517
+ if (!node || node.nodeType !== 1) return false;
518
+ var tag = String(node.tagName || "").toUpperCase();
519
+ var role = node.getAttribute && node.getAttribute("role");
520
+ if (tag === "BUTTON" || tag === "A" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA" || tag === "SUMMARY") return true;
521
+ if (role && /button|link|menuitem|checkbox|radio|switch|tab|option/.test(role)) return true;
522
+ if (node.getAttribute && (node.hasAttribute("onclick") || node.hasAttribute("ng-click"))) return true;
523
+ try { if (window.getComputedStyle(node).cursor === "pointer") return true; } catch (e) { /* ignore */ }
524
+ return false;
525
+ }
526
+ if (clickable(el)) return el;
527
+ var cur = el && el.parentElement;
528
+ while (cur) {
529
+ if (clickable(cur)) return cur;
530
+ cur = cur.parentElement;
531
+ }
532
+ return el;
533
+ }
534
+
455
535
  function setNativeValue(el, value) {
456
536
  var text = value == null ? "" : String(value);
457
537
  if (el.isContentEditable) {
@@ -580,7 +660,7 @@ function agentDomSource() {
580
660
  focus: function (ops, index) { var el = pickOrThrow(resolve(ops), index); if (el.focus) el.focus(); return "ok"; },
581
661
  evalScript: function (src) { return (0, eval)(String(src)); },
582
662
  };
583
- window.__keveAgentSource = "v1";
663
+ window.__keveAgentSource = "v7";
584
664
  return "ok";
585
665
  })()`;
586
666
  }
@@ -291,7 +291,7 @@ function createCdp(send) {
291
291
  await send('Page.addScriptToEvaluateOnNewDocument', { source });
292
292
  }
293
293
  async function ensureLocator() {
294
- const ok = await evaluateInAut('Boolean(window.__keveLocator && window.__keveLocatorSource === "v1")');
294
+ const ok = await evaluateInAut('Boolean(window.__keveLocator && window.__keveLocatorSource === "v5")');
295
295
  if (!ok) {
296
296
  const { locatorSource } = await Promise.resolve().then(() => __importStar(require('../dsl/locatorSource.js')));
297
297
  await injectLocator(locatorSource());
@@ -60,7 +60,7 @@ const diagnostics_js_1 = require("../page-agent/diagnostics.js");
60
60
  /** 单个用例体允许的最长执行时间(与 Playwright 侧 timeout 对齐) */
61
61
  const CY_TEST_TIMEOUT_MS = Number(process.env.KEVE_CY_TEST_TIMEOUT || 300_000);
62
62
  /** 单个 goal 的探索上限(与 Playwright keve-test.ts 的 GOAL_TIMEOUT_MS 对齐) */
63
- const CY_GOAL_TIMEOUT_MS = 300_000;
63
+ const CY_GOAL_TIMEOUT_MS = Number(globalThis.Cypress?.env?.('KEVE_CY_GOAL_TIMEOUT') || 120_000);
64
64
  /**
65
65
  * Cypress 的运行时对象(`Cypress`):env / config / automation / currentTest / spec。
66
66
  * 注意它**不是**命令链,不能 `.visit()` / `.wrap()`。
@@ -218,10 +218,10 @@ function installGoalExplorer(engine, bridge) {
218
218
  (0, keveGoal_js_1.setCyGoalExplorer)(async (options, ctx) => {
219
219
  const page = new agent_page_js_1.CyAgentPage(cdp);
220
220
  const controller = new AbortController();
221
- const timer = setTimeout(() => controller.abort(), CY_GOAL_TIMEOUT_MS);
221
+ let timeoutTimer;
222
222
  const host = createCyAgentHost(bridge, controller.signal);
223
223
  try {
224
- const result = await (0, agent_js_1.reactLoop)(page, options.step, options.expected, {
224
+ const reactPromise = (0, agent_js_1.reactLoop)(page, options.step, options.expected, {
225
225
  specFilePath: ctx.specFilePath,
226
226
  fnSource: ctx.fnSource,
227
227
  // fn 只是前置确定性校验:成功时也要让 Agent 重新走一遍 Re-Act 给出
@@ -236,6 +236,41 @@ function installGoalExplorer(engine, bridge) {
236
236
  loadInitialImages: host.loadInitialImages,
237
237
  llm: host.llm,
238
238
  });
239
+ // AbortSignal 只能请求协作式终止,不能保证第三方 LLM 或页面 hook
240
+ // 及时结束 Promise。必须同时 race 一个独立的 timer,避免最终卡在
241
+ // Cypress 的外层 cy.then() 超时(host.js:414)。
242
+ const timeoutMs = Math.max(1_000, CY_GOAL_TIMEOUT_MS);
243
+ const timeoutPromise = new Promise((resolve) => {
244
+ timeoutTimer = setTimeout(() => {
245
+ controller.abort();
246
+ resolve({ timedOut: true });
247
+ }, Math.max(1_000, timeoutMs - 1_000));
248
+ });
249
+ const outcome = await Promise.race([
250
+ reactPromise.then((result) => ({ timedOut: false, result })),
251
+ timeoutPromise,
252
+ ]);
253
+ if (!('result' in outcome)) {
254
+ // 让仍在收尾的 reactLoop 在后台观察 abort;当前 goal 立即返回,
255
+ // 后续由 keveGoal 记录 blocked,而不是继续占用 Cypress 的测试回调。
256
+ void reactPromise.catch(() => undefined);
257
+ return {
258
+ expectedMet: false,
259
+ conclusion: 'blocked',
260
+ actions: [{
261
+ action: {
262
+ tool: 'done',
263
+ verdict: 'blocked',
264
+ text: `Agent timed out after ${timeoutMs}ms`,
265
+ },
266
+ toolOutput: '',
267
+ result: 'ok',
268
+ }],
269
+ finalSnapshot: '',
270
+ agentScreenshots: [],
271
+ };
272
+ }
273
+ const result = outcome.result;
239
274
  /**
240
275
  * Cypress 侧 reactLoop 的原生页面事件监听不会产生数据(CyAgentPage 不派发
241
276
  * console/network 事件)。真正的采集在主世界预加载脚本里完成,这里读取
@@ -265,7 +300,8 @@ function installGoalExplorer(engine, bridge) {
265
300
  };
266
301
  }
267
302
  finally {
268
- clearTimeout(timer);
303
+ if (timeoutTimer)
304
+ clearTimeout(timeoutTimer);
269
305
  page.dispose();
270
306
  }
271
307
  });
@@ -17,6 +17,15 @@ export interface KeveGoalCallOptions {
17
17
  precondition?: string;
18
18
  step: string;
19
19
  expected: string;
20
+ /**
21
+ * 该 goal 的预期是否已被 fn 内的可执行断言完全覆盖。
22
+ *
23
+ * 由 DSL 编译器在「步骤无显式断言、但编译器补了确定性断言」时置为 true。
24
+ * 置位后 fn 无异常即等价于 expected 达成,无需再进 Agent Re-Act ——
25
+ * 否则 Agent 会按字面把「输入」理解为「把表单填完」,越界操作直到撞上
26
+ * 步数上限,把一步已成功的动作判成失败。
27
+ */
28
+ deterministic?: boolean;
20
29
  }
21
30
  /** agent Re-Act 的结果(P0-3 接入;未接入时由 fn 结果直接判定) */
22
31
  export interface CyGoalExploreResult {
@@ -139,6 +139,26 @@ function createCyKeveGoal(engine) {
139
139
  };
140
140
  console.log(`[keveGoal] 📊 "${options.step}" 截图差异超阈值 — 确定性失败`);
141
141
  }
142
+ else if (options.deterministic && fn && !fnError) {
143
+ // ── 确定性短路:fn 内的断言已完整覆盖 expected ──
144
+ // 与 fnBlocked / 截图差异同属「确定性事实」,不进 Re-Act。
145
+ // 进入 Re-Act 只会让 Agent 重读一句宽泛的步骤文案,把动作做到本步之外。
146
+ reactResult = {
147
+ expectedMet: true,
148
+ conclusion: 'pass',
149
+ actions: [{
150
+ action: {
151
+ tool: 'done',
152
+ verdict: 'pass',
153
+ text: `Deterministic step: fn 断言已通过(${options.expected})`,
154
+ },
155
+ toolOutput: '',
156
+ result: 'ok',
157
+ }],
158
+ agentScreenshots: [],
159
+ };
160
+ console.log(`[keveGoal] ⚡ "${options.step}" 确定性步骤 — fn 断言通过,跳过 Agent Re-Act`);
161
+ }
142
162
  else {
143
163
  // fn 成功 / 失败都交给评测器。fn 只是前置动作与确定性校验:
144
164
  // - fn 成功 ≠ expected 达成(fn 可能只覆盖预期的一个子集);
@@ -21,7 +21,7 @@ exports.diagnosticsSource = diagnosticsSource;
21
21
  function pageOpsSource() {
22
22
  return `(() => {
23
23
  "use strict";
24
- if (window.__keveOpsSource === "v1") return "cached";
24
+ if (window.__keveOpsSource === "v7") return "cached";
25
25
 
26
26
  function locator() {
27
27
  if (!window.__keveLocator) throw new Error("[keve] locator 未注入");
@@ -64,14 +64,82 @@ function pageOpsSource() {
64
64
  el.dispatchEvent(new MouseEvent(type, init));
65
65
  }
66
66
 
67
+ /** 文本定位常命中按钮内零尺寸 span;点击类动作应上溯到最近可交互祖先 */
68
+ function interactiveTarget(el) {
69
+ function clickable(node) {
70
+ if (!node || node.nodeType !== 1) return false;
71
+ var tag = String(node.tagName || "").toUpperCase();
72
+ var role = node.getAttribute && node.getAttribute("role");
73
+ if (tag === "BUTTON" || tag === "A" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA" || tag === "SUMMARY") return true;
74
+ if (role && /button|link|menuitem|checkbox|radio|switch|tab|option/.test(role)) return true;
75
+ if (node.getAttribute && (node.hasAttribute("onclick") || node.hasAttribute("ng-click"))) return true;
76
+ try { if (window.getComputedStyle(node).cursor === "pointer") return true; } catch (e) { /* ignore */ }
77
+ return false;
78
+ }
79
+ if (clickable(el)) return el;
80
+ var cur = el && el.parentElement;
81
+ while (cur) {
82
+ if (clickable(cur)) return cur;
83
+ cur = cur.parentElement;
84
+ }
85
+ return el;
86
+ }
87
+
88
+ /**
89
+ * KwaiBI 图表工具栏把 mousedown/mouseup 绑定在 grid-free-item.c-toolbar-item
90
+ * 上(v-drag-no-tap 指令),且要求 down/up 落在同一个元素。data-e2e-name
91
+ * 常挂在外层 SPAN 或 c-toolbar-item-container 上,只点上溯祖先不会触发。
92
+ */
93
+ function dragTapTarget(el) {
94
+ if (!el) return null;
95
+ var cur = el.nodeType === 1 ? el : (el.parentElement || null);
96
+ while (cur) {
97
+ var cls = String(cur.getAttribute && cur.getAttribute("class") || "");
98
+ if (cls.indexOf("c-toolbar-item") >= 0) return cur;
99
+ cur = cur.parentElement;
100
+ }
101
+ if (el.nodeType === 1 && el.querySelector) {
102
+ var found = el.querySelector(".c-toolbar-item");
103
+ if (found) return found;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** 悬停事件序列:部分下拉/弹层(el-dropdown-selfdefine / el-popover__reference)由 hover 打开 */
109
+ function hoverEl(el) {
110
+ if (!el) return;
111
+ el.scrollIntoView({ block: "center" });
112
+ mouse(el, "mouseover");
113
+ mouse(el, "mouseenter");
114
+ mouse(el, "mousemove");
115
+ }
116
+
67
117
  function clickEl(el, dbl) {
118
+ var _orig = el;
119
+ // 若命中 drag-no-tap 工具栏项或其容器,事件必须落在该子项上;
120
+ // 普通元素仍按可交互祖先处理。
121
+ el = dragTapTarget(el) || interactiveTarget(el);
122
+ // 点击前补真实鼠标移动序列:浏览器点击本身会先产生 mouseover/mousemove,
123
+ // 缺了它会让依赖 hover 展开的下拉菜单按钮(如 KwaiBI 表格菜单)不生效。
124
+ hoverEl(_orig);
125
+ try {
126
+ window.__keveLastClick = {
127
+ origTag: _orig && _orig.tagName,
128
+ origCls: _orig && (_orig.getAttribute && _orig.getAttribute("class") || ""),
129
+ tag: el && el.tagName,
130
+ cls: el && (el.getAttribute && el.getAttribute("class") || ""),
131
+ at: Date.now(),
132
+ };
133
+ } catch (e) { /* debug */ }
68
134
  el.scrollIntoView({ block: "center", inline: "center" });
69
135
  el.focus && el.focus();
70
136
  mouse(el, "mousedown");
71
137
  mouse(el, "mouseup");
138
+ // 单次点击只能派发一个 click:el.click() 自身即会派发 click,
139
+ // 再补一个合成 click 会让「打开弹窗」这类幂等性差的处理逻辑执行两次
140
+ // (实测一次「新建看板」会叠出两层 el-dialog,后续取 els[0] 命中被遮住的底层)。
72
141
  if (typeof el.click === "function") el.click();
73
142
  else mouse(el, "click");
74
- mouse(el, "click");
75
143
  if (dbl) {
76
144
  mouse(el, "mousedown", 2);
77
145
  mouse(el, "mouseup", 2);
@@ -104,6 +172,13 @@ function pageOpsSource() {
104
172
  return t.replace(/\\s+/g, " ").trim();
105
173
  }
106
174
 
175
+ // 表单元素当前值:只读 value,不回退 textContent。
176
+ // 输入类断言必须用它 —— input 的 textContent 恒为空,textOf 取不到用户输入。
177
+ function valueOf(el) {
178
+ if (!el) return "";
179
+ return el.value != null ? String(el.value) : "";
180
+ }
181
+
107
182
  function attrOf(el, name) {
108
183
  if (!el) return null;
109
184
  return el.getAttribute(name);
@@ -133,6 +208,7 @@ function pageOpsSource() {
133
208
  },
134
209
  text: function (refs) { return textOf(first(refs)); },
135
210
  texts: function (refs) { return resolve(refs).map(textOf); },
211
+ value: function (refs) { return valueOf(first(refs)); },
136
212
  visible: function (refs) { return isVisible(first(refs)); },
137
213
  hidden: function (refs) { var els = resolve(refs); return !els.length || !isVisible(els[0]); },
138
214
  exists: function (refs) { return resolve(refs).length > 0; },
@@ -157,7 +233,7 @@ function pageOpsSource() {
157
233
  return (0, eval)("(" + expr + ")");
158
234
  },
159
235
  };
160
- window.__keveOpsSource = "v1";
236
+ window.__keveOpsSource = "v7";
161
237
  return "ok";
162
238
  })()`;
163
239
  }
@@ -52,6 +52,13 @@ export declare class PlaywrightEngine implements EngineAdapter {
52
52
  expectNotText(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
53
53
  expectTextEquals(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
54
54
  expectTextNotEquals(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
55
+ /**
56
+ * 表单元素当前值断言(input/textarea/select 的 value)。
57
+ *
58
+ * 必须用 toHaveValue 而不是 toHaveText:input 的 textContent 恒为空,
59
+ * 文本断言对用户输入永远失败,会把它误判成用例缺陷。
60
+ */
61
+ expectValue(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
55
62
  expectCount(count: number, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
56
63
  expectNonEmptyCount(count: number, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
57
64
  expectExists(...refs: (ElementRef | ElementEntry)[]): Promise<void>;
@@ -264,6 +264,16 @@ class PlaywrightEngine {
264
264
  const locator = this.resolve(refs);
265
265
  await (0, test_1.expect)(locator.first()).not.toHaveText(expected);
266
266
  }
267
+ /**
268
+ * 表单元素当前值断言(input/textarea/select 的 value)。
269
+ *
270
+ * 必须用 toHaveValue 而不是 toHaveText:input 的 textContent 恒为空,
271
+ * 文本断言对用户输入永远失败,会把它误判成用例缺陷。
272
+ */
273
+ async expectValue(expected, ...refs) {
274
+ const locator = this.resolve(refs);
275
+ await (0, test_1.expect)(locator.first()).toHaveValue(expected);
276
+ }
267
277
  async expectCount(count, ...refs) {
268
278
  const locator = this.resolve(refs);
269
279
  await (0, test_1.expect)(locator).toHaveCount(count);
@@ -32,6 +32,14 @@ export interface KeveGoalCallOptions {
32
32
  precondition?: string;
33
33
  step: string;
34
34
  expected: string;
35
+ /**
36
+ * 该 goal 的预期是否已被 fn 内的可执行断言完全覆盖。
37
+ *
38
+ * 由 DSL 编译器在「步骤无显式断言、但编译器补了确定性断言」时置为 true。
39
+ * 与 Cypress 侧同构:置位后 fn 无异常即等价于 expected 达成,跳过 Agent Re-Act,
40
+ * 避免 Agent 按字面把「输入」扩大成「把表单填完」而越界操作。
41
+ */
42
+ deterministic?: boolean;
35
43
  }
36
44
  type KeveFixture = {
37
45
  keveGoal: (options: KeveGoalCallOptions, fn?: () => Promise<void>) => Promise<void>;
@@ -299,6 +299,27 @@ exports.test = test_1.test.extend({
299
299
  conclusion: 'fail',
300
300
  };
301
301
  }
302
+ else if (options.deterministic && fn && !fnError) {
303
+ // ── 确定性短路:fn 内的断言已完整覆盖 expected ──
304
+ // 与 Cypress 侧同构。Agent Re-Act 在这个场景没有可提供的信息,
305
+ // 只会重读宽泛步骤文案并把动作做到本步之外。
306
+ console.log(`[keveGoal] ⚡ "${options.step}" deterministic step — fn 断言通过,跳过 Agent Re-Act`);
307
+ reactResult = {
308
+ expectedMet: true,
309
+ actions: [{
310
+ action: {
311
+ tool: 'done',
312
+ verdict: 'pass',
313
+ text: `Deterministic step: fn 断言已通过(${options.expected})`,
314
+ },
315
+ toolOutput: '',
316
+ result: 'ok',
317
+ }],
318
+ finalSnapshot: '',
319
+ agentScreenshots: [],
320
+ conclusion: 'pass',
321
+ };
322
+ }
302
323
  else {
303
324
  // ── Per-goal timeout: prevent one slow goal from exhausting the test timeout ──
304
325
  // Default 300s per goal; total test timeout should be ≥ (goals × 300s + overhead)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kkeve/suite",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "description": "AI-powered full-stack test agent CLI - static analysis, unit test, component test, E2E test, execute, evaluate, and report",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -95,4 +95,4 @@
95
95
  "tsx": "^4.19.0",
96
96
  "typescript": "^6.0.2"
97
97
  }
98
- }
98
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as url from 'node:url';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { resolveEngineBin, buildExecEnv } from '../dist/runner/index.js';
7
+
8
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
9
+ const suiteRoot = path.resolve(__dirname, '..');
10
+ const runtimeDir = path.join(__dirname, 'runtime');
11
+ const defaultBackendRoot = path.resolve(suiteRoot, '..', '..', '..', 'eve-backend');
12
+
13
+ const inputFile = process.argv[2];
14
+ const specGlob = process.argv[3] || 'specs/__probe.spec.ts';
15
+ const round = process.argv[4] || 'probe';
16
+ const input = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
17
+ const taskDir = input.taskDir;
18
+ const backendRoot = input.backendRoot || defaultBackendRoot;
19
+ const resultDir = path.join(taskDir, 'reports', `round-${round}`);
20
+ fs.mkdirSync(resultDir, { recursive: true });
21
+
22
+ const env = buildExecEnv({
23
+ input, taskDir, round, engine: 'cypress', defaultBackendRoot, suiteRoot,
24
+ });
25
+ const cypressBin = resolveEngineBin({ engine: 'cypress', backendRoot, suiteRoot });
26
+ const configPath = path.join(runtimeDir, 'cypress.config.mjs');
27
+ const res = spawnSync(cypressBin, [
28
+ 'run', '--project', taskDir, '--config-file', configPath,
29
+ '--browser', 'electron', '--spec', specGlob,
30
+ ], {
31
+ cwd: taskDir,
32
+ stdio: 'inherit',
33
+ env: { ...env, ELECTRON_RUN_AS_NODE: '' },
34
+ });
35
+ process.exit(res.status ?? 1);