@kkeve/suite 0.0.21 → 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.
- package/dist/core/page-object/elementResolver.js +20 -4
- package/dist/core/page-object/engine-types.d.ts +3 -0
- package/dist/dsl/locatorSource.d.ts +1 -1
- package/dist/dsl/locatorSource.js +72 -20
- package/dist/dsl/toDecorator.d.ts +2 -0
- package/dist/dsl/toDecorator.js +318 -69
- package/dist/engine-cypress/adapter.d.ts +8 -0
- package/dist/engine-cypress/adapter.js +23 -3
- package/dist/engine-cypress/agent-page.js +1 -1
- package/dist/engine-cypress/agentDom.js +86 -6
- package/dist/engine-cypress/cdp.js +1 -1
- package/dist/engine-cypress/host.js +40 -4
- package/dist/engine-cypress/keveGoal.d.ts +9 -0
- package/dist/engine-cypress/keveGoal.js +20 -0
- package/dist/engine-cypress/pageOps.js +79 -3
- package/dist/engine-playwright/engineAdapter.d.ts +7 -0
- package/dist/engine-playwright/engineAdapter.js +10 -0
- package/dist/engine-playwright/keve-test.d.ts +8 -0
- package/dist/engine-playwright/keve-test.js +23 -0
- package/dist/engine-playwright/pw-config.js +2 -0
- package/dist/runner/index.d.ts +85 -0
- package/dist/runner/index.js +220 -0
- package/package.json +5 -1
- package/scripts/run-dsl-task.mjs +91 -171
- package/scripts/run-probe.mjs +35 -0
- package/scripts/runtime/cypress.config.mjs +2 -0
- package/scripts/runtime/playwright.config.mjs +1 -0
package/dist/dsl/toDecorator.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(${
|
|
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(${
|
|
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(${
|
|
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(${
|
|
221
|
+
emit([`await engine.type(${valueExpr(d.value, ctx, 'INPUT_ELEMENT 输入值')}, ${chain});`, ...waitExprs(d)]);
|
|
166
222
|
break;
|
|
167
223
|
}
|
|
168
|
-
case 'MOVE_ELEMENT':
|
|
169
|
-
|
|
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(${
|
|
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(${
|
|
272
|
+
emit([`await engine.waitForResponse(${codeLiteral(urlPart, 'WAIT_RESPONSE 目标 URL')}${opts});`]);
|
|
199
273
|
break;
|
|
200
274
|
}
|
|
201
275
|
case 'REFRESH_PAGE':
|
|
@@ -203,10 +277,15 @@ function renderStep(d, ctx) {
|
|
|
203
277
|
break;
|
|
204
278
|
case 'DRAGGING': {
|
|
205
279
|
// 拖拽:context 为源,value/child 携带目标链;缺失目标不得伪装成悬停成功
|
|
206
|
-
const
|
|
280
|
+
const rawValue = d.value;
|
|
281
|
+
const valueCtx = Array.isArray(rawValue)
|
|
282
|
+
? rawValue
|
|
283
|
+
: (rawValue && typeof rawValue === 'object'
|
|
284
|
+
? rawValue.context || rawValue.value
|
|
285
|
+
: undefined);
|
|
207
286
|
const targetCtx = valueCtx || d.child?.context || d.child;
|
|
208
287
|
if (Array.isArray(targetCtx) && targetCtx.length) {
|
|
209
|
-
emit([`await engine.drag(${chain}, ${chainExpr(targetCtx)});`, ...waitExprs(d)]);
|
|
288
|
+
emit([`await engine.drag(${chain}, ${chainExpr(targetCtx, ctx)});`, ...waitExprs(d)]);
|
|
210
289
|
}
|
|
211
290
|
else {
|
|
212
291
|
throw new Error(`DRAGGING 缺少目标元素,拒绝退化为悬停: ${stepText}`);
|
|
@@ -217,10 +296,17 @@ function renderStep(d, ctx) {
|
|
|
217
296
|
// 真实数据里表达式写在 context 的 javascript 跳上,value 只是变量名
|
|
218
297
|
const jsHop = firstHopOfType(d.context, 'javascript');
|
|
219
298
|
const expr = jsHop ? String(jsHop.value ?? '') : resolveValue(d.value, ctx) || (typeof d.child === 'string' ? d.child : d.child?.value);
|
|
220
|
-
if (expr)
|
|
221
|
-
emit([`await engine.evaluate(${q(String(expr))});`]);
|
|
222
|
-
else
|
|
299
|
+
if (!expr)
|
|
223
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
|
+
]);
|
|
224
310
|
break;
|
|
225
311
|
}
|
|
226
312
|
case 'SCREEN_SHOT':
|
|
@@ -228,18 +314,19 @@ function renderStep(d, ctx) {
|
|
|
228
314
|
case 'TAKE_SCREENSHOT': {
|
|
229
315
|
// 平台截图步骤 → 基线对比断言(无基线建候选,超阈值抛 ScreenshotDiffError)
|
|
230
316
|
const shotName = resolveValue(d.value, ctx) || sanitizeShotKey(stepText);
|
|
231
|
-
const shotChain = chainExprOrNull(d.context);
|
|
317
|
+
const shotChain = chainExprOrNull(d.context, ctx);
|
|
232
318
|
// 区域截图容错:元素不在当前页面(权限阻断等)时降级为整页截图,
|
|
233
319
|
// 是否一致由基线比对决定,截图本身不做权限硬门。
|
|
234
320
|
if (shotChain) {
|
|
235
321
|
out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
|
|
236
322
|
out.push(` try {`);
|
|
237
|
-
|
|
323
|
+
const shotKeyExpr = codeLiteral(shotName, 'SCREENSHOT 基线 key');
|
|
324
|
+
out.push(` await engine.screenshot(${shotKeyExpr}, ${shotChain});`);
|
|
238
325
|
out.push(` } catch (_shotErr: any) {`);
|
|
239
326
|
out.push(` // 区域截图失败(元素不在页面上,如权限阻断/加载异常)—— 降级为整页截图`);
|
|
240
327
|
out.push(` if (/timeout|waiting.*locator|not found|no element/i.test(String(_shotErr?.message || ''))) {`);
|
|
241
|
-
out.push(` console.warn('[dsl] 区域截图降级为整页截图:', ${
|
|
242
|
-
out.push(` await engine.screenshot(${
|
|
328
|
+
out.push(` console.warn('[dsl] 区域截图降级为整页截图:', ${shotKeyExpr}, _shotErr?.message);`);
|
|
329
|
+
out.push(` await engine.screenshot(${shotKeyExpr});`);
|
|
243
330
|
out.push(` } else { throw _shotErr; }`);
|
|
244
331
|
out.push(` }`);
|
|
245
332
|
for (const e of d.expectation || []) {
|
|
@@ -250,7 +337,7 @@ function renderStep(d, ctx) {
|
|
|
250
337
|
out.push(`});`);
|
|
251
338
|
}
|
|
252
339
|
else {
|
|
253
|
-
emit([`await engine.screenshot(${
|
|
340
|
+
emit([`await engine.screenshot(${codeLiteral(shotName, 'SCREENSHOT 基线 key')});`, ...waitExprs(d)]);
|
|
254
341
|
}
|
|
255
342
|
break;
|
|
256
343
|
}
|
|
@@ -290,10 +377,10 @@ function renderOpenPageWithGates(d, gates, ctx) {
|
|
|
290
377
|
const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
|
|
291
378
|
if (!urlPart)
|
|
292
379
|
return; // 就绪门无目标 URL 时跳过注册,由普通 WAIT_RESPONSE 分支报错
|
|
293
|
-
body.push(`const __gate${gi} = engine.waitForResponse(${
|
|
380
|
+
body.push(`const __gate${gi} = engine.waitForResponse(${codeLiteral(urlPart, '就绪门目标 URL')}${waitResponseOptions(g)}); // 预注册就绪门`);
|
|
294
381
|
});
|
|
295
382
|
body.push(`// 目标页面解析来源: ${resolved.via}`);
|
|
296
|
-
body.push(`await engine.navigate(${
|
|
383
|
+
body.push(`await engine.navigate(${codeLiteral(resolved.url, 'OPEN_PAGE 目标 URL')});`);
|
|
297
384
|
gates.forEach((g, gi) => {
|
|
298
385
|
const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
|
|
299
386
|
if (urlPart)
|
|
@@ -318,6 +405,9 @@ function stepExpectedText(step, ctx) {
|
|
|
318
405
|
env: ctx.env,
|
|
319
406
|
variableList: ctx.variableList || [],
|
|
320
407
|
account: ctx.account,
|
|
408
|
+
// 报告文案入口拿不到整份 dslList,无法识别场景内 WRITE_GLOBAL 变量。
|
|
409
|
+
// 这里只产出人话文案(未解析引用原样保留,便于排障),不需要运行时变量表。
|
|
410
|
+
runtimeVars: new Set(),
|
|
321
411
|
};
|
|
322
412
|
return expectedTextOf(step.expectation || [], (e) => resolveExpectationValue(e, renderCtx));
|
|
323
413
|
}
|
|
@@ -443,8 +533,11 @@ function renderExpectation(e, ctx) {
|
|
|
443
533
|
if (!type || (0, ops_js_1.isTextOnlyAssert)(type))
|
|
444
534
|
return null;
|
|
445
535
|
const comment = e.text ? ` // ${e.text}` : '';
|
|
446
|
-
const chain = chainExpr(e.context);
|
|
536
|
+
const chain = chainExpr(e.context, ctx);
|
|
537
|
+
// 断言值双轨:val 是人话文案(expected 用),valExpr 是脚本里的可执行表达式。
|
|
538
|
+
// 二者必须分开 —— 文案里保留 `${X}` 便于排障,代码里未解析引用必须编译期报错。
|
|
447
539
|
const val = resolveExpectationValue(e, ctx);
|
|
540
|
+
const valExpr = expectationValueExpr(e, ctx);
|
|
448
541
|
if (!(0, ops_js_1.isKnownAssert)(type)) {
|
|
449
542
|
// 未知断言不得静默跳过(旧实现只吐注释 = 断言不执行,是假绿的来源之一)
|
|
450
543
|
return `throw new Error(${q(`不支持的断言类型「${type}」: ${e.text || ''} —— 请移除该断言,或先为该类型补齐转译规则`)});`;
|
|
@@ -463,9 +556,9 @@ function renderExpectation(e, ctx) {
|
|
|
463
556
|
// 直接断言 body 文本会把标题里的命中判成失败(页面标题 ≠ 正文文案),
|
|
464
557
|
// 按步骤/断言文案识别出页面标题语义,落到 title 维度。
|
|
465
558
|
if (isPageTitleAssertion(e)) {
|
|
466
|
-
return `await engine.expectTitle(${
|
|
559
|
+
return `await engine.expectTitle(${valExpr});${comment}`;
|
|
467
560
|
}
|
|
468
|
-
return `await engine.expectText(${
|
|
561
|
+
return `await engine.expectText(${valExpr}, ${chain});${comment}`;
|
|
469
562
|
case 'TEXT_NOT_EXIST':
|
|
470
563
|
// 平台里存在「只打开页面」类用例:断言存成 TEXT_NOT_EXIST 但期望值为空串。
|
|
471
564
|
// 空串对任何页面都成立,属于无实际约束的占位断言;若照原样下发会变成
|
|
@@ -473,31 +566,32 @@ function renderExpectation(e, ctx) {
|
|
|
473
566
|
// 才生成 text-absent 校验)。这里按同一语义跳过。
|
|
474
567
|
if (!String(val || '').trim())
|
|
475
568
|
return null;
|
|
476
|
-
return `await engine.expectNotText(${
|
|
569
|
+
return `await engine.expectNotText(${valExpr}, ${chain});${comment}`;
|
|
477
570
|
case 'TEXT_EQUAL':
|
|
478
|
-
return `await engine.expectTextEquals(${
|
|
571
|
+
return `await engine.expectTextEquals(${valExpr}, ${chain});${comment}`;
|
|
479
572
|
case 'EXPECTED_VALUE':
|
|
480
|
-
return renderExpectedValue(e, val, chain, comment);
|
|
573
|
+
return renderExpectedValue(e, val, valExpr, chain, comment);
|
|
481
574
|
case 'EXPECTED__PAGET_VALUE':
|
|
482
|
-
return renderPageValue(e, val, comment);
|
|
575
|
+
return renderPageValue(e, val, valExpr, comment);
|
|
483
576
|
case 'SCREEN_SHOT_COMPARE':
|
|
484
577
|
case 'SCREENSHOT_COMPARE':
|
|
485
578
|
case 'IMAGE_COMPARE': {
|
|
486
579
|
// value 为基线 key(老平台基线 id 可回溯迁移来源)
|
|
487
580
|
const shotName = String(val || e.text || '').trim() || sanitizeShotKey(e.text || 'baseline');
|
|
488
|
-
const shotChain = chainExprOrNull(e.context);
|
|
581
|
+
const shotChain = chainExprOrNull(e.context, ctx);
|
|
582
|
+
const shotKeyExpr = codeLiteral(shotName, 'SCREENSHOT 断言基线 key');
|
|
489
583
|
if (shotChain) {
|
|
490
584
|
return [
|
|
491
|
-
`try { await engine.screenshot(${
|
|
585
|
+
`try { await engine.screenshot(${shotKeyExpr}, ${shotChain}); }`,
|
|
492
586
|
`catch (_shotErr: any) {`,
|
|
493
587
|
` if (/timeout|waiting.*locator|not found|no element/i.test(String(_shotErr?.message || ''))) {`,
|
|
494
|
-
` console.warn('[dsl] 区域截图降级为整页截图:', ${
|
|
495
|
-
` await engine.screenshot(${
|
|
588
|
+
` console.warn('[dsl] 区域截图降级为整页截图:', ${shotKeyExpr}, _shotErr?.message);`,
|
|
589
|
+
` await engine.screenshot(${shotKeyExpr});`,
|
|
496
590
|
` } else { throw _shotErr; }`,
|
|
497
591
|
`}`,
|
|
498
592
|
].join(' ');
|
|
499
593
|
}
|
|
500
|
-
return `await engine.screenshot(${
|
|
594
|
+
return `await engine.screenshot(${shotKeyExpr});${comment}`;
|
|
501
595
|
}
|
|
502
596
|
default:
|
|
503
597
|
return `throw new Error(${q(`断言「${type}」已在算子表登记但缺少转译实现: ${e.text || ''} —— 请补齐 toDecorator`)});`;
|
|
@@ -527,29 +621,29 @@ function isPageTitleAssertion(e) {
|
|
|
527
621
|
* class | include / not.include(15 条)
|
|
528
622
|
* 另留 placeholder / style / index 三个历史维度。
|
|
529
623
|
*/
|
|
530
|
-
function renderExpectedValue(e, val, chain, comment) {
|
|
624
|
+
function renderExpectedValue(e, val, valExpr, chain, comment) {
|
|
531
625
|
const ctype = String(e.ctype || 'text').toLowerCase();
|
|
532
626
|
const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'include'));
|
|
533
627
|
switch (ctype) {
|
|
534
628
|
case 'text':
|
|
535
629
|
if (mode === 'eq') {
|
|
536
630
|
return negated
|
|
537
|
-
? `await engine.expectTextNotEquals(${
|
|
538
|
-
: `await engine.expectTextEquals(${
|
|
631
|
+
? `await engine.expectTextNotEquals(${valExpr}, ${chain});${comment}`
|
|
632
|
+
: `await engine.expectTextEquals(${valExpr}, ${chain});${comment}`;
|
|
539
633
|
}
|
|
540
634
|
return negated
|
|
541
|
-
? `await engine.expectNotText(${
|
|
542
|
-
: `await engine.expectText(${
|
|
635
|
+
? `await engine.expectNotText(${valExpr}, ${chain});${comment}`
|
|
636
|
+
: `await engine.expectText(${valExpr}, ${chain});${comment}`;
|
|
543
637
|
case 'class':
|
|
544
638
|
return negated
|
|
545
|
-
? `await engine.expectNotClass(${
|
|
546
|
-
: `await engine.expectClass(${
|
|
639
|
+
? `await engine.expectNotClass(${valExpr}, ${chain});${comment}`
|
|
640
|
+
: `await engine.expectClass(${valExpr}, ${chain});${comment}`;
|
|
547
641
|
case 'placeholder':
|
|
548
|
-
return `await engine.expectAttribute(${q('placeholder')}, ${
|
|
642
|
+
return `await engine.expectAttribute(${q('placeholder')}, ${valExpr}, ${chain});${comment}`;
|
|
549
643
|
case 'style':
|
|
550
644
|
return mode === 'eq'
|
|
551
|
-
? `await engine.expectAttribute(${q('style')}, ${
|
|
552
|
-
: `await engine.expectAttributeContains(${q('style')}, ${
|
|
645
|
+
? `await engine.expectAttribute(${q('style')}, ${valExpr}, ${chain});${comment}`
|
|
646
|
+
: `await engine.expectAttributeContains(${q('style')}, ${valExpr}, ${chain});${comment}`;
|
|
553
647
|
case 'index': {
|
|
554
648
|
// index + eq:数「文本非空的元素个数」
|
|
555
649
|
const n = Number(val);
|
|
@@ -562,7 +656,7 @@ function renderExpectedValue(e, val, chain, comment) {
|
|
|
562
656
|
// 维度未知时不能当成通过 —— 但也不能硬失败:ctype 缺失的历史数据按 text 处理,
|
|
563
657
|
// 明确写了未知维度的才报错。
|
|
564
658
|
if (!e.ctype) {
|
|
565
|
-
return `await engine.expectText(${
|
|
659
|
+
return `await engine.expectText(${valExpr}, ${chain});${comment}`;
|
|
566
660
|
}
|
|
567
661
|
return `throw new Error(${q(`不支持的 EXPECTED_VALUE 比较维度「${ctype}」: ${e.text || ''} —— 请改用 text/class/placeholder/style/index`)});`;
|
|
568
662
|
}
|
|
@@ -571,27 +665,160 @@ function renderExpectedValue(e, val, chain, comment) {
|
|
|
571
665
|
* EXPECTED__PAGET_VALUE:比页面级属性(历史拼写错误,保留原名)。
|
|
572
666
|
* 真实数据里只有 ctype=url + cexpression=eq。
|
|
573
667
|
*/
|
|
574
|
-
function renderPageValue(e, val, comment) {
|
|
668
|
+
function renderPageValue(e, val, valExpr, comment) {
|
|
575
669
|
const ctype = String(e.ctype || 'url').toLowerCase();
|
|
576
670
|
const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'eq'));
|
|
577
671
|
if (ctype === 'url') {
|
|
578
672
|
if (mode === 'eq') {
|
|
579
673
|
return negated
|
|
580
|
-
? `await engine.expectUrlNotEquals(${
|
|
581
|
-
: `await engine.expectUrlEquals(${
|
|
674
|
+
? `await engine.expectUrlNotEquals(${valExpr});${comment}`
|
|
675
|
+
: `await engine.expectUrlEquals(${valExpr});${comment}`;
|
|
582
676
|
}
|
|
583
677
|
return negated
|
|
584
|
-
? `await engine.expectUrlNotContains(${
|
|
585
|
-
: `await engine.expectUrlContains(${
|
|
678
|
+
? `await engine.expectUrlNotContains(${valExpr});${comment}`
|
|
679
|
+
: `await engine.expectUrlContains(${valExpr});${comment}`;
|
|
586
680
|
}
|
|
587
681
|
if (ctype === 'title') {
|
|
588
682
|
return mode === 'eq'
|
|
589
|
-
? `await engine.expectTitleEquals(${
|
|
590
|
-
: `await engine.expectTitle(${
|
|
683
|
+
? `await engine.expectTitleEquals(${valExpr});${comment}`
|
|
684
|
+
: `await engine.expectTitle(${valExpr});${comment}`;
|
|
591
685
|
}
|
|
592
686
|
return `throw new Error(${q(`不支持的页面属性断言维度「${ctype}」: ${e.text || ''} —— 请改用 url/title`)});`;
|
|
593
687
|
}
|
|
594
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
|
+
}
|
|
595
822
|
/**
|
|
596
823
|
* OPEN_PAGE 目标页面解析(三级):
|
|
597
824
|
* 1) 步骤自带 value → 直接解析
|
|
@@ -736,7 +963,13 @@ function pickVariableValue(row, env) {
|
|
|
736
963
|
}
|
|
737
964
|
return String(row[env] ?? '');
|
|
738
965
|
}
|
|
739
|
-
/**
|
|
966
|
+
/**
|
|
967
|
+
* DSL value → 字面量(**仅用于人话文案 / expected**)。
|
|
968
|
+
*
|
|
969
|
+
* CUSTOM 插值账号占位 + 按变量名引用变量表;VARIABLE 查变量表取环境列。
|
|
970
|
+
* ⚠ 未命中时保留 `${X}` 原样,便于作者在报告里看见未解析引用。
|
|
971
|
+
* **代码发射路径禁止调用本函数** —— 那里未解析引用必须编译期报错(见 stringExpr)。
|
|
972
|
+
*/
|
|
740
973
|
function resolveValue(v, ctx) {
|
|
741
974
|
if (v === null || v === undefined)
|
|
742
975
|
return '';
|
|
@@ -750,16 +983,7 @@ function resolveValue(v, ctx) {
|
|
|
750
983
|
return buildVariableUrl(row, ctx.env, {}, v);
|
|
751
984
|
}
|
|
752
985
|
// CUSTOM:插值账号占位 + 按变量名引用变量表
|
|
753
|
-
|
|
754
|
-
.replace(/\$\{loginName\}/g, ctx.account?.name ?? '')
|
|
755
|
-
.replace(/\$\{pwd\}/g, ctx.account?.password ?? '');
|
|
756
|
-
return interpolated.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => {
|
|
757
|
-
const row = (ctx.variableList || []).find((r) => String(r.name) === String(name));
|
|
758
|
-
if (!row)
|
|
759
|
-
return m;
|
|
760
|
-
const u = buildVariableUrl(row, ctx.env, {}, v);
|
|
761
|
-
return u || m;
|
|
762
|
-
});
|
|
986
|
+
return interpolateKnown(raw, ctx, v);
|
|
763
987
|
}
|
|
764
988
|
/** 断言取期望值:VARIABLE 优先查 id,其次按 name(历史数据两种都存过) */
|
|
765
989
|
function resolveExpectationValue(e, ctx) {
|
|
@@ -773,19 +997,44 @@ function resolveExpectationValue(e, ctx) {
|
|
|
773
997
|
}
|
|
774
998
|
return resolveValue(v, ctx);
|
|
775
999
|
}
|
|
776
|
-
/**
|
|
777
|
-
|
|
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) {
|
|
778
1027
|
const hops = collectHops(context);
|
|
779
1028
|
if (!hops.length)
|
|
780
1029
|
return `C({ type: 'selector', value: 'body' })`;
|
|
781
|
-
return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${
|
|
1030
|
+
return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${stringExpr(h.value, ctx, `定位链 ${h.type}`)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
|
|
782
1031
|
}
|
|
783
1032
|
/** 同 chainExpr,但无有效跳时返回 ''(截图对比:无上下文 = 整页截图) */
|
|
784
|
-
function chainExprOrNull(context) {
|
|
1033
|
+
function chainExprOrNull(context, ctx) {
|
|
785
1034
|
const hops = collectHops(context);
|
|
786
1035
|
if (!hops.length)
|
|
787
1036
|
return '';
|
|
788
|
-
return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${
|
|
1037
|
+
return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${stringExpr(h.value, ctx, `定位链 ${h.type}`)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
|
|
789
1038
|
}
|
|
790
1039
|
/**
|
|
791
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>;
|