@kkeve/suite 0.0.17 → 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.17",
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": {
@@ -406,23 +406,68 @@ function readReportData(resultDir) {
406
406
 
407
407
  /**
408
408
  * 目标地址解析:优先使用用例 OPEN_PAGE 中的真实业务地址。
409
- * 只有 DSL 里的地址是本机占位地址,或 DSL 没有可用地址时,才使用
410
- * 调度入参 / 环境映射,避免 env=rc 覆盖用例明确声明的目标站点。
409
+ * 只有 DSL 里的地址是本机占位地址,或 DSL 没有可用地址时,才使用调度入参。
410
+ * 不做任何环境映射兜底——用例指定什么页面就访问什么页面,不私自替换为平台域名。
411
+ *
412
+ * 支持三种 OPEN_PAGE value 格式:
413
+ * 1. 字符串:直接用
414
+ * 2. { url } / { value }:取对应字段
415
+ * 3. { type: 'VARIABLE', value: varId, variable: [{key,value}] }:
416
+ * 从 variableList 查出对应环境的 URL,再应用 step.variable 改写
411
417
  */
412
418
  function deriveTargetUrl(input, cases) {
419
+ const env = String(input?.env || 'test');
413
420
  for (const item of cases) {
421
+ const variableList = Array.isArray(item.variableList) ? item.variableList : [];
414
422
  for (const raw of Array.isArray(item?.dslList) ? item.dslList : []) {
415
423
  const step = normalizeStep(raw);
416
424
  if (String(step?.operation || '') !== 'OPEN_PAGE') continue;
417
425
  const value = step.value;
418
- const candidate = typeof value === 'string'
419
- ? value
420
- : value?.url || value?.value || '';
421
- const candidateUrl = String(candidate || '').trim();
422
- if (!/^https?:\/\//i.test(candidateUrl)) continue;
423
- if (isLocalPlaceholderUrl(candidateUrl)) {
424
- continue;
426
+ let candidateUrl = '';
427
+ if (typeof value === 'string') {
428
+ candidateUrl = value.trim();
429
+ } else if (value && typeof value === 'object') {
430
+ if (value.type === 'VARIABLE' && variableList.length) {
431
+ // 变量库引用:按 id 查找变量行,取对应环境列
432
+ const varId = value.value;
433
+ const row = variableList.find((r) => String(r.id) === String(varId));
434
+ if (row) {
435
+ const cols = [env, 'test', 'online', 'rc', 'pre'];
436
+ const isUrl = (x) => typeof x === 'string' && /^https?:\/\//i.test(x.trim());
437
+ const isPlaceholder = (x) => /^(1|0|true|false)$/i.test(String(x ?? '').trim());
438
+ let base = '';
439
+ for (const c of cols) { if (isUrl(row[c])) { base = row[c]; break; } }
440
+ if (!base) {
441
+ for (const c of cols) {
442
+ const v = row[c];
443
+ if (typeof v === 'string' && v.trim() && !isPlaceholder(v)) { base = v; break; }
444
+ }
445
+ }
446
+ if (base) {
447
+ // 应用 step.variable 改写(与 buildVariableUrl 同逻辑)
448
+ const kv = new Map();
449
+ for (const item of row.variable || []) {
450
+ if (item && item.key !== undefined) kv.set(String(item.key), String(item.value ?? ''));
451
+ }
452
+ for (const item of value.variable || []) {
453
+ if (item && item.key !== undefined) kv.set(String(item.key), String(item.value ?? ''));
454
+ }
455
+ let url = String(base).replace(/\$\{([^}]+)}/g, (m, k) => kv.has(k) ? kv.get(k) : m);
456
+ for (const [k, v] of kv) {
457
+ if (!v || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(k)) continue;
458
+ const re = new RegExp(`([?&])${k}=[^&#]*`);
459
+ if (re.test(url)) url = url.replace(re, `$1${k}=${v}`);
460
+ else url += (url.includes('?') ? '&' : '?') + `${k}=${v}`;
461
+ }
462
+ candidateUrl = url.trim();
463
+ }
464
+ }
465
+ } else {
466
+ candidateUrl = String(value?.url || value?.value || '').trim();
467
+ }
425
468
  }
469
+ if (!/^https?:\/\//i.test(candidateUrl)) continue;
470
+ if (isLocalPlaceholderUrl(candidateUrl)) continue;
426
471
  return candidateUrl;
427
472
  }
428
473
  }
@@ -431,8 +476,8 @@ function deriveTargetUrl(input, cases) {
431
476
  if (/^https?:\/\//i.test(explicit)) {
432
477
  return explicit;
433
478
  }
434
- const envTarget = resolveEnvTargetUrl(input?.env);
435
- if (envTarget) return envTarget;
479
+ // 无法从用例和调度参数中取到有效地址,不做环境映射兜底,返回空串。
480
+ // cypress.config 中 baseUrl 留空时 Cypress 不会自行 visit,避免访问到平台自身域名。
436
481
  return '';
437
482
  }
438
483
 
@@ -445,17 +490,6 @@ function isLocalPlaceholderUrl(value) {
445
490
  }
446
491
  }
447
492
 
448
- function resolveEnvTargetUrl(env) {
449
- const normalized = String(env || '').trim().toLowerCase();
450
- return {
451
- online: 'https://keve.corp.kuaishou.com',
452
- pre: 'https://keve.corp.kuaishou.com',
453
- rc: 'https://rc-eve.corp.kuaishou.com',
454
- test: 'http://127.0.0.1:8080',
455
- local: 'http://127.0.0.1:8080',
456
- }[normalized] || '';
457
- }
458
-
459
493
  /**
460
494
  * 把 DSL 中指向本机的 OPEN_PAGE 地址投影为真实执行目标地址。
461
495
  *
@@ -600,7 +634,6 @@ function buildExecEnv({ input, taskDir, round, engine }) {
600
634
  export {
601
635
  deriveTargetUrl,
602
636
  isLocalPlaceholderUrl,
603
- resolveEnvTargetUrl,
604
637
  rewriteOpenPageTargets,
605
638
  };
606
639