@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.
- package/dist/core/page-object/elementResolver.js +20 -4
- package/dist/core/page-object/engine-types.d.ts +3 -0
- package/dist/dsl/locatorSource.d.ts +1 -1
- package/dist/dsl/locatorSource.js +72 -20
- package/dist/dsl/toDecorator.d.ts +2 -0
- package/dist/dsl/toDecorator.js +312 -68
- package/dist/engine-cypress/adapter.d.ts +8 -0
- package/dist/engine-cypress/adapter.js +23 -3
- package/dist/engine-cypress/agent-page.js +1 -1
- package/dist/engine-cypress/agentDom.js +86 -6
- package/dist/engine-cypress/cdp.js +1 -1
- package/dist/engine-cypress/host.js +40 -4
- package/dist/engine-cypress/keveGoal.d.ts +9 -0
- package/dist/engine-cypress/keveGoal.js +20 -0
- package/dist/engine-cypress/pageOps.js +79 -3
- package/dist/engine-playwright/engineAdapter.d.ts +7 -0
- package/dist/engine-playwright/engineAdapter.js +10 -0
- package/dist/engine-playwright/keve-test.d.ts +8 -0
- package/dist/engine-playwright/keve-test.js +21 -0
- package/package.json +2 -2
- package/scripts/run-probe.mjs +35 -0
|
@@ -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 === "
|
|
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
|
-
|
|
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 === "
|
|
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 === "
|
|
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
|
|
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 =
|
|
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)
|
|
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 = "
|
|
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 === "
|
|
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 =
|
|
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
|
-
|
|
221
|
+
let timeoutTimer;
|
|
222
222
|
const host = createCyAgentHost(bridge, controller.signal);
|
|
223
223
|
try {
|
|
224
|
-
const
|
|
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
|
-
|
|
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 === "
|
|
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 = "
|
|
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.
|
|
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);
|