@kkeve/suite 0.0.18 → 0.0.20

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.
@@ -9,7 +9,7 @@
9
9
  * 分层:DB 装载 / 内嵌场景展开 / 元素库 source 跳解析 / 变量表查询全部留在
10
10
  * 调用方(见 eve-backend 的 dslLoader)。这里只做纯函数投影。
11
11
  */
12
- export { toDecorator, type DslHop, type DslValue, type DslExpectation, type DslStep, type DslVariableRow, type ToDecoratorOptions, } from './toDecorator.js';
12
+ export { toDecorator, stepExpectedText, type DslHop, type DslValue, type DslExpectation, type DslStep, type DslVariableRow, type ExpectedRenderContext, type ToDecoratorOptions, } from './toDecorator.js';
13
13
  /** 语义别名:调用方按「生成一份装饰器 spec」的语义引用时更直观 */
14
14
  export { toDecorator as generateDecoratorSpec } from './toDecorator.js';
15
15
  export { STEP_OPERATIONS, ASSERT_OPERATIONS, TEXT_ONLY_ASSERT_OPERATIONS, EXPECTED_VALUE_CTYPES, COMPARISON_EXPRESSIONS, isKnownOperation, isKnownAssert, isTextOnlyAssert, parseComparison, type StepOperation, } from './ops.js';
package/dist/dsl/index.js CHANGED
@@ -11,9 +11,10 @@
11
11
  * 调用方(见 eve-backend 的 dslLoader)。这里只做纯函数投影。
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.parseComparison = exports.isTextOnlyAssert = exports.isKnownAssert = exports.isKnownOperation = exports.COMPARISON_EXPRESSIONS = exports.EXPECTED_VALUE_CTYPES = exports.TEXT_ONLY_ASSERT_OPERATIONS = exports.ASSERT_OPERATIONS = exports.STEP_OPERATIONS = exports.generateDecoratorSpec = exports.toDecorator = void 0;
14
+ exports.parseComparison = exports.isTextOnlyAssert = exports.isKnownAssert = exports.isKnownOperation = exports.COMPARISON_EXPRESSIONS = exports.EXPECTED_VALUE_CTYPES = exports.TEXT_ONLY_ASSERT_OPERATIONS = exports.ASSERT_OPERATIONS = exports.STEP_OPERATIONS = exports.generateDecoratorSpec = exports.stepExpectedText = exports.toDecorator = void 0;
15
15
  var toDecorator_js_1 = require("./toDecorator.js");
16
16
  Object.defineProperty(exports, "toDecorator", { enumerable: true, get: function () { return toDecorator_js_1.toDecorator; } });
17
+ Object.defineProperty(exports, "stepExpectedText", { enumerable: true, get: function () { return toDecorator_js_1.stepExpectedText; } });
17
18
  /** 语义别名:调用方按「生成一份装饰器 spec」的语义引用时更直观 */
18
19
  var toDecorator_js_2 = require("./toDecorator.js");
19
20
  Object.defineProperty(exports, "generateDecoratorSpec", { enumerable: true, get: function () { return toDecorator_js_2.toDecorator; } });
@@ -96,3 +96,20 @@ export interface ToDecoratorOptions {
96
96
  }
97
97
  /** 生成装饰器脚本源码(UTF-8 文本) */
98
98
  export declare function toDecorator(options: ToDecoratorOptions): string;
99
+ /** 计算 expected 文案所需的最小上下文(与脚本投影共用,保证报告与脚本同文案) */
100
+ export interface ExpectedRenderContext {
101
+ env: string;
102
+ variableList?: DslVariableRow[];
103
+ account?: {
104
+ name: string;
105
+ password: string;
106
+ };
107
+ }
108
+ /**
109
+ * 步骤 expected 文案的**公开**入口。
110
+ *
111
+ * 投影脚本(toDecorator)与报告 outline(run-dsl-task / report-data)必须产出
112
+ * 同一份文案,否则会出现「报告的预期」与「评测 Agent 看到的预期」不一致 ——
113
+ * 那正是「报错4」这类标签被当成字面量去页面搜索的根因。调用方统一走这里。
114
+ */
115
+ export declare function stepExpectedText(step: DslStep, ctx: ExpectedRenderContext): string;
@@ -21,6 +21,7 @@
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.toDecorator = toDecorator;
24
+ exports.stepExpectedText = stepExpectedText;
24
25
  const ops_js_1 = require("./ops.js");
25
26
  // ─── 生成入口 ────────────────────────────────────────────────────────
26
27
  /** 生成装饰器脚本源码(UTF-8 文本) */
@@ -96,8 +97,7 @@ function renderStep(d, ctx) {
96
97
  }
97
98
  const out = [];
98
99
  const stepText = String(d.text || op);
