@kkeve/suite 0.0.22 → 0.0.23

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,62 @@ 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
+ const hasUnresolved = [...body, ...expLines].some((b) => /\$\{[^}]+\}/.test(b));
164
+ const deterministic = expectedFullyCovered && !pageLevelOp && executableLineCount > 0 && !hasUnresolved;
165
+ out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)}${deterministic ? ', deterministic: true' : ''} }, async () => {`);
110
166
  for (const b of [...body, ...expLines])
111
167
  out.push(` ${b}`);
112
168
  out.push(`});`);
@@ -117,7 +173,7 @@ function renderStep(d, ctx) {
117
173
  const body = [];
118
174
  if (resolved.url) {
119
175
  body.push(`// 目标页面解析来源: ${resolved.via}`);
120
- body.push(`await engine.navigate(${q(resolved.url)});`);
176
+ body.push(`await engine.navigate(${codeLiteral(resolved.url, 'OPEN_PAGE 目标 URL')});`);
121
177
  }
122
178
  else {
123
179
  body.push(`throw new Error(${q(`OPEN_PAGE 无法解析有效 URL(步骤文案「${stepText}」,value=${JSON.stringify(d.value)})`
@@ -136,7 +192,7 @@ function renderStep(d, ctx) {
136
192
  if (!url) {
137
193
  throw new Error(`REDIRECT_TO 未解析到目标 URL: ${stepText}`);
138
194
  }
139
- emit([`await engine.navigate(${q(url)});`, ...waitExprs(d)]);
195
+ emit([`await engine.navigate(${codeLiteral(url, 'REDIRECT_TO 目标 URL')});`, ...waitExprs(d)]);
140
196
  break;
141
197
  }
142
198
  case 'CLICK_ELEMENT':
@@ -154,7 +210,7 @@ function renderStep(d, ctx) {
154
210
  // 少数步骤把待检文案直接放在 value:此时补一条包含断言,与老引擎
155
211
  // cy.contains(value).should('be.visible') 语义对齐。
156
212
  const val = resolveValue(d.value, ctx);
157
- emit(val ? [`await engine.expectText(${q(val)}, ${chain});`] : []);
213
+ emit(val ? [`await engine.expectText(${valueExpr(d.value, ctx, 'CHECK_TEXT 待检文案')}, ${chain});`] : []);
158
214
  break;
159
215
  }
160
216
  case 'INPUT_ELEMENT': {
@@ -162,12 +218,30 @@ function renderStep(d, ctx) {
162
218
  if (!val) {
163
219
  throw new Error(`INPUT_ELEMENT 缺少输入值,拒绝生成可误报通过的跳过步骤: ${stepText}`);
164
220
  }
165
- emit([`await engine.type(${q(val)}, ${chain});`, ...waitExprs(d)]);
221
+ emit([`await engine.type(${valueExpr(d.value, ctx, 'INPUT_ELEMENT 输入值')}, ${chain});`, ...waitExprs(d)]);
166
222
  break;
167
223
  }
168
- case 'MOVE_ELEMENT':
169
- emit([`await engine.hover(${chain});`, ...waitExprs(d)]);
224
+ case 'MOVE_ELEMENT': {
225
+ // 历史 DSL 把「hover 到某行后再点行内操作」建模成 children:
226
+ // MOVE_ELEMENT(context=行) + CLICK_ELEMENT(context=更多)。
227
+ // 只生成 hover 会漏掉动作,下一步「点删除」必然因下拉未展开而失败。
228
+ const childClicks = [];
229
+ for (const childAction of Array.isArray(d.children) ? d.children : []) {
230
+ const childOp = String(childAction?.operation || '');
231
+ const childHops = chainExprOrNull(childAction?.context, ctx);
232
+ if (!childHops)
233
+ continue;
234
+ if (childOp === 'CLICK_ELEMENT')
235
+ childClicks.push(`await engine.click(${childHops});`);
236
+ else if (childOp === 'FORCE_CLICK_ELEMENT')
237
+ childClicks.push(`await engine.forceClick(${childHops});`);
238
+ else if (childOp) {
239
+ throw new Error(`MOVE_ELEMENT 子动作「${childOp}」暂不支持: ${stepText}`);
240
+ }
241
+ }
242
+ emit([`await engine.hover(${chain});`, ...childClicks, ...waitExprs(d)]);
170
243
  break;
244
+ }
171
245
  case 'SCROLL':
172
246
  emit([`await engine.scroll(${chain});`, ...waitExprs(d)]);
173
247
  break;
@@ -179,7 +253,7 @@ function renderStep(d, ctx) {
179
253
  const sel = firstContextSelector(d.context);
180
254
  if (sel) {
181
255
  const budget = Math.round(waitMs(d) || 10000);
182
- emit([`await engine.waitForElement(${q(sel)}, { timeout: ${budget} });`]);
256
+ emit([`await engine.waitForElement(${codeLiteral(sel, 'WAIT_EVENTS 元素就绪门')}, { timeout: ${budget} });`]);
183
257
  break;
184
258
  }
185
259
  const ms = waitMs(d);
@@ -195,7 +269,7 @@ function renderStep(d, ctx) {
195
269
  throw new Error(`WAIT_RESPONSE 缺少目标 URL,拒绝生成可误报通过的跳过步骤: ${stepText}`);
196
270
  }
197
271
  const opts = waitResponseOptions(d);
198
- emit([`await engine.waitForResponse(${q(urlPart)}${opts});`]);
272
+ emit([`await engine.waitForResponse(${codeLiteral(urlPart, 'WAIT_RESPONSE 目标 URL')}${opts});`]);
199
273
  break;
200
274
  }
201
275
  case 'REFRESH_PAGE':
@@ -211,7 +285,7 @@ function renderStep(d, ctx) {
211
285
  : undefined);
212
286
  const targetCtx = valueCtx || d.child?.context || d.child;
213
287
  if (Array.isArray(targetCtx) && targetCtx.length) {
214
- emit([`await engine.drag(${chain}, ${chainExpr(targetCtx)});`, ...waitExprs(d)]);
288
+ emit([`await engine.drag(${chain}, ${chainExpr(targetCtx, ctx)});`, ...waitExprs(d)]);
215
289
  }
216
290
  else {
217
291
  throw new Error(`DRAGGING 缺少目标元素,拒绝退化为悬停: ${stepText}`);
@@ -222,10 +296,17 @@ function renderStep(d, ctx) {
222
296
  // 真实数据里表达式写在 context 的 javascript 跳上,value 只是变量名
223
297
  const jsHop = firstHopOfType(d.context, 'javascript');
224
298
  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
299
+ if (!expr)
228
300
  throw new Error(`WRITE_GLOBAL 缺少表达式: ${stepText}`);
301
+ const varName = writeGlobalVarName(d, ctx);
302
+ // 变量提升后的写入:值必须落到场景局部变量上,后续 `${name}` 才有来源。
303
+ // 非合法标识符(变量名缺失/非法)时不提升,退化为只求值不落库 ——
304
+ // 这种情况下任何 `${name}` 引用会在解析处编译期报错,不会静默假绿。
305
+ emit([
306
+ varName
307
+ ? `${varName} = await engine.evaluate(${q(String(expr))});`
308
+ : `await engine.evaluate(${q(String(expr))});`,
309
+ ]);
229
310
  break;
230
311
  }
231
312
  case 'SCREEN_SHOT':
@@ -233,18 +314,19 @@ function renderStep(d, ctx) {
233
314
  case 'TAKE_SCREENSHOT': {
234
315
  // 平台截图步骤 → 基线对比断言(无基线建候选,超阈值抛 ScreenshotDiffError)
235
316
  const shotName = resolveValue(d.value, ctx) || sanitizeShotKey(stepText);
236
- const shotChain = chainExprOrNull(d.context);
317
+ const shotChain = chainExprOrNull(d.context, ctx);
237
318
  // 区域截图容错:元素不在当前页面(权限阻断等)时降级为整页截图,
238
319
  // 是否一致由基线比对决定,截图本身不做权限硬门。
239
320
  if (shotChain) {
240
321
  out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
241
322
  out.push(` try {`);
242
- out.push(` await engine.screenshot(${q(shotName)}, ${shotChain});`);
323
+ const shotKeyExpr = codeLiteral(shotName, 'SCREENSHOT 基线 key');
324
+ out.push(` await engine.screenshot(${shotKeyExpr}, ${shotChain});`);
243
325
  out.push(` } catch (_shotErr: any) {`);
244
326
  out.push(` // 区域截图失败(元素不在页面上,如权限阻断/加载异常)—— 降级为整页截图`);
245
327
  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)});`);
328
+ out.push(` console.warn('[dsl] 区域截图降级为整页截图:', ${shotKeyExpr}, _shotErr?.message);`);
329
+ out.push(` await engine.screenshot(${shotKeyExpr});`);
248
330
  out.push(` } else { throw _shotErr; }`);
249
331
  out.push(` }`);
250
332
  for (const e of d.expectation || []) {
@@ -255,7 +337,7 @@ function renderStep(d, ctx) {
255
337
  out.push(`});`);
256
338
  }
257
339
  else {
258
- emit([`await engine.screenshot(${q(shotName)});`, ...waitExprs(d)]);
340
+ emit([`await engine.screenshot(${codeLiteral(shotName, 'SCREENSHOT 基线 key')});`, ...waitExprs(d)]);
259
341
  }
260
342
  break;
261
343
  }
@@ -295,10 +377,10 @@ function renderOpenPageWithGates(d, gates, ctx) {
295
377
  const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
296
378
  if (!urlPart)
297
379
  return; // 就绪门无目标 URL 时跳过注册,由普通 WAIT_RESPONSE 分支报错
298
- body.push(`const __gate${gi} = engine.waitForResponse(${q(urlPart)}${waitResponseOptions(g)}); // 预注册就绪门`);
380
+ body.push(`const __gate${gi} = engine.waitForResponse(${codeLiteral(urlPart, '就绪门目标 URL')}${waitResponseOptions(g)}); // 预注册就绪门`);
299
381
  });
