@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.
@@ -160,7 +160,17 @@ function renderStep(d, ctx) {
160
160
  const executableLineCount = [...body, ...expLines].length;
161
161
  // P1 纵深防御:断言里若残留未解析占位符,说明解析链路有漏(正常已在
162
162
  // stringExpr 处编译期报错),一律不许短路 —— 自证式断言不能当作确定性事实。
163
- const hasUnresolved = [...body, ...expLines].some((b) => /\$\{[^}]+\}/.test(b));
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(${chain}, ${chainExpr(targetCtx, ctx)});`, ...waitExprs(d)]);
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 landed = await pollUntil(async () => {
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
- }, timeout);
221
- if (!landed) {
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 : (reactResult.actions || []).filter((a) => a.action?.tool === 'done').pop()?.action?.text || 'Agent blocked'}`
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 {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kkeve/suite",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
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": {