@kkeve/suite 0.0.23 → 0.0.25
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/dsl/toDecorator.js
CHANGED
|
@@ -160,7 +160,17 @@ function renderStep(d, ctx) {
|
|
|
160
160
|
const executableLineCount = [...body, ...expLines].length;
|
|
161
161
|
// P1 纵深防御:断言里若残留未解析占位符,说明解析链路有漏(正常已在
|
|
162
162
|
// stringExpr 处编译期报错),一律不许短路 —— 自证式断言不能当作确定性事实。
|
|
163
|
-
|
|
163
|
+
// stringExpr 对已知合法引用生成 ${String(X)} 模板,运行时变量在这里不是
|
|
164
|
+
// 「未解析占位符」,不应阻断确定性短路。其余残留 ${...} 仍是解析漏洞。
|
|
165
|
+
const hasUnresolved = [...body, ...expLines].some((b) => {
|
|
166
|
+
for (const m of String(b).matchAll(/\$\{([^}]+)\}/g)) {
|
|
167
|
+
const raw = String(m[1]).trim();
|
|
168
|
+
const name = /^String\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\)$/.exec(raw)?.[1] || raw;
|
|
169
|
+
if (!ctx.runtimeVars.has(name))
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
});
|
|
164
174
|
const deterministic = expectedFullyCovered && !pageLevelOp && executableLineCount > 0 && !hasUnresolved;
|
|
165
175
|
out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)}${deterministic ? ', deterministic: true' : ''} }, async () => {`);
|
|
166
176
|
for (const b of [...body, ...expLines])
|
|
@@ -285,7 +295,7 @@ function renderStep(d, ctx) {
|
|
|
285
295
|
: undefined);
|
|
286
296
|
const targetCtx = valueCtx || d.child?.context || d.child;
|
|
287
297
|
if (Array.isArray(targetCtx) && targetCtx.length) {
|
|
288
|
-
emit([`await engine.drag(${
|
|
298
|
+
emit([`await engine.drag(${chainExpr(normalizeDragSource(d.context), ctx)}, ${chainExpr(targetCtx, ctx)});`, ...waitExprs(d)]);
|
|
289
299
|
}
|
|
290
300
|
else {
|
|
291
301
|
throw new Error(`DRAGGING 缺少目标元素,拒绝退化为悬停: ${stepText}`);
|
|
@@ -1063,6 +1073,23 @@ function collectHops(context) {
|
|
|
1063
1073
|
}
|
|
1064
1074
|
return out;
|
|
1065
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* 拖拽源链归一化:录制器常见的「父容器 → 文本 → 列表项」链会把列表项限制在
|
|
1078
|
+
* 文本叶节点后代内,导致确定性定位落空。同用例里可稳定命中的点击链是
|
|
1079
|
+
* 「列表项 → 文本/includes」,拖拽源也应优先使用该形态。
|
|
1080
|
+
*/
|
|
1081
|
+
function normalizeDragSource(context) {
|
|
1082
|
+
const hops = Array.isArray(context) ? context : [];
|
|
1083
|
+
if (hops.length < 2)
|
|
1084
|
+
return context;
|
|
1085
|
+
const prev = hops[hops.length - 2];
|
|
1086
|
+
const tail = hops[hops.length - 1];
|
|
1087
|
+
const isListItemTail = String(tail?.value ?? '') === '[data-e2e-name="列表项"]';
|
|
1088
|
+
const isTextHop = String(prev?.type || '') === 'includes' || String(prev?.type || '') === 'text';
|
|
1089
|
+
if (isListItemTail && isTextHop)
|
|
1090
|
+
return [tail, prev];
|
|
1091
|
+
return context;
|
|
1092
|
+
}
|
|
1066
1093
|
/** 截图基线 key 兜底:步骤文案 → 稳定 key(同步骤重复生成不变) */
|
|
1067
1094
|
function sanitizeShotKey(text) {
|
|
1068
1095
|
return String(text || 'shot').replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '_').slice(0, 40);
|
|
@@ -209,7 +209,7 @@ function createCdp(send) {
|
|
|
209
209
|
async function goto(url, timeout = 30000) {
|
|
210
210
|
const before = await currentUrl();
|
|
211
211
|
await evaluateInAut(`location.href = ${JSON.stringify(url)}`);
|
|
212
|
-
const
|
|
212
|
+
const waitComplete = async () => {
|
|
213
213
|
const snap = await snapshot();
|
|
214
214
|
if (snap.readyState !== 'complete')
|
|
215
215
|
return undefined;
|
|
@@ -217,8 +217,21 @@ function createCdp(send) {
|
|
|
217
217
|
return undefined;
|
|
218
218
|
// 同 URL 的显式 re-goto 场景:URL 不变时直接以 readyState 为准
|
|
219
219
|
return snap;
|
|
220
|
-
}
|
|
221
|
-
|
|
220
|
+
};
|
|
221
|
+
const landed = await pollUntil(waitComplete, timeout);
|
|
222
|
+
if (landed)
|
|
223
|
+
return;
|
|
224
|
+
// 页面存在长连接时 document.readyState 可能一直停在 interactive,
|
|
225
|
+
// 但新文档实际已经可交互且 URL 已切到目标。此时不能再判导航超时。
|
|
226
|
+
const interactive = await pollUntil(async () => {
|
|
227
|
+
const snap = await snapshot();
|
|
228
|
+
if (snap.readyState === 'loading' || snap.readyState === 'uninitialized')
|
|
229
|
+
return undefined;
|
|
230
|
+
if (snap.url !== url)
|
|
231
|
+
return undefined;
|
|
232
|
+
return snap;
|
|
233
|
+
}, 5000);
|
|
234
|
+
if (!interactive) {
|
|
222
235
|
throw new Error(`[cypress-engine] 导航超时(${timeout}ms):${url}`);
|
|
223
236
|
}
|
|
224
237
|
}
|
|
@@ -40,10 +40,108 @@ function logLine(line) {
|
|
|
40
40
|
console.log(line);
|
|
41
41
|
(0, state_js_1.addLog)(line);
|
|
42
42
|
}
|
|
43
|
+
function openGoalPanelLog(order, step) {
|
|
44
|
+
// keveGoal 同源实现也被非 Cypress 宿主引用,必须显式探测运行时能力。
|
|
45
|
+
const cypress = globalThis.Cypress;
|
|
46
|
+
if (typeof cypress?.log !== 'function')
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
return cypress.log({
|
|
50
|
+
name: 'keveGoal',
|
|
51
|
+
// order 从 0 起算,展示与旧链路一致从「步骤1」开始。
|
|
52
|
+
displayName: `步骤${order + 1}`,
|
|
53
|
+
message: step,
|
|
54
|
+
}) || null;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.warn(`[keveGoal] 步骤面板日志创建失败(不影响执行):${err?.message || err}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function closeGoalPanelLog(log, success, errorMessage) {
|
|
62
|
+
if (!log)
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
// 失败态必须走 log.error():实测 set('state','failed') 会被 end() 重置回 passed。
|
|
66
|
+
// 成功态不手写 ✅/❌,交给 Cypress 原生状态染色。
|
|
67
|
+
if (!success)
|
|
68
|
+
log.error(new Error(errorMessage || 'keveGoal failed'));
|
|
69
|
+
log.end();
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.warn(`[keveGoal] 步骤面板日志收敛失败(不影响执行):${err?.message || err}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 面板单行承载有限:长错误折叠空白并截断,避免一次失败把后续步骤刷出视口。
|
|
77
|
+
*
|
|
78
|
+
* 折叠空白是必须的 —— 面板实测会把 message 里的 `\n` 折叠成空格,
|
|
79
|
+
* 这里显式做一遍,保证 stdout / 报告 / 面板三处文本一致。
|
|
80
|
+
*/
|
|
81
|
+
function clip(text, max = 220) {
|
|
82
|
+
const s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
83
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 把失败归因拆成「实际」与「判定」两段,供面板详情行与 errorMessage 复用。
|
|
87
|
+
*
|
|
88
|
+
* 面板实测限制(见方案文档第十节):
|
|
89
|
+
* - message 里的 `\n` 会被折叠成空格 → 多字段必须用显式分隔符连接;
|
|
90
|
+
* - `consoleProps` 不会展开、点击也不渲染 → 信息必须直接可见,不能藏起来。
|
|
91
|
+
* 因此这里只产出可直接铺在面板上的单段文本,不做多行排版。
|
|
92
|
+
*/
|
|
93
|
+
function describeGoalFailure(input) {
|
|
94
|
+
const actual = input.fnBlocked
|
|
95
|
+
? `环境阻断 — ${clip(input.fnError || '未知环境错误')}`
|
|
96
|
+
: input.shotDiff
|
|
97
|
+
? `截图差异超阈值 — ${clip(input.fnError || '未知差异')}`
|
|
98
|
+
: input.fnError
|
|
99
|
+
? `${input.assertion ? 'fn 断言未通过' : 'fn 执行报错'} — ${clip(input.fnError)}`
|
|
100
|
+
: 'Agent Re-Act 结束后仍未达到预期';
|
|
101
|
+
const judge = input.fnBlocked
|
|
102
|
+
? 'blocked · 环境错误,跳过 Agent Re-Act'
|
|
103
|
+
: input.overriddenByFn
|
|
104
|
+
? 'fail · fn 断言覆盖 Agent 的 PASS'
|
|
105
|
+
: input.fnError
|
|
106
|
+
? input.assertion
|
|
107
|
+
? 'fail · fn 断言未通过'
|
|
108
|
+
: 'fail · fn 报错后 Agent 仍未达成'
|
|
109
|
+
: 'fail · Agent Re-Act 未达成预期';
|
|
110
|
+
// 用「自述」而非「结论」:Agent 判 pass 却因 fn 断言被判 fail 时,
|
|
111
|
+
// 这句话与最终判定可能相反,如实呈现才能暴露冲突,而不是掩盖它。
|
|
112
|
+
const verdict = input.agentText
|
|
113
|
+
? `${judge}(Agent 自述:${clip(input.agentText, 120)})`
|
|
114
|
+
: judge;
|
|
115
|
+
return { actual, verdict };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 失败步骤在红色主行之后追加一条详情行。
|
|
119
|
+
*
|
|
120
|
+
* 独立成条而非塞进主行 message:实测详情条不继承红色,天然是「详情位」的
|
|
121
|
+
* 视觉层次,且主行保持「步骤N + 动作」的整洁形态,长文案不会污染步骤列表。
|
|
122
|
+
*/
|
|
123
|
+
function appendGoalFailurePanelLine(order, detail) {
|
|
124
|
+
const cypress = globalThis.Cypress;
|
|
125
|
+
if (typeof cypress?.log !== 'function')
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
cypress.log({
|
|
129
|
+
name: 'keveGoal',
|
|
130
|
+
displayName: `↳ 步骤${order + 1} 失败原因`,
|
|
131
|
+
// 不重复步骤文案:主行已显示「步骤N + 动作」,displayName 也带了步骤号,
|
|
132
|
+
// 详情行只承载「期望 / 实际 / 判定」三段,避免同样的信息占两倍行高。
|
|
133
|
+
message: detail,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
console.warn(`[keveGoal] 步骤面板失败详情写入失败(不影响执行):${err?.message || err}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
43
140
|
function createCyKeveGoal(engine) {
|
|
44
141
|
return async (options, fn) => {
|
|
45
142
|
const state = (0, state_js_1.requireTestState)();
|
|
46
143
|
const order = (0, state_js_1.nextGoalOrder)();
|
|
144
|
+
const panelLog = openGoalPanelLog(order, options.step);
|
|
47
145
|
const goalMeta = {
|
|
48
146
|
precondition: options.precondition,
|
|
49
147
|
step: options.step,
|
|
@@ -201,26 +299,50 @@ function createCyKeveGoal(engine) {
|
|
|
201
299
|
};
|
|
202
300
|
}
|
|
203
301
|
// ── 确定性覆盖:fn 断言失败不允许被 Agent 判成 pass ──
|
|
302
|
+
// 覆盖发生在此处,事后无法从 reactResult 反推(conclusion 已被改写),
|
|
303
|
+
// 因此就地留痕,供面板与 errorMessage 说明「为什么和 Agent 的结论相反」。
|
|
304
|
+
let overriddenByFn = false;
|
|
204
305
|
if (fnError && !fnBlocked && reactResult.expectedMet && (0, goal_heuristics_js_1.isAssertionFailure)(fnError)) {
|
|
205
306
|
console.log(`[keveGoal] ⚠️ "${options.step}" Agent 判 PASS 但 fn 断言失败 — 覆盖为 FAIL`);
|
|
206
307
|
reactResult.expectedMet = false;
|
|
207
308
|
reactResult.conclusion = 'fail';
|
|
309
|
+
overriddenByFn = true;
|
|
208
310
|
}
|
|
209
311
|
const conclusion = reactResult.expectedMet ? 'pass' : (reactResult.conclusion || 'fail');
|
|
210
312
|
const success = reactResult.expectedMet;
|
|
313
|
+
// 失败归因必须自带上下文:只报「哪一步 / 为什么」其中的一半,都要回查报告
|
|
314
|
+
// 才能定位。这里把第几步、步骤文案、fn 真实异常、Agent 判定理由一并带上。
|
|
315
|
+
const agentText = (reactResult.actions || [])
|
|
316
|
+
.filter((a) => a.action?.tool === 'done')
|
|
317
|
+
.pop()?.action?.text;
|
|
318
|
+
const failure = describeGoalFailure({
|
|
319
|
+
fnError,
|
|
320
|
+
fnBlocked,
|
|
321
|
+
shotDiff: fnErrorRaw?.name === 'ScreenshotDiffError',
|
|
322
|
+
assertion: Boolean(fnError && (0, goal_heuristics_js_1.isAssertionFailure)(fnError)),
|
|
323
|
+
overriddenByFn,
|
|
324
|
+
agentText,
|
|
325
|
+
});
|
|
326
|
+
const failureDetail = `期望:${clip(options.expected, 160)} | 实际:${failure.actual} | 判定:${failure.verdict}`;
|
|
211
327
|
const errorMessage = success
|
|
212
328
|
? undefined
|
|
213
329
|
: conclusion === 'blocked'
|
|
214
|
-
? `Blocked: ${fnBlocked ? fnError :
|
|
330
|
+
? `Blocked: [步骤${order + 1} ${options.step}] ${fnBlocked ? fnError : agentText || 'Agent blocked'}`
|
|
215
331
|
: fnErrorRaw?.name === 'ScreenshotDiffError'
|
|
216
|
-
? fnError
|
|
332
|
+
? `[步骤${order + 1} ${options.step}] ${fnError}`
|
|
217
333
|
: fnError && (0, goal_heuristics_js_1.isAssertionFailure)(fnError)
|
|
218
|
-
? `Assertion failed: ${fnError}`
|
|
219
|
-
: `Expected not achieved: ${options.expected}`;
|
|
334
|
+
? `Assertion failed: [步骤${order + 1} ${options.step}] ${fnError}`
|
|
335
|
+
: `Expected not achieved: [步骤${order + 1} ${options.step}] ${options.expected} — ${failure.actual}`;
|
|
220
336
|
// 与 Playwright keve-test.ts 的终局日志同形,保证两端执行轨迹可逐行对照
|
|
221
337
|
logLine(success
|
|
222
338
|
? `[keveGoal] ✅ "${options.step}" PASSED`
|
|
223
339
|
: `[keveGoal] ❌ "${options.step}" FAILED (${conclusion}) — ${errorMessage}`);
|
|
340
|
+
closeGoalPanelLog(panelLog, success, errorMessage);
|
|
341
|
+
if (!success) {
|
|
342
|
+
// 主行红条 + 独立详情行:面板上不点开任何东西就能回答
|
|
343
|
+
// 第几步 / 期望什么 / 实际发生什么 / 谁判的。
|
|
344
|
+
appendGoalFailurePanelLine(order, failureDetail);
|
|
345
|
+
}
|
|
224
346
|
// ── goal-after:优先取 agent done 时刻的截图,否则用 fn-after ──
|
|
225
347
|
let goalScreenshotAfter = '';
|
|
226
348
|
try {
|
package/dist/page-agent/tools.js
CHANGED
|
@@ -158,7 +158,9 @@ exports.tools.set('done', tool({
|
|
|
158
158
|
execute: async function (input) {
|
|
159
159
|
const icons = { pass: '✅', fail: '❌', blocked: '🚫' };
|
|
160
160
|
let verdict = input.verdict;
|
|
161
|
-
// 强制校验:verdict 缺失或非法时从 text 推断(MacroTool 扁平化导致 verdict 变 optional
|
|
161
|
+
// 强制校验:verdict 缺失或非法时从 text 推断(MacroTool 扁平化导致 verdict 变 optional)。
|
|
162
|
+
// verdict 与可推断文本都缺失时不能默认 pass —— Agent 已把动作做完但没给出结论,
|
|
163
|
+
// 交给上层按保守语义判定,而不是在工具层制造假绿。
|
|
162
164
|
if (!icons[verdict]) {
|
|
163
165
|
console.warn(`[done] ⚠️ verdict missing or invalid ("${input.verdict}"), inferring from text`);
|
|
164
166
|
const t = String(input.text || '').toLowerCase();
|
|
@@ -168,9 +170,12 @@ exports.tools.set('done', tool({
|
|
|
168
170
|
else if (t.includes('blocked') || t.includes('阻塞') || t.includes('阻止')) {
|
|
169
171
|
verdict = 'blocked';
|
|
170
172
|
}
|
|
171
|
-
else {
|
|
173
|
+
else if (t.length > 0) {
|
|
172
174
|
verdict = 'pass'; // 有 text 且无否定词 → 默认 pass
|
|
173
175
|
}
|
|
176
|
+
else {
|
|
177
|
+
throw new Error('done 缺少 verdict 且没有可推断的 text,不能默认判定通过');
|
|
178
|
+
}
|
|
174
179
|
}
|
|
175
180
|
return `${icons[verdict]} Task ${verdict}: ${input.text}`;
|
|
176
181
|
},
|
package/package.json
CHANGED