300
382
  body.push(`// 目标页面解析来源: ${resolved.via}`);
301
- body.push(`await engine.navigate(${q(resolved.url)});`);
383
+ body.push(`await engine.navigate(${codeLiteral(resolved.url, 'OPEN_PAGE 目标 URL')});`);
302
384
  gates.forEach((g, gi) => {
303
385
  const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
304
386
  if (urlPart)
@@ -323,6 +405,9 @@ function stepExpectedText(step, ctx) {
323
405
  env: ctx.env,
324
406
  variableList: ctx.variableList || [],
325
407
  account: ctx.account,
408
+ // 报告文案入口拿不到整份 dslList,无法识别场景内 WRITE_GLOBAL 变量。
409
+ // 这里只产出人话文案(未解析引用原样保留,便于排障),不需要运行时变量表。
410
+ runtimeVars: new Set(),
326
411
  };
327
412
  return expectedTextOf(step.expectation || [], (e) => resolveExpectationValue(e, renderCtx));
328
413
  }
@@ -448,8 +533,11 @@ function renderExpectation(e, ctx) {
448
533
  if (!type || (0, ops_js_1.isTextOnlyAssert)(type))
449
534
  return null;
450
535
  const comment = e.text ? ` // ${e.text}` : '';
451
- const chain = chainExpr(e.context);
536
+ const chain = chainExpr(e.context, ctx);
537
+ // 断言值双轨:val 是人话文案(expected 用),valExpr 是脚本里的可执行表达式。
538
+ // 二者必须分开 —— 文案里保留 `${X}` 便于排障,代码里未解析引用必须编译期报错。
452
539
  const val = resolveExpectationValue(e, ctx);
540
+ const valExpr = expectationValueExpr(e, ctx);
453
541
  if (!(0, ops_js_1.isKnownAssert)(type)) {
454
542
  // 未知断言不得静默跳过(旧实现只吐注释 = 断言不执行,是假绿的来源之一)
455
543
  return `throw new Error(${q(`不支持的断言类型「${type}」: ${e.text || ''} —— 请移除该断言,或先为该类型补齐转译规则`)});`;
@@ -468,9 +556,9 @@ function renderExpectation(e, ctx) {
468
556
  // 直接断言 body 文本会把标题里的命中判成失败(页面标题 ≠ 正文文案),
469
557
  // 按步骤/断言文案识别出页面标题语义,落到 title 维度。
470
558
  if (isPageTitleAssertion(e)) {
471
- return `await engine.expectTitle(${q(val)});${comment}`;
559
+ return `await engine.expectTitle(${valExpr});${comment}`;
472
560
  }
473
- return `await engine.expectText(${q(val)}, ${chain});${comment}`;
561
+ return `await engine.expectText(${valExpr}, ${chain});${comment}`;
474
562
  case 'TEXT_NOT_EXIST':
475
563
  // 平台里存在「只打开页面」类用例:断言存成 TEXT_NOT_EXIST 但期望值为空串。
476
564
  // 空串对任何页面都成立,属于无实际约束的占位断言;若照原样下发会变成
@@ -478,31 +566,32 @@ function renderExpectation(e, ctx) {
478
566
  // 才生成 text-absent 校验)。这里按同一语义跳过。
479
567
  if (!String(val || '').trim())
480
568
  return null;
481
- return `await engine.expectNotText(${q(val)}, ${chain});${comment}`;
569
+ return `await engine.expectNotText(${valExpr}, ${chain});${comment}`;
482
570
  case 'TEXT_EQUAL':
483
- return `await engine.expectTextEquals(${q(val)}, ${chain});${comment}`;
571
+ return `await engine.expectTextEquals(${valExpr}, ${chain});${comment}`;
484
572
  case 'EXPECTED_VALUE':
485
- return renderExpectedValue(e, val, chain, comment);
573
+ return renderExpectedValue(e, val, valExpr, chain, comment);
486
574
  case 'EXPECTED__PAGET_VALUE':
487
- return renderPageValue(e, val, comment);
575
+ return renderPageValue(e, val, valExpr, comment);
488
576
  case 'SCREEN_SHOT_COMPARE':
489
577
  case 'SCREENSHOT_COMPARE':
490
578
  case 'IMAGE_COMPARE': {
491
579
  // value 为基线 key(老平台基线 id 可回溯迁移来源)
492
580
  const shotName = String(val || e.text || '').trim() || sanitizeShotKey(e.text || 'baseline');
493
- const shotChain = chainExprOrNull(e.context);
581
+ const shotChain = chainExprOrNull(e.context, ctx);
582
+ const shotKeyExpr = codeLiteral(shotName, 'SCREENSHOT 断言基线 key');
494
583
  if (shotChain) {
495
584
  return [
496
- `try { await engine.screenshot(${q(shotName)}, ${shotChain}); }`,
585
+ `try { await engine.screenshot(${shotKeyExpr}, ${shotChain}); }`,
497
586
  `catch (_shotErr: any) {`,
498
587
  ` 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)});`,
588
+ ` console.warn('[dsl] 区域截图降级为整页截图:', ${shotKeyExpr}, _shotErr?.message);`,
589
+ ` await engine.screenshot(${shotKeyExpr});`,
501
590
  ` } else { throw _shotErr; }`,
502
591
  `}`,
503
592
  ].join(' ');
504
593
  }
505
- return `await engine.screenshot(${q(shotName)});${comment}`;
594
+ return `await engine.screenshot(${shotKeyExpr});${comment}`;
506
595
  }
507
596
  default:
508
597
  return `throw new Error(${q(`断言「${type}」已在算子表登记但缺少转译实现: ${e.text || ''} —— 请补齐 toDecorator`)});`;
@@ -532,29 +621,29 @@ function isPageTitleAssertion(e) {
532
621
  * class | include / not.include(15 条)
533
622
  * 另留 placeholder / style / index 三个历史维度。
534
623
  */
535
- function renderExpectedValue(e, val, chain, comment) {
624
+ function renderExpectedValue(e, val, valExpr, chain, comment) {
536
625
  const ctype = String(e.ctype || 'text').toLowerCase();
537
626
  const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'include'));
538
627
  switch (ctype) {
539
628
  case 'text':
540
629
  if (mode === 'eq') {
541
630
  return negated
542
- ? `await engine.expectTextNotEquals(${q(val)}, ${chain});${comment}`
543
- : `await engine.expectTextEquals(${q(val)}, ${chain});${comment}`;
631
+ ? `await engine.expectTextNotEquals(${valExpr}, ${chain});${comment}`
632
+ : `await engine.expectTextEquals(${valExpr}, ${chain});${comment}`;
544
633
  }
545
634
  return negated
546
- ? `await engine.expectNotText(${q(val)}, ${chain});${comment}`
547
- : `await engine.expectText(${q(val)}, ${chain});${comment}`;
635
+ ? `await engine.expectNotText(${valExpr}, ${chain});${comment}`
636
+ : `await engine.expectText(${valExpr}, ${chain});${comment}`;
548
637
  case 'class':
549
638
  return negated
550
- ? `await engine.expectNotClass(${q(val)}, ${chain});${comment}`
551
- : `await engine.expectClass(${q(val)}, ${chain});${comment}`;
639
+ ? `await engine.expectNotClass(${valExpr}, ${chain});${comment}`
640
+ : `await engine.expectClass(${valExpr}, ${chain});${comment}`;
552
641
  case 'placeholder':
553
- return `await engine.expectAttribute(${q('placeholder')}, ${q(val)}, ${chain});${comment}`;
642
+ return `await engine.expectAttribute(${q('placeholder')}, ${valExpr}, ${chain});${comment}`;
554
643
  case 'style':
555
644
  return mode === 'eq'
556
- ? `await engine.expectAttribute(${q('style')}, ${q(val)}, ${chain});${comment}`
557
- : `await engine.expectAttributeContains(${q('style')}, ${q(val)}, ${chain});${comment}`;
645
+ ? `await engine.expectAttribute(${q('style')}, ${valExpr}, ${chain});${comment}`
646
+ : `await engine.expectAttributeContains(${q('style')}, ${valExpr}, ${chain});${comment}`;
558
647
  case 'index': {
559
648
  // index + eq:数「文本非空的元素个数」
560
649
  const n = Number(val);
@@ -567,7 +656,7 @@ function renderExpectedValue(e, val, chain, comment) {
567
656
  // 维度未知时不能当成通过 —— 但也不能硬失败:ctype 缺失的历史数据按 text 处理,
568
657
  // 明确写了未知维度的才报错。
569
658
  if (!e.ctype) {
570
- return `await engine.expectText(${q(val)}, ${chain});${comment}`;
659
+ return `await engine.expectText(${valExpr}, ${chain});${comment}`;
571
660
  }
572
661
  return `throw new Error(${q(`不支持的 EXPECTED_VALUE 比较维度「${ctype}」: ${e.text || ''} —— 请改用 text/class/placeholder/style/index`)});`;
573
662
  }
@@ -576,27 +665,160 @@ function renderExpectedValue(e, val, chain, comment) {
576
665
  * EXPECTED__PAGET_VALUE:比页面级属性(历史拼写错误,保留原名)。
577
666
  * 真实数据里只有 ctype=url + cexpression=eq。
578
667
  */
579
- function renderPageValue(e, val, comment) {
668
+ function renderPageValue(e, val, valExpr, comment) {
580
669
  const ctype = String(e.ctype || 'url').toLowerCase();
581
670
  const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'eq'));
582
671
  if (ctype === 'url') {
583
672
  if (mode === 'eq') {
584
673
  return negated
585
- ? `await engine.expectUrlNotEquals(${q(val)});${comment}`
586
- : `await engine.expectUrlEquals(${q(val)});${comment}`;
674
+ ? `await engine.expectUrlNotEquals(${valExpr});${comment}`
675
+ : `await engine.expectUrlEquals(${valExpr});${comment}`;
587
676
  }
588
677
  return negated
589
- ? `await engine.expectUrlNotContains(${q(val)});${comment}`
590
- : `await engine.expectUrlContains(${q(val)});${comment}`;
678
+ ? `await engine.expectUrlNotContains(${valExpr});${comment}`
679
+ : `await engine.expectUrlContains(${valExpr});${comment}`;
591
680
  }
592
681
  if (ctype === 'title') {
593
682
  return mode === 'eq'
594
- ? `await engine.expectTitleEquals(${q(val)});${comment}`
595
- : `await engine.expectTitle(${q(val)});${comment}`;
683
+ ? `await engine.expectTitleEquals(${valExpr});${comment}`
684
+ : `await engine.expectTitle(${valExpr});${comment}`;
596
685
  }
597
686
  return `throw new Error(${q(`不支持的页面属性断言维度「${ctype}」: ${e.text || ''} —— 请改用 url/title`)});`;
598
687
  }
599
688
  // ─── 值 / 链解析 ─────────────────────────────────────────────────────
689
+ /** 合法 JS 标识符:只有合法名才能提升为场景局部变量 */
690
+ const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
691
+ /** WRITE_GLOBAL 声明的变量名:真实数据放在 value({type:'CUSTOM', value:'X'}) */
692
+ function rawWriteGlobalName(d) {
693
+ const v = d?.value;
694
+ if (typeof v === 'string')
695
+ return v.trim();
696
+ if (v && typeof v === 'object')
697
+ return String(v.value ?? '').trim();
698
+ if (typeof d?.child === 'string')
699
+ return d.child.trim();
700
+ return '';
701
+ }
702
+ /**
703
+ * 扫描全场景,收集 WRITE_GLOBAL 声明的运行时变量名。
704
+ *
705
+ * 只有合法标识符才纳入提升集合;非法名(含 `${}`、空格等)不提升,且任何
706
+ * `${非法名}` 引用会在 valueExpr 处编译期报错,不会退化成字面量假绿。
707
+ */
708
+ function collectWriteGlobalVars(dslList) {
709
+ const out = new Set();
710
+ for (const d of Array.isArray(dslList) ? dslList : []) {
711
+ if (String(d?.operation || '') !== 'WRITE_GLOBAL')
712
+ continue;
713
+ const name = rawWriteGlobalName(d);
714
+ if (name && IDENT_RE.test(name))
715
+ out.add(name);
716
+ }
717
+ return out;
718
+ }
719
+ /** WRITE_GLOBAL 的写入目标(提升变量)名;不可提升时返回空串 */
720
+ function writeGlobalVarName(d, ctx) {
721
+ const name = rawWriteGlobalName(d);
722
+ return name && IDENT_RE.test(name) && ctx.runtimeVars.has(name) ? name : '';
723
+ }
724
+ /** 已知来源占位符替换:账号占位 + 变量表按名引用(不做严格性校验) */
725
+ function interpolateKnown(raw, ctx, stepValue) {
726
+ const interpolated = String(raw ?? '')
727
+ .replace(/\$\{loginName\}/g, ctx.account?.name ?? '')
728
+ .replace(/\$\{pwd\}/g, ctx.account?.password ?? '');
729
+ return interpolated.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => {
730
+ const row = (ctx.variableList || []).find((r) => String(r.name) === String(name));
731
+ if (!row)
732
+ return m;
733
+ const u = buildVariableUrl(row, ctx.env, {}, stepValue);
734
+ return u || m;
735
+ });
736
+ }
737
+ /** js 模板字面量片段转义(`\`、反引号;`${` 由调用方逐段拼装) */
738
+ function escapeTemplate(s) {
739
+ return String(s ?? '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
740
+ }
741
+ /**
742
+ * 字符串 → **可执行表达式**(代码发射路径的唯一入口)。
743
+ *
744
+ * 与 resolveValue 的区别:后者面向人话文案(expected),未解析占位符原样保留;
745
+ * 本函数面向生成的脚本源码,必须保证「落到代码里的东西真的能取到值」:
746
+ * - 已被已知来源(账号/变量表)解析 → 字符串字面量
747
+ * - 引用场景内 WRITE_GLOBAL 提升的变量 → 裸标识符 / 模板字面量(运行时取值)
748
+ * - **其余未解析 `${X}` → 编译期硬报错**,绝不静默透传成字面量
749
+ *
750
+ * 第三条是本次假绿的根因防线:未解析值一旦透传,`engine.type(x)` 与编译器补的
751
+ * `engine.expectValue(x)` 会共用同一个字面量,断言天然成立,用例假绿通过。
752
+ */
753
+ function stringExpr(raw, ctx, where, stepValue) {
754
+ const interpolated = interpolateKnown(raw, ctx, stepValue);
755
+ const refs = [...interpolated.matchAll(/\$\{([^}]+)\}/g)].map((m) => String(m[1]).trim());
756
+ for (const name of refs) {
757
+ if (ctx.runtimeVars.has(name))
758
+ continue;
759
+ throw new Error(`未解析的变量引用 \${${name}}(${where})—— 该名称既不在变量表中,`
760
+ + '也不由本场景内的 WRITE_GLOBAL 写入。跨场景引用不会自动传递,'
761
+ + '请让用例自包含(在变量表配置该名称,或在本场景内先 WRITE_GLOBAL 写入)');
762
+ }
763
+ if (!refs.length)
764
+ return q(interpolated);
765
+ // 整体就是一个变量引用:直接取运行时值(统一 String 化,匹配标量输入/文案语义)
766
+ const whole = /^\s*\$\{([^}]+)\}\s*$/.exec(interpolated);
767
+ if (whole && ctx.runtimeVars.has(whole[1].trim()))
768
+ return `String(${whole[1].trim()})`;
769
+ // 内嵌引用 → 模板字面量,逐段转义后拼装
770
+ let body = '';
771
+ let last = 0;
772
+ for (const m of interpolated.matchAll(/\$\{([^}]+)\}/g)) {
773
+ const at = m.index ?? 0;
774
+ body += escapeTemplate(interpolated.slice(last, at));
775
+ body += '${String(' + String(m[1]).trim() + ')}';
776
+ last = at + m[0].length;
777
+ }
778
+ body += escapeTemplate(interpolated.slice(last));
779
+ return '`' + body + '`';
780
+ }
781
+ /** DSL 值 → 可执行表达式(字符串字面量 或 运行时变量引用) */
782
+ function valueExpr(v, ctx, where) {
783
+ if (v === null || v === undefined)
784
+ return q('');
785
+ if (typeof v === 'number')
786
+ return q(String(v));
787
+ if (typeof v === 'string')
788
+ return stringExpr(v, ctx, where, v);
789
+ const raw = String(v.value ?? '');
790
+ if (v.type === 'VARIABLE') {
791
+ const row = ctx.variableList.find((r) => Number(r.id) === Number(raw));
792
+ if (!row)
793
+ return q('');
794
+ return q(buildVariableUrl(row, ctx.env, {}, v));
795
+ }
796
+ return stringExpr(raw, ctx, where, v);
797
+ }
798
+ /**
799
+ * 已解析字符串 → 代码字面量(纵深防御)。
800
+ *
801
+ * 用于「已经过 variableList / 账号插值、但不再走运行时变量表」的发射点
802
+ * (WAIT_RESPONSE 的 URL 片段、元素就绪门选择器、截图基线 key 等)。
803
+ * 若仍残留 `${X}`,说明该引用无处取值 —— 落到代码里只会变成永远匹配不到的字面量,
804
+ * 宁可编译期报错,也不要生成一个静默失败/静默假绿的步骤。
805
+ */
806
+ function codeLiteral(s, where) {
807
+ const refs = [...String(s ?? '').matchAll(/\$\{([^}]+)\}/g)].map((m) => String(m[1]).trim());
808
+ if (refs.length) {
809
+ throw new Error(`未解析的变量引用 \${${refs[0]}}(${where})—— 该名称不在变量表中,`
810
+ + '也不是本场景 WRITE_GLOBAL 可写入的位置(仅 INPUT_ELEMENT 等值的来源支持运行时变量)');
811
+ }
812
+ return q(String(s ?? ''));
813
+ }
814
+ /** 变量表达式 → 人话文案(expected 用):运行时变量显示为「变量「X」」 */
815
+ function describeValue(v, ctx) {
816
+ const raw = typeof v === 'object' && v !== null ? String(v.value ?? '') : String(v ?? '');
817
+ const whole = /^\s*\$\{([^}]+)\}\s*$/.exec(raw);
818
+ if (whole && ctx.runtimeVars.has(whole[1].trim()))
819
+ return `运行时变量「${whole[1].trim()}」`;
820
+ return resolveValue(v, ctx) || raw;
821
+ }
600
822
  /**
601
823
  * OPEN_PAGE 目标页面解析(三级):
602
824
  * 1) 步骤自带 value → 直接解析
@@ -741,7 +963,13 @@ function pickVariableValue(row, env) {
741
963
  }
742
964
  return String(row[env] ?? '');
743
965
  }
744
- /** DSL value → 字面量:CUSTOM 插值账号占位;VARIABLE 查变量表取环境列 */
966
+ /**
967
+ * DSL value → 字面量(**仅用于人话文案 / expected**)。
968
+ *
969
+ * CUSTOM 插值账号占位 + 按变量名引用变量表;VARIABLE 查变量表取环境列。
970
+ * ⚠ 未命中时保留 `${X}` 原样,便于作者在报告里看见未解析引用。
971
+ * **代码发射路径禁止调用本函数** —— 那里未解析引用必须编译期报错(见 stringExpr)。
972
+ */
745
973
  function resolveValue(v, ctx) {
746
974
  if (v === null || v === undefined)
747
975
  return '';
@@ -755,16 +983,7 @@ function resolveValue(v, ctx) {
755
983
  return buildVariableUrl(row, ctx.env, {}, v);
756
984
  }
757
985
  // 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
- });
986
+ return interpolateKnown(raw, ctx, v);
768
987
  }
769
988
  /** 断言取期望值:VARIABLE 优先查 id,其次按 name(历史数据两种都存过) */
770
989
  function resolveExpectationValue(e, ctx) {
@@ -778,19 +997,44 @@ function resolveExpectationValue(e, ctx) {
778
997
  }
779
998
  return resolveValue(v, ctx);
780
999
  }
781
- /** context 多跳 → C(...) 链表达式(selector/class/includes/text 原样透传) */
782
- function chainExpr(context) {
1000
+ /**
1001
+ * 断言期望值 → 可执行表达式。
1002
+ *
1003
+ * 与 resolveExpectationValue 同源同序(VARIABLE 优先 id、再按 name),只是末尾
1004
+ * 从「字面量」改为「表达式」:未解析的 `${X}` 在这里编译期报错,而不是渲染成
1005
+ * 与输入值相同的字面量,避免 `type(x); expectValue(x)` 自证假绿。
1006
+ */
1007
+ function expectationValueExpr(e, ctx) {
1008
+ const v = e.value;
1009
+ if (v && typeof v === 'object' && v.type === 'VARIABLE') {
1010
+ const raw = String(v.value ?? '');
1011
+ const byId = ctx.variableList.find((r) => Number(r.id) === Number(raw));
1012
+ const byName = byId || ctx.variableList.find((r) => String(r.name) === raw);
1013
+ // 变量表命中 → 生成期固化为字面量(与 resolveExpectationValue 一致)
1014
+ if (byName)
1015
+ return q(pickVariableValue(byName, ctx.env) || raw);
1016
+ }
1017
+ return valueExpr(v, ctx, `断言「${e.text || e.type || ''}」的期望值`);
1018
+ }
1019
+ /**
1020
+ * context 多跳 → C(...) 链表达式。
1021
+ *
1022
+ * 跳值同样要过变量解析:定位链里的 `${X}`(如「包含 DashboardName 的行」)
1023
+ * 若不解析会变成字面量选择器,必然匹配不到任何元素。这里统一走 stringExpr,
1024
+ * 未解析引用同样编译期报错。
1025
+ */
1026
+ function chainExpr(context, ctx) {
783
1027
  const hops = collectHops(context);
784
1028
  if (!hops.length)
785
1029
  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(', ')})`;
1030
+ return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${stringExpr(h.value, ctx, `定位链 ${h.type}`)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
787
1031
  }
788
1032
  /** 同 chainExpr,但无有效跳时返回 ''(截图对比:无上下文 = 整页截图) */
789
- function chainExprOrNull(context) {
1033
+ function chainExprOrNull(context, ctx) {
790
1034
  const hops = collectHops(context);
791
1035
  if (!hops.length)
792
1036
  return '';
793
- return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${q(h.value)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
1037
+ return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${stringExpr(h.value, ctx, `定位链 ${h.type}`)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
794
1038
  }
795
1039
  /**
796
1040
  * 归一化定位跳。
@@ -76,6 +76,14 @@ export declare class CypressEngine implements EngineAdapter {
76
76
  expectNotText(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
77
77
  expectTextEquals(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
78
78
  expectTextNotEquals(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
79
+ /**
80
+ * 表单元素当前值断言(input/textarea/select 的 value)。
81
+ *
82
+ * 与 expectTextEquals 的区别是取值口径:input 的 textContent 恒为空,
83
+ * 用文本断言校验用户输入会永远失败;这里读 DOM value,才是「输入框里
84
+ * 确实有这个值」的证据。
85
+ */
86
+ expectValue(expected: string, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
79
87
  expectCount(count: number, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
80
88
  expectNonEmptyCount(count: number, ...refs: (ElementRef | ElementEntry)[]): Promise<void>;
81
89
  expectExists(...refs: (ElementRef | ElementEntry)[]): Promise<void>;