@kkeve/suite 0.0.18 → 0.0.19

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.
@@ -106,7 +106,7 @@ function renderStep(d, ctx) {
106
106
  expLines.push(a);
107
107
  }
108
108
  const emit = (body) => {
109
- out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
109
+ out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)}${reactOption(d)} }, async () => {`);
110
110
  for (const b of [...body, ...expLines])
111
111
  out.push(` ${b}`);
112
112
  out.push(`});`);
@@ -232,7 +232,7 @@ function renderStep(d, ctx) {
232
232
  // 区域截图容错:元素不在当前页面(权限阻断等)时降级为整页截图,
233
233
  // 是否一致由基线比对决定,截图本身不做权限硬门。
234
234
  if (shotChain) {
235
- out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
235
+ out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)}${reactOption(d)} }, async () => {`);
236
236
  out.push(` try {`);
237
237
  out.push(` await engine.screenshot(${q(shotName)}, ${shotChain});`);
238
238
  out.push(` } catch (_shotErr: any) {`);
@@ -299,13 +299,50 @@ function renderOpenPageWithGates(d, gates, ctx) {
299
299
  body.push(`await __gate${gi}; // 等 ${String(g.text || '就绪响应')}`);
300
300
  });
301
301
  }
302
- out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
302
+ out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)}${reactOption(d)} }, async () => {`);
303
303
  for (const b of body)
304
304
  out.push(` ${b}`);
305
305
  out.push(`});`);
306
306
  return out;
307
307
  }
308
308
  // ─── 断言转译 ────────────────────────────────────────────────────────
309
+ /**
310
+ * 步骤是否只有「纯文案期望」,必须依赖 LLM 才能判定。
311
+ *
312
+ * 平台 DSL 里有两类 expectation:
313
+ * - 可执行断言(ELEMENT_NOT_EXIST / TEXT_EXIST …)→ 转译成 engine 断言,
314
+ * 成败在脚本里已经确定,无需再看页面;
315
+ * - 纯文案载体(TEXT / 无 type)→ 不产生任何断言,只以文字描述预期,
316
+ * 脚本无法自证,只能交给 Agent 观察页面后判定。
317
+ *
318
+ * 只有第二类(且该步骤没有可执行断言)才需要保留 Agent Re-Act。
319
+ */
320
+ function needsAgentJudge(d) {
321
+ const expects = d.expectation || [];
322
+ if (!expects.length)
323
+ return false;
324
+ const hasExecutable = expects.some((e) => {
325
+ const type = String(e.type || '');
326
+ return type && !(0, ops_js_1.isTextOnlyAssert)(type);
327
+ });
328
+ if (hasExecutable)
329
+ return false;
330
+ return expects.some((e) => {
331
+ const type = String(e.type || '');
332
+ return !type || (0, ops_js_1.isTextOnlyAssert)(type);
333
+ });
334
+ }
335
+ /**
336
+ * keveGoal 的 react 参数片段。
337
+ *
338
+ * 平台 DSL 是确定性录制产物:凡能自证的步骤都显式 react: false,跳过 Agent
339
+ * Re-Act —— 否则每个步骤都会额外发起一轮 LLM 探索,既拖长整体耗时至超出
340
+ * Cypress 用例超时(表现为「任务卡住」),也会让「报错1」这类仅作平台标签的
341
+ * expected 文案参与最终结论。
342
+ */
343
+ function reactOption(d) {
344
+ return needsAgentJudge(d) ? '' : ', react: false';
345
+ }
309
346
  /** 渲染一条 expectation;返回 null 表示「纯文案载体,不产生断言」 */
310
347
  function renderExpectation(e, ctx) {
311
348
  const type = String(e.type || '');
@@ -15,7 +15,9 @@ import type { Cdp } from './cdp.js';
15
15
  import { type CyShotDiffResult } from './shotBuffer.js';
16
16
  /** 浏览器 → Node 的调用封装(端口来自 config.env.KEVE_BRIDGE_PORT) */
17
17
  export interface Bridge {
18
- post(route: string, payload?: Record<string, unknown>): Promise<any>;
18
+ post(route: string, payload?: Record<string, unknown>, opts?: {
19
+ signal?: AbortSignal;
20
+ }): Promise<any>;
19
21
  }
20
22
  export declare function createBridge(): Bridge;
21
23
  declare function escapeRegExp(s: string): string;
@@ -56,11 +56,14 @@ function createBridge() {
56
56
  throw new Error('[cypress-engine] KEVE_BRIDGE_PORT 未注入:请确认 cypress.config 使用了 @kkeve/suite/engine-cypress 的 setupKeveBridge');
57
57
  const base = `http://127.0.0.1:${port}`;
