@kkeve/suite 0.0.22 → 0.0.24

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.
@@ -27,10 +27,13 @@ const ops_js_1 = require("./ops.js");
27
27
  /** 生成装饰器脚本源码(UTF-8 文本) */
28
28
  function toDecorator(options) {
29
29
  const { stepGroupId, caseName, dslList, env, account } = options;
30
+ // 先扫一遍 WRITE_GLOBAL,收集本场景声明的运行时变量名(合法标识符才提升)
31
+ const runtimeVars = collectWriteGlobalVars(dslList);
30
32
  const ctx = {
31
33
  env,
32
34
  variableList: options.variableList || [],
33
35
  account,
36
+ runtimeVars,
34
37
  };
35
38
  const lines = [];
36
39
  const L = (s) => lines.push(s);
@@ -48,6 +51,13 @@ function toDecorator(options) {
48
51
  L(`export class Model_SG${stepGroupId} {`);
49
52
  L(` @keveScene('S${stepGroupId}', ${q(`${caseName}(源 DSL 转译)`)})`);
50
53
  L(` static async scene_SG${stepGroupId}({ engine, keveGoal }: any) {`);
54
+ if (runtimeVars.size) {
55
+ // WRITE_GLOBAL 变量提升:顶部声明、原位赋值。声明放在场景函数作用域,
56
+ // 保证跨步骤读写的顺序语义与「先写后读」一致,且同一变量只求值一次。
57
+ L(` // WRITE_GLOBAL 提升的运行时变量(原位赋值,见对应步骤)`);
58
+ for (const name of [...runtimeVars].sort())
59
+ L(` let ${name}: any;`);
60
+ }
51
61
  for (let i = 0; i < dslList.length; i++) {
52
62
  const d = dslList[i];
53
63
  const op = String(d?.operation || '');
@@ -97,16 +107,72 @@ function renderStep(d, ctx) {
97
107
  }
98
108
  const out = [];
99
109
  const stepText = String(d.text || op);
100
- const expected = expectedTextOf(d.expectation || [], (e) => resolveExpectationValue(e, ctx));
101
- const chain = chainExpr(d.context);
110
+ const chain = chainExpr(d.context, ctx);
102
111
  const expLines = [];
103
112
  for (const e of d.expectation || []) {
104
113
  const a = renderExpectation(e, ctx);
105
114
  if (a)
106
115
  expLines.push(a);
107
116
  }
117
+ // ── 短路判据:expected 是否已被 fn 内的可执行事实**完全**覆盖 ──
118
+ // 每条 expectation 渲染成功 ⇔ 它落下了一条可执行断言,此时 expected 只是这批
119
+ // 断言的转述,断言全过即 expected 达成。两种形态满足该等式:
120
+ // a) exps 与 expLines 都为空 —— 平台存下来的 expected 只是「操作完成,页面正常
121
+ // 响应」这句占位文案,不含可判定语义。让 Agent 去评测它,它既无判据可依,
122
+ // 又会顺着步骤文案继续往下做(实测会越界把后续步骤的动作一并执行,再撞上
123
+ // 步数上限,把一步已成功的动作判成失败)。
124
+ // b) 全部 expectation 都是可执行断言。
125
+ // 只要出现 TEXT / 无 type 的「纯文案期望」、或某条断言渲染成 null,等式不成立,
126
+ // expected 就携带了断言覆盖不到的人话语义,必须保留评测阶段交给 Agent,不能短路。
127
+ const exps = d.expectation || [];
128
+ // 注意:必须在补自动断言之前取值 —— 补断言会让 expLines 比 exps 多一条。
129
+ const expectedFullyCovered = expLines.length === exps.length;
130
+ // 页面级步骤例外:navigate / refresh 不抛错只证明「导航动作已下发」,不证明
131
+ // 「目标页面真的渲染出来了」—— 403、登录跳转、空白页同样是成功返回。
132
+ // 「页面正常响应」是一份 fn 无法自证的独立事实,必须保留 Agent 评测。
133
+ const pageLevelOp = op === 'OPEN_PAGE' || op === 'REDIRECT_TO' || op === 'REFRESH_PAGE';
134
+ // ── 输入类步骤的确定性补强 ──
135
+ // INPUT_ELEMENT 若没有显式断言,平台存下来的 expected 只是「操作完成,页面正常响应」,
136
+ // 对评测层没有任何约束:Agent 无法据此判断本步是否已完成,于是倾向于继续往下做,
137
+ // 越界填完整张表单后撞上步数上限,把一步已成功的动作判成失败。
138
+ // 这里由编译器补一条「输入框的值等于输入内容」的断言 —— 它是对该步骤最自然、
139
+ // 也最贴近事实的期望,使 expected 从一句空话变成可执行、可短路的确定性事实。
140
+ // 仅在完全没有 expectation 时补:作者写了 expectation(哪怕只是 TEXT 文案),
141
+ // 就说明本步有自己表达的后置条件,不叠加新语义去覆盖它。
142
+ let autoValueAssertion = '';
143
+ if (op === 'INPUT_ELEMENT' && exps.length === 0) {
144
+ // 输入值走 valueExpr:运行时变量生成裸表达式,字面量走 JSON 字符串。
145
+ // 未解析引用会在这里编译期抛错 —— 这正是本次假绿的直接来源:若输入值与
146
+ // 补断言的期望值是同一个未解析字面量,等式天然成立,用例必然假绿通过。
147
+ const inputValueExpr = valueExpr(d.value, ctx, 'INPUT_ELEMENT 输入值');
148
+ const inputChain = chainExprOrNull(d.context, ctx);
149
+ if (inputValueExpr && inputChain) {
150
+ autoValueAssertion = `await engine.expectValue(${inputValueExpr}, ${inputChain}); // 编译器补断言:输入框值等于输入内容`;
151
+ expLines.push(autoValueAssertion);
152
+ }
153
+ }
154
+ const expected = autoValueAssertion
155
+ ? `元素${describeChain(d.context)}的输入值等于「${describeValue(d.value, ctx)}」`
156
+ : expectedTextOf(d.expectation || [], (e) => resolveExpectationValue(e, ctx));
108
157
  const emit = (body) => {
109
- out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
158
+ // 短路条件:expected 已被 fn 完全覆盖,且 fn 里确实有可执行内容 ——
159
+ // body 与断言都为空说明该步骤既无动作也无校验,不能凭「没报错」判通过。
160
+ const executableLineCount = [...body, ...expLines].length;
161
+ // P1 纵深防御:断言里若残留未解析占位符,说明解析链路有漏(正常已在
162
+ // stringExpr 处编译期报错),一律不许短路 —— 自证式断言不能当作确定性事实。
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
+ });
174
+ const deterministic = expectedFullyCovered && !pageLevelOp && executableLineCount > 0 && !hasUnresolved;
175
+ out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)}${deterministic ? ', deterministic: true' : ''} }, async () => {`);
110
176
  for (const b of [...body, ...expLines])
111
177
  out.push(` ${b}`);
112
178
  out.push(`});`);
@@ -117,7 +183,7 @@ function renderStep(d, ctx) {
117
183
  const body = [];
118
184
  if (resolved.url) {
119
185
  body.push(`// 目标页面解析来源: ${resolved.via}`);