99
- const expTexts = (d.expectation || []).map((e) => String(e.text || '')).filter(Boolean);
100
- const expected = expTexts.length ? expTexts.join(';') : '操作完成,页面正常响应';
100
+ const expected = expectedTextOf(d.expectation || [], (e) => resolveExpectationValue(e, ctx));
101
101
  const chain = chainExpr(d.context);
102
102
  const expLines = [];
103
103
  for (const e of d.expectation || []) {
@@ -275,7 +275,8 @@ function renderOpenPageWithGates(d, gates, ctx) {
275
275
  const stepText = String(d.text || 'OPEN_PAGE');
276
276
  const gateTexts = gates.map((g) => String(g.text || '')).filter(Boolean);
277
277
  const gateExpects = gates
278
- .flatMap((g) => (g.expectation || []).map((e) => String(e.text || '')))
278
+ .filter((g) => (g.expectation || []).length > 0)
279
+ .map((g) => expectedTextOf(g.expectation || [], (e) => resolveExpectationValue(e, ctx)))
279
280
  .filter(Boolean);
280
281
  const expected = [...gateTexts, ...gateExpects].filter(Boolean).join(';') || '打开目标页面并等待就绪响应';
281
282
  const resolved = resolveOpenPage(d, ctx);
@@ -305,8 +306,137 @@ function renderOpenPageWithGates(d, gates, ctx) {
305
306
  out.push(`});`);
306
307
  return out;
307
308
  }
308
- // ─── 断言转译 ────────────────────────────────────────────────────────
309
- /** 渲染一条 expectation;返回 null 表示「纯文案载体,不产生断言」 */
309
+ /**
310
+ * 步骤 expected 文案的**公开**入口。
311
+ *
312
+ * 投影脚本(toDecorator)与报告 outline(run-dsl-task / report-data)必须产出
313
+ * 同一份文案,否则会出现「报告的预期」与「评测 Agent 看到的预期」不一致 ——
314
+ * 那正是「报错4」这类标签被当成字面量去页面搜索的根因。调用方统一走这里。
315
+ */
316
+ function stepExpectedText(step, ctx) {
317
+ const renderCtx = {
318
+ env: ctx.env,
319
+ variableList: ctx.variableList || [],
320
+ account: ctx.account,
321
+ };
322
+ return expectedTextOf(step.expectation || [], (e) => resolveExpectationValue(e, renderCtx));
323
+ }
324
+ /**
325
+ * 步骤的 expected 文案 —— 喂给评测 Agent 判断「预期是否真的达成」。
326
+ *
327
+ * 平台 expectation[].text 常常只是录制期标签(「报错1」「报错4」),不携带
328
+ * 任何可判定语义;真实预期藏在同一条 expectation 的 type + value + context 里
329
+ * (如 ELEMENT_NOT_EXIST + includes「业务逻辑错误」= 页面不应出现该错误)。
330
+ * 只把标签交给评测 Agent,它会去页面上找字面量「报错4」并据此误判失败。
331
+ *
332
+ * 因此按「可执行断言语义优先、标签作为备注」组装:
333
+ * - 有可执行断言 → 渲染成人话(页面不存在包含「业务逻辑错误」的内容);
334
+ * - 纯文案载体(TEXT / 无 type)→ 标签本身就是人话,直接采用;
335
+ * - 一个断言都渲染不出来时,退回平台标签,最后兜底为通用文案。
336
+ */
337
+ function expectedTextOf(expectations, resolveValue) {
338
+ const semantic = [];
339
+ const labelOnly = [];
340
+ for (const e of expectations) {
341
+ const label = String(e.text || '').trim();
342
+ const type = String(e.type || '');
343
+ if (!type || (0, ops_js_1.isTextOnlyAssert)(type)) {
344
+ if (label)
345
+ labelOnly.push(label);
346
+ continue;
347
+ }
348
+ const phrase = describeExpectation(e, resolveValue(e));
349
+ // 未知断言在 renderExpectation 里会确定性报错;这里不能让 expected 抢先崩,
350
+ // 退化成标签即可,真正的失败由断言渲染抛出。
351
+ if (phrase)
352
+ semantic.push(label && !phrase.includes(label) ? `${phrase}(${label})` : phrase);
353
+ else if (label)
354
+ labelOnly.push(label);
355
+ }
356
+ const parts = [...semantic, ...labelOnly].filter(Boolean);
357
+ return parts.length ? parts.join(';') : '操作完成,页面正常响应';
358
+ }
359
+ /** 单条可执行断言 → 人类可读预期;返回 null 表示无法给出语义(由调用方兜底) */
360
+ function describeExpectation(e, val) {
361
+ const type = String(e.type || '');
362
+ const target = describeChain(e.context);
363
+ const value = String(val ?? '');
364
+ switch (type) {
365
+ case 'ELEMENT_EXIST':
366
+ return `页面存在${target}`;
367
+ case 'ELEMENT_NOT_EXIST':
368
+ return `页面不存在${target}`;
369
+ case 'ELEMENT_VISIBLE':
370
+ return `${target}可见`;
371
+ case 'ELEMENT_NOT_VISIBLE':
372
+ return `${target}不可见`;
373
+ case 'TEXT_EXIST':
374
+ if (isPageTitleAssertion(e))
375
+ return `页面标题包含「${value}」`;
376
+ return `页面存在包含「${value}」的内容${target}`;
377
+ case 'TEXT_NOT_EXIST':
378
+ if (!value.trim())
379
+ return null;
380
+ return `页面不存在包含「${value}」的内容${target}`;
381
+ case 'TEXT_EQUAL':
382
+ return `元素${target}文本等于「${value}」`;
383
+ case 'EXPECTED_VALUE': {
384
+ const ctype = String(e.ctype || 'text').toLowerCase();
385
+ const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'include'));
386
+ const dim = { text: '文本', class: '类名', placeholder: '占位提示', style: '样式', index: '元素个数' }[ctype] || ctype;
387
+ if (ctype === 'index')
388
+ return `元素${target}个数等于 ${value}`;
389
+ const op = mode === 'eq' ? (negated ? '不等于' : '等于') : (negated ? '不包含' : '包含');
390
+ return `元素${target}的${dim}${op}「${value}」`;
391
+ }
392
+ case 'EXPECTED__PAGET_VALUE': {
393
+ const ctype = String(e.ctype || 'url').toLowerCase();
394
+ const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'eq'));
395
+ const dim = ctype === 'title' ? '标题' : '地址';
396
+ const op = mode === 'eq' ? (negated ? '不等于' : '等于') : (negated ? '不包含' : '包含');
397
+ return `页面${dim}${op}「${value}」`;
398
+ }
399
+ case 'SCREEN_SHOT_COMPARE':
400
+ case 'SCREENSHOT_COMPARE':
401
+ case 'IMAGE_COMPARE':
402
+ return `页面截图与基线「${value || e.text || ''}」一致`;
403
+ default:
404
+ return null;
405
+ }
406
+ }
407
+ /** 定位链 → 人类可读描述(评测 Agent 据此知道「在哪个区域」观察) */
408
+ function describeChain(context) {
409
+ let hops;
410
+ try {
411
+ hops = collectHops(context);
412
+ }
413
+ catch {
414
+ return '';
415
+ }
416
+ if (!hops.length)
417
+ return '';
418
+ // 单跳文本包含:平台最常见的「页面不应出现某文案」录制形态
419
+ if (hops.length === 1 && (hops[0].type === 'includes' || hops[0].type === 'text')) {
420
+ return `包含「${hops[0].value.trim()}」的元素`;
421
+ }
422
+ const parts = hops.map((h) => {
423
+ const text = h.value.trim();
424
+ if (h.type === 'includes' || h.type === 'text')
425
+ return `包含「${text}」`;
426
+ if (h.type === 'selector')
427
+ return `选择器「${text}」`;
428
+ return `${h.type}=「${text}」`;
429
+ });
430
+ return `(${parts.join(' → ')})`;
431
+ }
432
+ /**
433
+ * 渲染一条 expectation 为 engine 断言;返回 null 表示「纯文案载体,不产生断言」。
434
+ *
435
+ * 注意投影脚本**不输出 `react: false`**:可执行断言只证明预期的一个子集,
436
+ * fn 执行成功不等于 expected 已达成,所以每个 goal 都保留评测阶段 —— Agent
437
+ * 先观察真实页面评测,达成即放行,未达成再继续探索;断言失败由引擎侧统一
438
+ * 参与终局覆盖,不允许被评审判成 pass。
439
+ */
310
440
  function renderExpectation(e, ctx) {
311
441
  const type = String(e.type || '');
312
442
  // 无 type / TEXT:纯「期望文案」载体,只喂 keveGoal.expected,不是断言
@@ -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,
@@ -140,10 +140,11 @@ function createCyKeveGoal(engine) {
140
140
  console.log(`[keveGoal] 📊 "${options.step}" 截图差异超阈值 — 确定性失败`);
141
141
  }
142
142
  else {
143
- // fn 成功 / 失败都交给探索器(若引擎入口注册了 AI Re-Act)。
144
- // fn 只是前置确定性校验,@keveGoal 的预期是否真正达成必须由 Agent
145
- // 观察真实页面后给出最终结论 —— 即便 fn 已通过,也要产出推理轨迹与
146
- // 执行后截图,否则报告里会缺少 AI 推理日志。
143
+ // fn 成功 / 失败都交给评测器。fn 只是前置动作与确定性校验:
144
+ // - fn 成功 ≠ expected 达成(fn 可能只覆盖预期的一个子集);
145
+ // - fn 失败 ≠ 不可恢复(普通运行时错误下页面可能仍可探索自愈)。
146
+ // 「先评测、再决定放行还是探索」由这一轮承担:Agent 先观察真实页面评测
147
+ // expected,达成即 done(pass) 放行,未达成再继续探索。
147
148
  let explored;
148
149
  if (explorer) {
149
150
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kkeve/suite",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
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": {
@@ -134,8 +134,12 @@ function normalizeStep(step) {
134
134
  * 投影 DSL 步骤为报告 outline,语义与 toDecorator 的 keveGoal 参数保持一致。
135
135
  * OPEN_PAGE 后紧跟的 WAIT_RESPONSE 会合并进同一个导航步骤,避免报告步骤数
136
136
  * 与脚本实际 keveGoal 数不一致。
137
+ *
138
+ * expected 文案统一走 stepExpectedText(与脚本投影同一个函数):报告里展示的
139
+ * 预期与评测 Agent 读到的预期必须逐字一致,否则平台标签(「报错4」)会在报告
140
+ * 与评测两侧产生不同解读。
137
141
  */
138
- function buildCaseSteps(dslList) {
142
+ function buildCaseSteps(dslList, ctx, stepExpectedText) {
139
143
  const steps = [];
140
144
  for (let i = 0; i < dslList.length; i++) {
141
145
  const d = dslList[i];
@@ -149,7 +153,8 @@ function buildCaseSteps(dslList) {
149
153
  if (gates.length) {
150
154
  const gateTexts = gates.map((g) => String(g?.text || '')).filter(Boolean);
151
155
  const gateExpects = gates
152
- .flatMap((g) => (g?.expectation || []).map((e) => String(e?.text || '')))
156
+ .filter((g) => (g?.expectation || []).length > 0)
157
+ .map((g) => stepExpectedText(g, ctx))
153
158
  .filter(Boolean);
154
159
  steps.push({
155
160
  step: String(d?.text || 'OPEN_PAGE'),
@@ -160,8 +165,7 @@ function buildCaseSteps(dslList) {
160
165
  }
161
166
  }
162
167
  const stepText = String(d?.text || op || '');
163
- const expTexts = (d?.expectation || []).map((e) => String(e?.text || '')).filter(Boolean);
164
- const expected = expTexts.length ? expTexts.join(';') : '操作完成,页面正常响应';
168
+ const expected = stepExpectedText(d, ctx);
165
169
  if (!stepText) continue;
166
170
  steps.push({ step: stepText, expected });
167
171
  }
@@ -217,7 +221,7 @@ async function main() {
217
221
  fs.rmSync(resultFile, { force: true });
218
222
  fs.rmSync(manifestFile, { force: true });
219
223
 
220
- const { toDecorator } = await import(
224
+ const { toDecorator, stepExpectedText } = await import(
221
225
  url.pathToFileURL(path.join(suiteRoot, 'dist/dsl/index.js')).href
222
226
  );
223
227
 
@@ -233,20 +237,26 @@ async function main() {
233
237
  const importFrom = engine === 'cypress'
234
238
  ? '@kkeve/suite/engine-cypress'
235
239
  : '@kkeve/suite/keve-test';
240
+ const account = resolveAccount(item.accountInfo || input.accountInfo);
241
+ const renderCtx = {
242
+ env: input.env || 'test',
243
+ variableList: Array.isArray(item.variableList) ? item.variableList : [],
244
+ account,
245
+ };
236
246
  const code = toDecorator({
237
247
  stepGroupId,
238
248
  caseName: item.caseName || `用例 ${stepGroupId}`,
239
249
  dslList,
240
- env: input.env || 'test',
241
- variableList: Array.isArray(item.variableList) ? item.variableList : [],
242
- account: resolveAccount(item.accountInfo || input.accountInfo),
250
+ env: renderCtx.env,
251
+ variableList: renderCtx.variableList,
252
+ account: renderCtx.account,
243
253
  ssoInject: input.ssoInject !== false,
244
254
  importFrom,
245
255
  });
246
256
 
247
257
  const fileName = `sg${stepGroupId}.spec.ts`;
248
258
  fs.writeFileSync(path.join(specDir, fileName), code, 'utf-8');
249
- const dslSteps = buildCaseSteps(dslList);
259
+ const dslSteps = buildCaseSteps(dslList, renderCtx, stepExpectedText);
250
260
  caseEntries.push({
251
261
  caseId: `S${stepGroupId}`,
252
262
  stepGroupId,