58
58
  return {
59
- async post(route, payload = {}) {
59
+ async post(route, payload = {}, opts = {}) {
60
60
  const res = await fetch(base + route, {
61
61
  method: 'POST',
62
62
  headers: { 'content-type': 'application/json' },
63
63
  body: JSON.stringify(payload),
64
+ // 透传中止信号:goal 超时后必须能中断本次桥调用,否则浏览器侧会一直
65
+ // 等一个已经在 Agent 层判定为超时的响应。
66
+ signal: opts.signal,
64
67
  });
65
68
  return await res.json();
66
69
  },
@@ -302,7 +302,7 @@ async function setupKeveBridge(on, config) {
302
302
  }
303
303
  return { ok: true, resultsPath, reportDataPath, cases: reportData?.summary?.total ?? undefined };
304
304
  };
305
- const handle = async (route, payload) => {
305
+ const handle = async (route, payload, signal) => {
306
306
  switch (route) {
307
307
  case '/echo':
308
308
  return { ok: true, echoed: payload, at: Date.now() };
@@ -355,6 +355,9 @@ async function setupKeveBridge(on, config) {
355
355
  method: String(payload.method || 'POST'),
356
356
  headers: reqHeaders,
357
357
  body: typeof payload.body === 'string' ? payload.body : JSON.stringify(payload.body ?? {}),
358
+ // 浏览器侧 goal 超时 / 主动中止后,Node 侧必须同步停止在途 LLM 请求,
359
+ // 否则桥会一直挂着一个上游连接,子进程无法及时收敛。
360
+ signal,
358
361
  });
359
362
  const body = await resp.text();
360
363
  const headers = {};
@@ -454,6 +457,17 @@ async function setupKeveBridge(on, config) {
454
457
  const chunks = [];
455
458
  req.on('data', (c) => chunks.push(c));
456
459
  req.on('end', async () => {
460
+ const controller = new AbortController();
461
+ const llmTimeoutMs = Number(process.env.KEVE_LLM_REQUEST_TIMEOUT_MS) > 0
462
+ ? Number(process.env.KEVE_LLM_REQUEST_TIMEOUT_MS)
463
+ : 300_000;
464
+ const timeout = setTimeout(() => controller.abort(), llmTimeoutMs);
465
+ // 浏览器侧 fetch 被 AbortSignal 中止时会断开连接,这里同步中止下游请求。
466
+ req.on('aborted', () => controller.abort());
467
+ res.on('close', () => {
468
+ if (!res.writableEnded)
469
+ controller.abort();
470
+ });
457
471
  const route = String(req.url || '').split('?')[0];
458
472
  let payload = {};
459
473
  try {
@@ -465,16 +479,23 @@ async function setupKeveBridge(on, config) {
465
479
  }
466
480
  let out;
467
481
  try {
468
- out = await handle(route, payload);
482
+ out = await handle(route, payload, controller.signal);
469
483
  }
470
484
  catch (err) {
471
485
  out = { ok: false, error: err?.message || String(err) };
472
486
  }
473
- res.writeHead(200, {
474
- 'content-type': 'application/json',
475
- ...corsHeaders,
476
- });
477
- res.end(JSON.stringify(out));
487
+ finally {
488
+ clearTimeout(timeout);
489
+ }
490
+ // 客户端在等待期间主动断开(goal 超时 / 用例中止)时响应流可能已销毁,
491
+ // 此时再写回会抛 ERR_STREAM_WRITE_AFTER_END,把一个正常的中止变成噪声错误。
492
+ if (!res.writableEnded && !res.destroyed) {
493
+ res.writeHead(200, {
494
+ 'content-type': 'application/json',
495
+ ...corsHeaders,
496
+ });
497
+ res.end(JSON.stringify(out));
498
+ }
478
499
  });
479
500
  });
480
501
  await new Promise((resolve, reject) => {
@@ -125,7 +125,7 @@ function readBrowserEnv(name) {
125
125
  * - 读初始截图 → `/fs/read-binary`
126
126
  * - LLM 调用 → `/llm/chat`(apiKey 不出 Node)
127
127
  */
128
- function createCyAgentHost(bridge) {
128
+ function createCyAgentHost(bridge, signal) {
129
129
  const targetUrl = readBrowserEnv('KEVE_TARGET_URL') || resolveBaseUrl();
130
130
  const getEnv = (name) => {
131
131
  const direct = readBrowserEnv(name);
@@ -147,7 +147,7 @@ function createCyAgentHost(bridge) {
147
147
  const resp = await bridge.post('/shot/save', {
148
148
  name: `agent-step${stepIndex}-${safeName}`,
149
149
  pngBase64,
150
- });
150
+ }, { signal });
151
151
  return resp?.ok ? String(resp.path || '') || undefined : undefined;
152
152
  };
153
153
  const loadInitialImages = async (paths) => {
@@ -159,7 +159,7 @@ function createCyAgentHost(bridge) {
159
159
  if (!rel)
160
160
  continue;
161
161
  try {
162
- const resp = await bridge.post('/fs/read-binary', { file: rel });
162
+ const resp = await bridge.post('/fs/read-binary', { file: rel }, { signal });
163
163
  if (!resp?.ok || !resp.base64)
164
164
  continue;
165
165
  items.push({ type: 'text', text: `Screenshot (${label}):` });
@@ -186,7 +186,7 @@ function createCyAgentHost(bridge) {
186
186
  method: String(init?.method || 'POST'),
187
187
  headers,
188
188
  body: typeof init?.body === 'string' ? init.body : JSON.stringify(init?.body ?? {}),
189
- });
189
+ }, { signal });
190
190
  if (!resp?.ok)
191
191
  throw new Error(resp?.error || 'llm/chat 桥调用失败');
192
192
  return new Response(String(resp.body ?? ''), {
@@ -215,11 +215,11 @@ function createCyAgentHost(bridge) {
215
215
  */
216
216
  function installGoalExplorer(engine, bridge) {
217
217
  const cdp = engine.rawPage();
218
- const host = createCyAgentHost(bridge);
219
218
  (0, keveGoal_js_1.setCyGoalExplorer)(async (options, ctx) => {
220
219
  const page = new agent_page_js_1.CyAgentPage(cdp);
221
220
  const controller = new AbortController();
222
221
  const timer = setTimeout(() => controller.abort(), CY_GOAL_TIMEOUT_MS);
222
+ const host = createCyAgentHost(bridge, controller.signal);
223
223
  try {
224
224
  const result = await (0, agent_js_1.reactLoop)(page, options.step, options.expected, {
225
225
  specFilePath: ctx.specFilePath,
@@ -17,6 +17,16 @@ export interface KeveGoalCallOptions {
17
17
  precondition?: string;
18
18
  step: string;
19
19
  expected: string;
20
+ /**
21
+ * 是否在 fn 之后追加一轮 Agent Re-Act 做最终判定。
22
+ *
23
+ * 默认 true(AI 场景 / 手写用例):fn 只是前置动作与确定性校验,预期是否真正
24
+ * 达成由 Agent 观察真实页面后判定。
25
+ *
26
+ * DSL 投影脚本会显式传 false:整条链路的判定已由确定性断言给出,Re-Act 只会
27
+ * 额外消耗 LLM 预算,并让「报错1」这类平台标签参与结论。
28
+ */
29
+ react?: boolean;
20
30
  }
21
31
  /** agent Re-Act 的结果(P0-3 接入;未接入时由 fn 结果直接判定) */
22
32
  export interface CyGoalExploreResult {
@@ -75,6 +75,7 @@ function createCyKeveGoal(engine) {
75
75
  let fnErrorRaw;
76
76
  let fnResult;
77
77
  let fnBlocked = false;
78
+ const reactEnabled = options.react !== false;
78
79
  const fnSource = fn?.toString() || undefined;
79
80
  const owned = !(0, shotBuffer_js_1.isGoalShotDiffCaptureActive)();
80
81
  if (owned)
@@ -139,6 +140,26 @@ function createCyKeveGoal(engine) {
139
140
  };
140
141
  console.log(`[keveGoal] 📊 "${options.step}" 截图差异超阈值 — 确定性失败`);
141
142
  }
143
+ else if (!reactEnabled) {
144
+ // 确定性模式:断言是否通过就是结论,不再发起 LLM 探索。
145
+ reactResult = {
146
+ expectedMet: !fnError,
147
+ conclusion: fnError ? 'fail' : 'pass',
148
+ actions: [{
149
+ action: {
150
+ tool: 'done',
151
+ verdict: fnError ? 'fail' : 'pass',
152
+ text: fnError ? `Assertion failed: ${fnError}` : '确定性断言全部通过',
153
+ },
154
+ toolOutput: '',
155
+ result: 'ok',
156
+ }],
157
+ agentScreenshots: [],
158
+ };
159
+ logLine(fnError
160
+ ? `[keveGoal] ⚙️ "${options.step}" 确定性模式:fn 失败,跳过 Agent Re-Act`
161
+ : `[keveGoal] ⚙️ "${options.step}" 确定性模式:fn 通过,跳过 Agent Re-Act`);
162
+ }
142
163
  else {
143
164
  // fn 成功 / 失败都交给探索器(若引擎入口注册了 AI Re-Act)。
144
165
  // fn 只是前置确定性校验,@keveGoal 的预期是否真正达成必须由 Agent
@@ -32,6 +32,13 @@ export interface KeveGoalCallOptions {
32
32
  precondition?: string;
33
33
  step: string;
34
34
  expected: string;
35
+ /**
36
+ * 是否在 fn 之后追加一轮 Agent Re-Act 做最终判定。
37
+ *
38
+ * 默认 true(AI 场景 / 手写用例);DSL 投影脚本传 false,判定完全由确定性
39
+ * 断言给出,避免为每个步骤额外消耗一轮 LLM。
40
+ */
41
+ react?: boolean;
35
42
  }
36
43
  type KeveFixture = {
37
44
  keveGoal: (options: KeveGoalCallOptions, fn?: () => Promise<void>) => Promise<void>;
@@ -189,6 +189,7 @@ exports.test = test_1.test.extend({
189
189
  let fnErrorRaw; // 原始错误对象(ScreenshotDiffError 等带结构的错误需要保留结构)
190
190
  let fnResult; // fn 返回值透传(flow 产物,如 createDashboard 的看板名)
191
191
  let fnBlocked = false; // true when fn error is environment-blocked (skip agent Re-Act)
192
+ const reactEnabled = options.react !== false;
192
193
  // 截图对比:fn 前开缓冲、fn 后收集 —— engine.expectScreenshot 的结果经此进 attachment
193
194
  (0, screenshotDiff_js_1.beginGoalShotDiffCapture)();
194
195
  if (fn) {
@@ -297,6 +298,27 @@ exports.test = test_1.test.extend({
297
298
  conclusion: 'fail',
298
299
  };
299
300
  }
301
+ else if (!reactEnabled) {
302
+ // 确定性模式(DSL 投影脚本):断言结果即结论,不再发起 LLM 探索。
303
+ console.log(fnError
304
+ ? `[keveGoal] ⚙️ "${options.step}" deterministic mode: fn failed — skipping agent Re-Act`
305
+ : `[keveGoal] ⚙️ "${options.step}" deterministic mode: fn passed — skipping agent Re-Act`);
306
+ reactResult = {
307
+ expectedMet: !fnError,
308
+ actions: [{
309
+ action: {
310
+ tool: 'done',
311
+ verdict: fnError ? 'fail' : 'pass',
312
+ text: fnError ? `Assertion failed: ${fnError}` : '确定性断言全部通过',
313
+ },
314
+ toolOutput: '',
315
+ result: 'ok',
316
+ }],
317
+ finalSnapshot: '',
318
+ agentScreenshots: [],
319
+ conclusion: fnError ? 'fail' : 'pass',
320
+ };
321
+ }
300
322
  else {
301
323
  // ── Per-goal timeout: prevent one slow goal from exhausting the test timeout ──
302
324
  // 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.18",
3
+ "version": "0.0.19",
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": {