120
- body.push(`await engine.navigate(${q(resolved.url)});`);
186
+ body.push(`await engine.navigate(${codeLiteral(resolved.url, 'OPEN_PAGE 目标 URL')});`);
121
187
  }
122
188
  else {
123
189
  body.push(`throw new Error(${q(`OPEN_PAGE 无法解析有效 URL(步骤文案「${stepText}」,value=${JSON.stringify(d.value)})`
@@ -136,7 +202,7 @@ function renderStep(d, ctx) {
136
202
  if (!url) {
137
203
  throw new Error(`REDIRECT_TO 未解析到目标 URL: ${stepText}`);
138
204
  }
139
- emit([`await engine.navigate(${q(url)});`, ...waitExprs(d)]);
205
+ emit([`await engine.navigate(${codeLiteral(url, 'REDIRECT_TO 目标 URL')});`, ...waitExprs(d)]);
140
206
  break;
141
207
  }
142
208
  case 'CLICK_ELEMENT':
@@ -154,7 +220,7 @@ function renderStep(d, ctx) {
154
220
  // 少数步骤把待检文案直接放在 value:此时补一条包含断言,与老引擎
155
221
  // cy.contains(value).should('be.visible') 语义对齐。
156
222
  const val = resolveValue(d.value, ctx);
157
- emit(val ? [`await engine.expectText(${q(val)}, ${chain});`] : []);
223
+ emit(val ? [`await engine.expectText(${valueExpr(d.value, ctx, 'CHECK_TEXT 待检文案')}, ${chain});`] : []);
158
224
  break;
159
225
  }
160
226
  case 'INPUT_ELEMENT': {
@@ -162,12 +228,30 @@ function renderStep(d, ctx) {
162
228
  if (!val) {
163
229
  throw new Error(`INPUT_ELEMENT 缺少输入值,拒绝生成可误报通过的跳过步骤: ${stepText}`);
164
230
  }
165
- emit([`await engine.type(${q(val)}, ${chain});`, ...waitExprs(d)]);
231
+ emit([`await engine.type(${valueExpr(d.value, ctx, 'INPUT_ELEMENT 输入值')}, ${chain});`, ...waitExprs(d)]);
166
232
  break;
167
233
  }
168
- case 'MOVE_ELEMENT':
169
- emit([`await engine.hover(${chain});`, ...waitExprs(d)]);
234
+ case 'MOVE_ELEMENT': {
235
+ // 历史 DSL 把「hover 到某行后再点行内操作」建模成 children:
236
+ // MOVE_ELEMENT(context=行) + CLICK_ELEMENT(context=更多)。
237
+ // 只生成 hover 会漏掉动作,下一步「点删除」必然因下拉未展开而失败。
238
+ const childClicks = [];
239
+ for (const childAction of Array.isArray(d.children) ? d.children : []) {
240
+ const childOp = String(childAction?.operation || '');
241
+ const childHops = chainExprOrNull(childAction?.context, ctx);
242
+ if (!childHops)
243
+ continue;
244
+ if (childOp === 'CLICK_ELEMENT')
245
+ childClicks.push(`await engine.click(${childHops});`);
246
+ else if (childOp === 'FORCE_CLICK_ELEMENT')
247
+ childClicks.push(`await engine.forceClick(${childHops});`);
248
+ else if (childOp) {
249
+ throw new Error(`MOVE_ELEMENT 子动作「${childOp}」暂不支持: ${stepText}`);
250
+ }
251
+ }
252
+ emit([`await engine.hover(${chain});`, ...childClicks, ...waitExprs(d)]);
170
253
  break;
254
+ }
171
255
  case 'SCROLL':
172
256
  emit([`await engine.scroll(${chain});`, ...waitExprs(d)]);
173
257
  break;
@@ -179,7 +263,7 @@ function renderStep(d, ctx) {
179
263
  const sel = firstContextSelector(d.context);
180
264
  if (sel) {
181
265
  const budget = Math.round(waitMs(d) || 10000);
182
- emit([`await engine.waitForElement(${q(sel)}, { timeout: ${budget} });`]);
266
+ emit([`await engine.waitForElement(${codeLiteral(sel, 'WAIT_EVENTS 元素就绪门')}, { timeout: ${budget} });`]);
183
267
  break;
184
268
  }
185
269
  const ms = waitMs(d);
@@ -195,7 +279,7 @@ function renderStep(d, ctx) {
195
279
  throw new Error(`WAIT_RESPONSE 缺少目标 URL,拒绝生成可误报通过的跳过步骤: ${stepText}`);
196
280
  }
197
281
  const opts = waitResponseOptions(d);
198
- emit([`await engine.waitForResponse(${q(urlPart)}${opts});`]);
282
+ emit([`await engine.waitForResponse(${codeLiteral(urlPart, 'WAIT_RESPONSE 目标 URL')}${opts});`]);
199
283
  break;
200
284
  }
201
285
  case 'REFRESH_PAGE':
@@ -211,7 +295,7 @@ function renderStep(d, ctx) {
211
295
  : undefined);
212
296
  const targetCtx = valueCtx || d.child?.context || d.child;
213
297
  if (Array.isArray(targetCtx) && targetCtx.length) {
214
- emit([`await engine.drag(${chain}, ${chainExpr(targetCtx)});`, ...waitExprs(d)]);
298
+ emit([`await engine.drag(${chainExpr(normalizeDragSource(d.context), ctx)}, ${chainExpr(targetCtx, ctx)});`, ...waitExprs(d)]);
215
299
  }
216
300
  else {
217
301
  throw new Error(`DRAGGING 缺少目标元素,拒绝退化为悬停: ${stepText}`);
@@ -222,10 +306,17 @@ function renderStep(d, ctx) {
222
306
  // 真实数据里表达式写在 context 的 javascript 跳上,value 只是变量名
223
307
  const jsHop = firstHopOfType(d.context, 'javascript');
224
308
  const expr = jsHop ? String(jsHop.value ?? '') : resolveValue(d.value, ctx) || (typeof d.child === 'string' ? d.child : d.child?.value);
225
- if (expr)
226
- emit([`await engine.evaluate(${q(String(expr))});`]);
227
- else
309
+ if (!expr)
228
310
  throw new Error(`WRITE_GLOBAL 缺少表达式: ${stepText}`);
311
+ const varName = writeGlobalVarName(d, ctx);
312
+ // 变量提升后的写入:值必须落到场景局部变量上,后续 `${name}` 才有来源。
313
+ // 非合法标识符(变量名缺失/非法)时不提升,退化为只求值不落库 ——
314
+ // 这种情况下任何 `${name}` 引用会在解析处编译期报错,不会静默假绿。
315
+ emit([
316
+ varName
317
+ ? `${varName} = await engine.evaluate(${q(String(expr))});`
318
+ : `await engine.evaluate(${q(String(expr))});`,
319
+ ]);
229
320
  break;
230
321
  }
231
322
  case 'SCREEN_SHOT':
@@ -233,18 +324,19 @@ function renderStep(d, ctx) {
233
324
  case 'TAKE_SCREENSHOT': {
234
325
  // 平台截图步骤 → 基线对比断言(无基线建候选,超阈值抛 ScreenshotDiffError)
235
326
  const shotName = resolveValue(d.value, ctx) || sanitizeShotKey(stepText);
236
- const shotChain = chainExprOrNull(d.context);
327
+ const shotChain = chainExprOrNull(d.context, ctx);
237
328
  // 区域截图容错:元素不在当前页面(权限阻断等)时降级为整页截图,
238
329
  // 是否一致由基线比对决定,截图本身不做权限硬门。
239
330
  if (shotChain) {
240
331
  out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
241
332
  out.push(` try {`);
242
- out.push(` await engine.screenshot(${q(shotName)}, ${shotChain});`);
333
+ const shotKeyExpr = codeLiteral(shotName, 'SCREENSHOT 基线 key');
334
+ out.push(` await engine.screenshot(${shotKeyExpr}, ${shotChain});`);
243
335
  out.push(` } catch (_shotErr: any) {`);
244
336
  out.push(` // 区域截图失败(元素不在页面上,如权限阻断/加载异常)—— 降级为整页截图`);
245
337
  out.push(` if (/timeout|waiting.*locator|not found|no element/i.test(String(_shotErr?.message || ''))) {`);
246
- out.push(` console.warn('[dsl] 区域截图降级为整页截图:', ${q(shotName)}, _shotErr?.message);`);
247
- out.push(` await engine.screenshot(${q(shotName)});`);
338
+ out.push(` console.warn('[dsl] 区域截图降级为整页截图:', ${shotKeyExpr}, _shotErr?.message);`);
339
+ out.push(` await engine.screenshot(${shotKeyExpr});`);
248
340
  out.push(` } else { throw _shotErr; }`);
249
341
  out.push(` }`);
250
342
  for (const e of d.expectation || []) {
@@ -255,7 +347,7 @@ function renderStep(d, ctx) {
255
347
  out.push(`});`);
256
348
  }
257
349
  else {
258
- emit([`await engine.screenshot(${q(shotName)});`, ...waitExprs(d)]);
350
+ emit([`await engine.screenshot(${codeLiteral(shotName, 'SCREENSHOT 基线 key')});`, ...waitExprs(d)]);
259
351
  }
260
352
  break;
261
353
  }
@@ -295,10 +387,10 @@ function renderOpenPageWithGates(d, gates, ctx) {
295
387
  const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
296
388
  if (!urlPart)
297
389
  return; // 就绪门无目标 URL 时跳过注册,由普通 WAIT_RESPONSE 分支报错
298
- body.push(`const __gate${gi} = engine.waitForResponse(${q(urlPart)}${waitResponseOptions(g)}); // 预注册就绪门`);
390
+ body.push(`const __gate${gi} = engine.waitForResponse(${codeLiteral(urlPart, '就绪门目标 URL')}${waitResponseOptions(g)}); // 预注册就绪门`);
299
391
  });
300
392
  body.push(`// 目标页面解析来源: ${resolved.via}`);
301
- body.push(`await engine.navigate(${q(resolved.url)});`);
393
+ body.push(`await engine.navigate(${codeLiteral(resolved.url, 'OPEN_PAGE 目标 URL')});`);
302
394
  gates.forEach((g, gi) => {
303
395
  const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
304
396
  if (urlPart)
@@ -323,6 +415,9 @@ function stepExpectedText(step, ctx) {
323
415
  env: ctx.env,
324
416
  variableList: ctx.variableList || [],
325
417
  account: ctx.account,
418
+ // 报告文案入口拿不到整份 dslList,无法识别场景内 WRITE_GLOBAL 变量。
419
+ // 这里只产出人话文案(未解析引用原样保留,便于排障),不需要运行时变量表。
420
+ runtimeVars: new Set(),
326
421
  };
327
422
  return expectedTextOf(step.expectation || [], (e) => resolveExpectationValue(e, renderCtx));
328
423
  }
@@ -448,8 +543,11 @@ function renderExpectation(e, ctx) {
448
543
  if (!type || (0, ops_js_1.isTextOnlyAssert)(type))
449
544
  return null;
450
545
  const comment = e.text ? ` // ${e.text}` : '';
451
- const chain = chainExpr(e.context);
546
+ const chain = chainExpr(e.context, ctx);
547
+ // 断言值双轨:val 是人话文案(expected 用),valExpr 是脚本里的可执行表达式。
548
+ // 二者必须分开 —— 文案里保留 `${X}` 便于排障,代码里未解析引用必须编译期报错。
452
549
  const val = resolveExpectationValue(e, ctx);
550
+ const valExpr = expectationValueExpr(e, ctx);
453
551
  if (!(0, ops_js_1.isKnownAssert)(type)) {
454
552
  // 未知断言不得静默跳过(旧实现只吐注释 = 断言不执行,是假绿的来源之一)
455
553
  return `throw new Error(${q(`不支持的断言类型「${type}」: ${e.text || ''} —— 请移除该断言,或先为该类型补齐转译规则`)});`;
@@ -468,9 +566,9 @@ function renderExpectation(e, ctx) {
468
566
  // 直接断言 body 文本会把标题里的命中判成失败(页面标题 ≠ 正文文案),
469
567
  // 按步骤/断言文案识别出页面标题语义,落到 title 维度。
470
568
  if (isPageTitleAssertion(e)) {
471
- return `await engine.expectTitle(${q(val)});${comment}`;
569
+ return `await engine.expectTitle(${valExpr});${comment}`;
472
570
  }
473
- return `await engine.expectText(${q(val)}, ${chain});${comment}`;
571
+ return `await engine.expectText(${valExpr}, ${chain});${comment}`;
474
572
  case 'TEXT_NOT_EXIST':
475
573
  // 平台里存在「只打开页面」类用例:断言存成 TEXT_NOT_EXIST 但期望值为空串。
476
574
  // 空串对任何页面都成立,属于无实际约束的占位断言;若照原样下发会变成
@@ -478,31 +576,32 @@ function renderExpectation(e, ctx) {
478
576
  // 才生成 text-absent 校验)。这里按同一语义跳过。
479
577
  if (!String(val || '').trim())
480
578
  return null;
481
- return `await engine.expectNotText(${q(val)}, ${chain});${comment}`;
579
+ return `await engine.expectNotText(${valExpr}, ${chain});${comment}`;
482
580
  case 'TEXT_EQUAL':
483
- return `await engine.expectTextEquals(${q(val)}, ${chain});${comment}`;
581
+ return `await engine.expectTextEquals(${valExpr}, ${chain});${comment}`;
484
582
  case 'EXPECTED_VALUE':
485
- return renderExpectedValue(e, val, chain, comment);
583
+ return renderExpectedValue(e, val, valExpr, chain, comment);
486
584
  case 'EXPECTED__PAGET_VALUE':
487
- return renderPageValue(e, val, comment);
585
+ return renderPageValue(e, val, valExpr, comment);
488
586
  case 'SCREEN_SHOT_COMPARE':
489
587
  case 'SCREENSHOT_COMPARE':
490
588
  case 'IMAGE_COMPARE': {
491
589
  // value 为基线 key(老平台基线 id 可回溯迁移来源)
492
590
  const shotName = String(val || e.text || '').trim() || sanitizeShotKey(e.text || 'baseline');
493
- const shotChain = chainExprOrNull(e.context);
591
+ const shotChain = chainExprOrNull(e.context, ctx);
592
+ const shotKeyExpr = codeLiteral(shotName, 'SCREENSHOT 断言基线 key');
494
593
  if (shotChain) {
495
594
  return [
496
- `try { await engine.screenshot(${q(shotName)}, ${shotChain}); }`,
595
+ `try { await engine.screenshot(${shotKeyExpr}, ${shotChain}); }`,
497
596
  `catch (_shotErr: any) {`,
498
597
  ` if (/timeout|waiting.*locator|not found|no element/i.test(String(_shotErr?.message || ''))) {`,
499
- ` console.warn('[dsl] 区域截图降级为整页截图:', ${q(shotName)}, _shotErr?.message);`,
500
- ` await engine.screenshot(${q(shotName)});`,
598
+ ` console.warn('[dsl] 区域截图降级为整页截图:', ${shotKeyExpr}, _shotErr?.message);`,
599
+ ` await engine.screenshot(${shotKeyExpr});`,
501
600
  ` } else { throw _shotErr; }`,
502
601
  `}`,
503
602
  ].join(' ');
504
603
  }
505
- return `await engine.screenshot(${q(shotName)});${comment}`;
604
+ return `await engine.screenshot(${shotKeyExpr});${comment}`;
506
605
  }
507
606
  default:
508
607
  return `throw new Error(${q(`断言「${type}」已在算子表登记但缺少转译实现: ${e.text || ''} —— 请补齐 toDecorator`)});`;
@@ -532,29 +631,29 @@ function isPageTitleAssertion(e) {
532
631
  * class | include / not.include(15 条)
533
632
  * 另留 placeholder / style / index 三个历史维度。
534
633
  */
535
- function renderExpectedValue(e, val, chain, comment) {
634
+ function renderExpectedValue(e, val, valExpr, chain, comment) {
536
635
  const ctype = String(e.ctype || 'text').toLowerCase();
537
636
  const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'include'));
538
637
  switch (ctype) {
539
638
  case 'text':
540
639
  if (mode === 'eq') {
541
640
  return negated
542
- ? `await engine.expectTextNotEquals(${q(val)}, ${chain});${comment}`
543
- : `await engine.expectTextEquals(${q(val)}, ${chain});${comment}`;
641
+ ? `await engine.expectTextNotEquals(${valExpr}, ${chain});${comment}`
642
+ : `await engine.expectTextEquals(${valExpr}, ${chain});${comment}`;
544
643
  }
545
644
  return negated
546
- ? `await engine.expectNotText(${q(val)}, ${chain});${comment}`
547
- : `await engine.expectText(${q(val)}, ${chain});${comment}`;
645
+ ? `await engine.expectNotText(${valExpr}, ${chain});${comment}`
646
+ : `await engine.expectText(${valExpr}, ${chain});${comment}`;
548
647
  case 'class':
549
648
  return negated
550
- ? `await engine.expectNotClass(${q(val)}, ${chain});${comment}`
551
- : `await engine.expectClass(${q(val)}, ${chain});${comment}`;
649
+ ? `await engine.expectNotClass(${valExpr}, ${chain});${comment}`
650
+ : `await engine.expectClass(${valExpr}, ${chain});${comment}`;
552
651
  case 'placeholder':
553
- return `await engine.expectAttribute(${q('placeholder')}, ${q(val)}, ${chain});${comment}`;
652
+ return `await engine.expectAttribute(${q('placeholder')}, ${valExpr}, ${chain});${comment}`;
554
653
  case 'style':
555
654
  return mode === 'eq'
556
- ? `await engine.expectAttribute(${q('style')}, ${q(val)}, ${chain});${comment}`
557
- : `await engine.expectAttributeContains(${q('style')}, ${q(val)}, ${chain});${comment}`;
655
+ ? `await engine.expectAttribute(${q('style')}, ${valExpr}, ${chain});${comment}`
656
+ : `await engine.expectAttributeContains(${q('style')}, ${valExpr}, ${chain});${comment}`;
558
657
  case 'index': {
559
658
  // index + eq:数「文本非空的元素个数」
560
659
  const n = Number(val);
@@ -567,7 +666,7 @@ function renderExpectedValue(e, val, chain, comment) {
567
666
  // 维度未知时不能当成通过 —— 但也不能硬失败:ctype 缺失的历史数据按 text 处理,
568
667
  // 明确写了未知维度的才报错。
569
668
  if (!e.ctype) {
570
- return `await engine.expectText(${q(val)}, ${chain});${comment}`;
669
+ return `await engine.expectText(${valExpr}, ${chain});${comment}`;
571
670
  }
572
671
  return `throw new Error(${q(`不支持的 EXPECTED_VALUE 比较维度「${ctype}」: ${e.text || ''} —— 请改用 text/class/placeholder/style/index`)});`;
573
672
  }
@@ -576,27 +675,160 @@ function renderExpectedValue(e, val, chain, comment) {
576
675
  * EXPECTED__PAGET_VALUE:比页面级属性(历史拼写错误,保留原名)。
577
676
  * 真实数据里只有 ctype=url + cexpression=eq。
578
677
  */
579
- function renderPageValue(e, val, comment) {
678
+ function renderPageValue(e, val, valExpr, comment) {
580
679
  const ctype = String(e.ctype || 'url').toLowerCase();
581
680
  const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'eq'));
582
681
  if (ctype === 'url') {
583
682
  if (mode === 'eq') {
584
683
  return negated
585
- ? `await engine.expectUrlNotEquals(${q(val)});${comment}`
586
- : `await engine.expectUrlEquals(${q(val)});${comment}`;
684
+ ? `await engine.expectUrlNotEquals(${valExpr});${comment}`
685
+ : `await engine.expectUrlEquals(${valExpr});${comment}`;
587
686
  }
588
687
  return negated
589
- ? `await engine.expectUrlNotContains(${q(val)});${comment}`
590
- : `await engine.expectUrlContains(${q(val)});${comment}`;
688
+ ? `await engine.expectUrlNotContains(${valExpr});${comment}`
689
+ : `await engine.expectUrlContains(${valExpr});${comment}`;
591
690
  }
592
691
  if (ctype === 'title') {
593
692
  return mode === 'eq'
594
- ? `await engine.expectTitleEquals(${q(val)});${comment}`
595
- : `await engine.expectTitle(${q(val)});${comment}`;
693
+ ? `await engine.expectTitleEquals(${valExpr});${comment}`
694
+ : `await engine.expectTitle(${valExpr});${comment}`;
596
695
  }
597
696
  return `throw new Error(${q(`不支持的页面属性断言维度「${ctype}」: ${e.text || ''} —— 请改用 url/title`)});`;
598
697
  }
599
698
  // ─── 值 / 链解析 ─────────────────────────────────────────────────────
699
+ /** 合法 JS 标识符:只有合法名才能提升为场景局部变量 */
700
+ const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
701
+ /** WRITE_GLOBAL 声明的变量名:真实数据放在 value({type:'CUSTOM', value:'X'}) */
702
+ function rawWriteGlobalName(d) {
703
+ const v = d?.value;
704
+ if (typeof v === 'string')
705
+ return v.trim();
706
+ if (v && typeof v === 'object')
707
+ return String(v.value ?? '').trim();
708
+ if (typeof d?.child === 'string')
709
+ return d.child.trim();
710
+ return '';
711
+ }
712
+ /**
713
+ * 扫描全场景,收集 WRITE_GLOBAL 声明的运行时变量名。
714
+ *
715
+ * 只有合法标识符才纳入提升集合;非法名(含 `${}`、空格等)不提升,且任何
716
+ * `${非法名}` 引用会在 valueExpr 处编译期报错,不会退化成字面量假绿。
717
+ */
718
+ function collectWriteGlobalVars(dslList) {
719
+ const out = new Set();
720
+ for (const d of Array.isArray(dslList) ? dslList : []) {
721
+ if (String(d?.operation || '') !== 'WRITE_GLOBAL')
722
+ continue;
723
+ const name = rawWriteGlobalName(d);
724
+ if (name && IDENT_RE.test(name))
725
+ out.add(name);
726
+ }
727
+ return out;
728
+ }
729
+ /** WRITE_GLOBAL 的写入目标(提升变量)名;不可提升时返回空串 */
730
+ function writeGlobalVarName(d, ctx) {
731
+ const name = rawWriteGlobalName(d);
732
+ return name && IDENT_RE.test(name) && ctx.runtimeVars.has(name) ? name : '';
733
+ }
734
+ /** 已知来源占位符替换:账号占位 + 变量表按名引用(不做严格性校验) */
735
+ function interpolateKnown(raw, ctx, stepValue) {
736
+ const interpolated = String(raw ?? '')
737
+ .replace(/\$\{loginName\}/g, ctx.account?.name ?? '')
738
+ .replace(/\$\{pwd\}/g, ctx.account?.password ?? '');
739
+ return interpolated.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => {
740
+ const row = (ctx.variableList || []).find((r) => String(r.name) === String(name));
741
+ if (!row)
742
+ return m;
743
+ const u = buildVariableUrl(row, ctx.env, {}, stepValue);
744
+ return u || m;
745
+ });
746
+ }
747
+ /** js 模板字面量片段转义(`\`、反引号;`${` 由调用方逐段拼装) */
748
+ function escapeTemplate(s) {
749
+ return String(s ?? '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
750
+ }
751
+ /**
752
+ * 字符串 → **可执行表达式**(代码发射路径的唯一入口)。
753
+ *
754
+ * 与 resolveValue 的区别:后者面向人话文案(expected),未解析占位符原样保留;
755
+ * 本函数面向生成的脚本源码,必须保证「落到代码里的东西真的能取到值」:
756
+ * - 已被已知来源(账号/变量表)解析 → 字符串字面量
757
+ * - 引用场景内 WRITE_GLOBAL 提升的变量 → 裸标识符 / 模板字面量(运行时取值)
758
+ * - **其余未解析 `${X}` → 编译期硬报错**,绝不静默透传成字面量
759
+ *
760
+ * 第三条是本次假绿的根因防线:未解析值一旦透传,`engine.type(x)` 与编译器补的
761
+ * `engine.expectValue(x)` 会共用同一个字面量,断言天然成立,用例假绿通过。
762
+ */
763
+ function stringExpr(raw, ctx, where, stepValue) {
764
+ const interpolated = interpolateKnown(raw, ctx, stepValue);
765
+ const refs = [...interpolated.matchAll(/\$\{([^}]+)\}/g)].map((m) => String(m[1]).trim());
766
+ for (const name of refs) {
767
+ if (ctx.runtimeVars.has(name))
768
+ continue;
769
+ throw new Error(`未解析的变量引用 \${${name}}(${where})—— 该名称既不在变量表中,`
770
+ + '也不由本场景内的 WRITE_GLOBAL 写入。跨场景引用不会自动传递,'
771
+ + '请让用例自包含(在变量表配置该名称,或在本场景内先 WRITE_GLOBAL 写入)');
772
+ }
773
+ if (!refs.length)
774
+ return q(interpolated);
775
+ // 整体就是一个变量引用:直接取运行时值(统一 String 化,匹配标量输入/文案语义)
776
+ const whole = /^\s*\$\{([^}]+)\}\s*$/.exec(interpolated);
777
+ if (whole && ctx.runtimeVars.has(whole[1].trim()))
778
+ return `String(${whole[1].trim()})`;
779
+ // 内嵌引用 → 模板字面量,逐段转义后拼装
780
+ let body = '';
781
+ let last = 0;
782
+ for (const m of interpolated.matchAll(/\$\{([^}]+)\}/g)) {
783
+ const at = m.index ?? 0;
784
+ body += escapeTemplate(interpolated.slice(last, at));
785
+ body += '${String(' + String(m[1]).trim() + ')}';
786
+ last = at + m[0].length;
787
+ }
788
+ body += escapeTemplate(interpolated.slice(last));
789
+ return '`' + body + '`';
790
+ }
791
+ /** DSL 值 → 可执行表达式(字符串字面量 或 运行时变量引用) */
792
+ function valueExpr(v, ctx, where) {
793
+ if (v === null || v === undefined)
794
+ return q('');
795
+ if (typeof v === 'number')
796
+ return q(String(v));
797
+ if (typeof v === 'string')
798
+ return stringExpr(v, ctx, where, v);
799
+ const raw = String(v.value ?? '');
800
+ if (v.type === 'VARIABLE') {
801
+ const row = ctx.variableList.find((r) => Number(r.id) === Number(raw));
802
+ if (!row)
803
+ return q('');
804
+ return q(buildVariableUrl(row, ctx.env, {}, v));
805
+ }
806
+ return stringExpr(raw, ctx, where, v);
807
+ }
808
+ /**
809
+ * 已解析字符串 → 代码字面量(纵深防御)。
810
+ *
811
+ * 用于「已经过 variableList / 账号插值、但不再走运行时变量表」的发射点
812
+ * (WAIT_RESPONSE 的 URL 片段、元素就绪门选择器、截图基线 key 等)。
813
+ * 若仍残留 `${X}`,说明该引用无处取值 —— 落到代码里只会变成永远匹配不到的字面量,
814
+ * 宁可编译期报错,也不要生成一个静默失败/静默假绿的步骤。
815
+ */
816
+ function codeLiteral(s, where) {
817
+ const refs = [...String(s ?? '').matchAll(/\$\{([^}]+)\}/g)].map((m) => String(m[1]).trim());
818
+ if (refs.length) {
819
+ throw new Error(`未解析的变量引用 \${${refs[0]}}(${where})—— 该名称不在变量表中,`
820
+ + '也不是本场景 WRITE_GLOBAL 可写入的位置(仅 INPUT_ELEMENT 等值的来源支持运行时变量)');
821
+ }
822
+ return q(String(s ?? ''));
823
+ }
824
+ /** 变量表达式 → 人话文案(expected 用):运行时变量显示为「变量「X」」 */
825
+ function describeValue(v, ctx) {
826
+ const raw = typeof v === 'object' && v !== null ? String(v.value ?? '') : String(v ?? '');
827
+ const whole = /^\s*\$\{([^}]+)\}\s*$/.exec(raw);
828
+ if (whole && ctx.runtimeVars.has(whole[1].trim()))
829
+ return `运行时变量「${whole[1].trim()}」`;
830
+ return resolveValue(v, ctx) || raw;
831
+ }
600
832
  /**
601
833
  * OPEN_PAGE 目标页面解析(三级):
602
834
  * 1) 步骤自带 value → 直接解析
@@ -741,7 +973,13 @@ function pickVariableValue(row, env) {
741
973
  }
742
974
  return String(row[env] ?? '');
743
975
  }
744
- /** DSL value → 字面量:CUSTOM 插值账号占位;VARIABLE 查变量表取环境列 */
976
+ /**
977
+ * DSL value → 字面量(**仅用于人话文案 / expected**)。
978
+ *
979
+ * CUSTOM 插值账号占位 + 按变量名引用变量表;VARIABLE 查变量表取环境列。
980
+ * ⚠ 未命中时保留 `${X}` 原样,便于作者在报告里看见未解析引用。
981
+ * **代码发射路径禁止调用本函数** —— 那里未解析引用必须编译期报错(见 stringExpr)。
982
+ */
745
983
  function resolveValue(v, ctx) {
746
984
  if (v === null || v === undefined)
747
985
  return '';
@@ -755,16 +993,7 @@ function resolveValue(v, ctx) {
755
993
  return buildVariableUrl(row, ctx.env, {}, v);
756
994
  }
757
995
  // CUSTOM:插值账号占位 + 按变量名引用变量表
758
- const interpolated = raw
759
- .replace(/\$\{loginName\}/g, ctx.account?.name ?? '')
760
- .replace(/\$\{pwd\}/g, ctx.account?.password ?? '');
761
- return interpolated.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => {
762
- const row = (ctx.variableList || []).find((r) => String(r.name) === String(name));
763
- if (!row)
764
- return m;
765
- const u = buildVariableUrl(row, ctx.env, {}, v);
766
- return u || m;
767
- });
996
+ return interpolateKnown(raw, ctx, v);
768
997
  }
769
998
  /** 断言取期望值:VARIABLE 优先查 id,其次按 name(历史数据两种都存过) */
770
999
  function resolveExpectationValue(e, ctx) {
@@ -778,19 +1007,44 @@ function resolveExpectationValue(e, ctx) {
778
1007
  }
779
1008
  return resolveValue(v, ctx);
780
1009
  }
781
- /** context 多跳 → C(...) 链表达式(selector/class/includes/text 原样透传) */
782
- function chainExpr(context) {
1010
+ /**
1011
+ * 断言期望值 → 可执行表达式。
1012
+ *
1013
+ * 与 resolveExpectationValue 同源同序(VARIABLE 优先 id、再按 name),只是末尾
1014
+ * 从「字面量」改为「表达式」:未解析的 `${X}` 在这里编译期报错,而不是渲染成
1015
+ * 与输入值相同的字面量,避免 `type(x); expectValue(x)` 自证假绿。
1016
+ */
1017
+ function expectationValueExpr(e, ctx) {
1018
+ const v = e.value;
1019
+ if (v && typeof v === 'object' && v.type === 'VARIABLE') {
1020
+ const raw = String(v.value ?? '');
1021
+ const byId = ctx.variableList.find((r) => Number(r.id) === Number(raw));
1022
+ const byName = byId || ctx.variableList.find((r) => String(r.name) === raw);
1023
+ // 变量表命中 → 生成期固化为字面量(与 resolveExpectationValue 一致)
1024
+ if (byName)
1025
+ return q(pickVariableValue(byName, ctx.env) || raw);
1026
+ }
1027
+ return valueExpr(v, ctx, `断言「${e.text || e.type || ''}」的期望值`);
1028
+ }
1029
+ /**
1030
+ * context 多跳 → C(...) 链表达式。
1031
+ *
1032
+ * 跳值同样要过变量解析:定位链里的 `${X}`(如「包含 DashboardName 的行」)
1033
+ * 若不解析会变成字面量选择器,必然匹配不到任何元素。这里统一走 stringExpr,
1034
+ * 未解析引用同样编译期报错。
1035
+ */
1036
+ function chainExpr(context, ctx) {
783
1037
  const hops = collectHops(context);
784
1038
  if (!hops.length)
785
1039
  return `C({ type: 'selector', value: 'body' })`;
786
- return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${q(h.value)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
1040
+ return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${stringExpr(h.value, ctx, `定位链 ${h.type}`)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
787
1041
  }
788
1042
  /** 同 chainExpr,但无有效跳时返回 ''(截图对比:无上下文 = 整页截图) */
789
- function chainExprOrNull(context) {
1043
+ function chainExprOrNull(context, ctx) {
790
1044
  const hops = collectHops(context);
791
1045
  if (!hops.length)
792
1046
  return '';
793
- return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${q(h.value)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
1047
+ return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${stringExpr(h.value, ctx, `定位链 ${h.type}`)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
794
1048
  }
795
1049
  /**
796
1050
  * 归一化定位跳。
@@ -819,6 +1073,23 @@ function collectHops(context) {
819
1073
  }
820
1074
  return out;
821
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
+ }
822
1093
  /** 截图基线 key 兜底:步骤文案 → 稳定 key(同步骤重复生成不变) */
823
1094
  function sanitizeShotKey(text) {
824
1095
  return String(text || 'shot').replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '_').slice(0, 40);