@kkeve/suite 0.0.13 → 0.0.17
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/bin/keve.js +2 -1
- package/dist/commands/clean.js +2 -2
- package/dist/commands/config.js +7 -7
- package/dist/commands/init.js +2 -2
- package/dist/commands/report.js +7 -7
- package/dist/commands/run.js +16 -12
- package/dist/commands/test.js +7 -7
- package/dist/config.js +7 -7
- package/dist/core/action-log-writer.js +4 -1
- package/dist/core/decorator/keve-decorators.d.ts +128 -0
- package/dist/core/decorator/keve-decorators.js +254 -0
- package/dist/core/decorator/keve-host.d.ts +40 -0
- package/dist/core/decorator/keve-host.js +33 -0
- package/dist/core/{keve-registry.d.ts → decorator/keve-registry.d.ts} +19 -0
- package/dist/core/{keve-registry.js → decorator/keve-registry.js} +13 -1
- package/dist/core/generateReport/report.d.ts +1 -1
- package/dist/core/generateReport/report.js +2 -2
- package/dist/core/generateReport/reportData.d.ts +3 -0
- package/dist/core/generateReport/reportData.js +55 -18
- package/dist/core/judge/confidence.d.ts +119 -0
- package/dist/core/judge/confidence.js +295 -0
- package/dist/core/judge/goal-heuristics.d.ts +32 -0
- package/dist/core/judge/goal-heuristics.js +128 -0
- package/dist/core/judge/pngCodec.d.ts +23 -0
- package/dist/core/judge/pngCodec.js +215 -0
- package/dist/core/judge/screenshotDiff.d.ts +112 -0
- package/dist/core/judge/screenshotDiff.js +420 -0
- package/dist/core/page-object/definePage.d.ts +95 -0
- package/dist/core/page-object/definePage.js +62 -0
- package/dist/core/page-object/elementResolver.d.ts +32 -0
- package/dist/core/page-object/elementResolver.js +236 -0
- package/dist/core/page-object/engine-types.d.ts +140 -0
- package/dist/core/page-object/engine-types.js +34 -0
- package/dist/core/sso-cookie.d.ts +18 -0
- package/dist/core/sso-cookie.js +10 -0
- package/dist/dsl/index.d.ts +15 -0
- package/dist/dsl/index.js +29 -0
- package/dist/dsl/locatorSource.d.ts +25 -0
- package/dist/dsl/locatorSource.js +323 -0
- package/dist/dsl/ops.d.ts +46 -0
- package/dist/dsl/ops.js +95 -0
- package/dist/dsl/toDecorator.d.ts +98 -0
- package/dist/dsl/toDecorator.js +747 -0
- package/dist/engine-cypress/adapter.d.ts +108 -0
- package/dist/engine-cypress/adapter.js +514 -0
- package/dist/engine-cypress/agent-page.d.ts +159 -0
- package/dist/engine-cypress/agent-page.js +566 -0
- package/dist/engine-cypress/agentDom.d.ts +22 -0
- package/dist/engine-cypress/agentDom.js +590 -0
- package/dist/engine-cypress/bridge.d.ts +39 -0
- package/dist/engine-cypress/bridge.js +598 -0
- package/dist/engine-cypress/cdp.d.ts +78 -0
- package/dist/engine-cypress/cdp.js +339 -0
- package/dist/engine-cypress/host.d.ts +22 -0
- package/dist/engine-cypress/host.js +480 -0
- package/dist/engine-cypress/index.d.ts +26 -0
- package/dist/engine-cypress/index.js +58 -0
- package/dist/engine-cypress/keveGoal.d.ts +43 -0
- package/dist/engine-cypress/keveGoal.js +265 -0
- package/dist/engine-cypress/pageOps.d.ts +41 -0
- package/dist/engine-cypress/pageOps.js +439 -0
- package/dist/engine-cypress/reporter.d.ts +20 -0
- package/dist/engine-cypress/reporter.js +128 -0
- package/dist/engine-cypress/setup.d.ts +21 -0
- package/dist/engine-cypress/setup.js +27 -0
- package/dist/engine-cypress/shotBuffer.d.ts +40 -0
- package/dist/engine-cypress/shotBuffer.js +41 -0
- package/dist/engine-cypress/state.d.ts +56 -0
- package/dist/engine-cypress/state.js +81 -0
- package/dist/engine-playwright/engineAdapter.d.ts +78 -0
- package/dist/engine-playwright/engineAdapter.js +371 -0
- package/dist/engine-playwright/global-setup.d.ts +28 -0
- package/dist/engine-playwright/global-setup.js +142 -0
- package/dist/{core → engine-playwright}/keve-report.d.ts +0 -1
- package/dist/engine-playwright/keve-report.js +137 -0
- package/dist/{core → engine-playwright}/keve-test.d.ts +10 -8
- package/dist/{core → engine-playwright}/keve-test.js +257 -207
- package/dist/{core → engine-playwright}/pw-config.js +8 -7
- package/dist/index.d.ts +13 -8
- package/dist/index.js +43 -28
- package/dist/page-agent/agent.d.ts +98 -7
- package/dist/page-agent/agent.js +338 -84
- package/dist/page-agent/diagnostics.d.ts +184 -0
- package/dist/page-agent/diagnostics.js +377 -0
- package/dist/page-agent/hooks.d.ts +3 -2
- package/dist/page-agent/hooks.js +4 -3
- package/dist/page-agent/page-like.d.ts +116 -0
- package/dist/page-agent/page-like.js +14 -0
- package/dist/page-agent/playwright-page.d.ts +20 -0
- package/dist/page-agent/playwright-page.js +98 -0
- package/dist/page-agent/system-prompt.d.ts +7 -0
- package/dist/page-agent/system-prompt.js +10 -0
- package/dist/page-agent/system_prompt.md +1 -1
- package/dist/page-agent/tools.d.ts +1 -1
- package/dist/page-agent/tools.js +441 -9
- package/doc/skills/keve-e2e-gen/references/script-generation-guide.md +11 -0
- package/package.json +24 -11
- package/scripts/gen-system-prompt.mjs +36 -0
- package/scripts/run-dsl-task.mjs +619 -0
- package/scripts/runtime/cypress-preprocessor.mjs +44 -0
- package/scripts/runtime/cypress.config.mjs +35 -0
- package/scripts/runtime/playwright.config.mjs +41 -0
- package/scripts/verify-shot-diff.mjs +243 -0
- package/dist/commands/auth-update.d.ts +0 -16
- package/dist/commands/auth-update.js +0 -88
- package/dist/core/auth.d.ts +0 -17
- package/dist/core/auth.js +0 -224
- package/dist/core/cdp-resolver.d.ts +0 -5
- package/dist/core/cdp-resolver.js +0 -93
- package/dist/core/global-setup.d.ts +0 -6
- package/dist/core/global-setup.js +0 -113
- package/dist/core/keve-decorators.d.ts +0 -51
- package/dist/core/keve-decorators.js +0 -95
- package/dist/core/keve-report.js +0 -376
- /package/dist/core/{keve-aspect.d.ts → decorator/keve-aspect.d.ts} +0 -0
- /package/dist/core/{keve-aspect.js → decorator/keve-aspect.js} +0 -0
- /package/dist/{core → engine-playwright}/pw-config.d.ts +0 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* DSL → @kkeve/suite 装饰器脚本(纯投影,零 DB / 零框架依赖)
|
|
4
|
+
*
|
|
5
|
+
* 输入:**已展开、已解析**的 stepDsl 清单(内嵌场景已递归展开、source 元素库
|
|
6
|
+
* 引用已解析成 locator 跳、变量已可查表)。输出:可被 Playwright 与 Cypress
|
|
7
|
+
* 两个引擎同时执行的装饰器脚本源码。
|
|
8
|
+
*
|
|
9
|
+
* 为什么生成器放在 suite 包而不是调用方:
|
|
10
|
+
* 1. 同一份 DSL 必须只投影出一种脚本 —— 两端报告一致的前提是执行体一致
|
|
11
|
+
* 2. 探索期由 LLM 生成的 @kkeve/suite 脚本,后续要能回填成 DSL;正反变换
|
|
12
|
+
* 放在同一个包里才闭得上环,否则两边算子语义会各自漂移
|
|
13
|
+
*
|
|
14
|
+
* 分层约定:本模块**不做任何 IO**。DB 装载、内嵌展开、元素库解析、变量表
|
|
15
|
+
* 查询全部留在调用方的 service 层(见 keve 侧 dslLoader)。这样本模块可以被
|
|
16
|
+
* Cypress 浏览器 bundle 直接引用。
|
|
17
|
+
*
|
|
18
|
+
* 关于「不支持就报错」:未知算子渲染成确定性 throw,而不是静默跳过 —— 静默
|
|
19
|
+
* 跳过会让报告出现「验证通过」的假绿。但**已知的纯文案载体**(TEXT 断言、
|
|
20
|
+
* 无 type 的 expectation)不是未知算子,它们只喂 expected 文案,不产生断言。
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.toDecorator = toDecorator;
|
|
24
|
+
const ops_js_1 = require("./ops.js");
|
|
25
|
+
// ─── 生成入口 ────────────────────────────────────────────────────────
|
|
26
|
+
/** 生成装饰器脚本源码(UTF-8 文本) */
|
|
27
|
+
function toDecorator(options) {
|
|
28
|
+
const { stepGroupId, caseName, dslList, env, account } = options;
|
|
29
|
+
const ctx = {
|
|
30
|
+
env,
|
|
31
|
+
variableList: options.variableList || [],
|
|
32
|
+
account,
|
|
33
|
+
};
|
|
34
|
+
const lines = [];
|
|
35
|
+
const L = (s) => lines.push(s);
|
|
36
|
+
L(`// ⚠️ 自动生成 —— 勿手改:改平台 DSL(stepGroup ${stepGroupId})后重新生成`);
|
|
37
|
+
L(`// 源: stepGroup ${stepGroupId}「${caseName}」→ @kkeve/suite 装饰器投影`);
|
|
38
|
+
L(`// 环境: ${env} / 账号: ${account ? account.name : '无'} / SSO 注入: ${options.ssoInject ? '开' : '关'}`);
|
|
39
|
+
L(`import { keveModel, keveScene } from '${options.importFrom || '@kkeve/suite/keve-test'}';`);
|
|
40
|
+
L('');
|
|
41
|
+
L(`// 平台定位链透传:context 多跳 → ElementRef.chain`);
|
|
42
|
+
L(`const C = (...hops: any[]) => ({`);
|
|
43
|
+
L(` chain: hops.filter(h => h && String(h.type || '') !== '' && String(h?.value ?? '') !== '')`);
|
|
44
|
+
L(`});`);
|
|
45
|
+
L('');
|
|
46
|
+
L(`@keveModel('M_SG${stepGroupId}', ${q(caseName)})`);
|
|
47
|
+
L(`export class Model_SG${stepGroupId} {`);
|
|
48
|
+
L(` @keveScene('S${stepGroupId}', ${q(`${caseName}(源 DSL 转译)`)})`);
|
|
49
|
+
L(` static async scene_SG${stepGroupId}({ engine, keveGoal }: any) {`);
|
|
50
|
+
for (let i = 0; i < dslList.length; i++) {
|
|
51
|
+
const d = dslList[i];
|
|
52
|
+
const op = String(d?.operation || '');
|
|
53
|
+
// 就绪门预注册:OPEN_PAGE 后紧跟的 WAIT_RESPONSE 必须合并进导航步骤体
|
|
54
|
+
//(先注册监听、再导航),否则导航期间到达的响应会因「先导航后注册」而丢失。
|
|
55
|
+
if (op === 'OPEN_PAGE') {
|
|
56
|
+
let j = i + 1;
|
|
57
|
+
const gates = [];
|
|
58
|
+
while (j < dslList.length && String(dslList[j]?.operation || '') === 'WAIT_RESPONSE') {
|
|
59
|
+
gates.push(dslList[j++]);
|
|
60
|
+
}
|
|
61
|
+
if (gates.length) {
|
|
62
|
+
const seg = renderOpenPageWithGates(d, gates, ctx);
|
|
63
|
+
if (seg.length)
|
|
64
|
+
appendBlock(lines, seg);
|
|
65
|
+
i = j - 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const seg = renderStep(d, ctx);
|
|
70
|
+
if (seg.length)
|
|
71
|
+
appendBlock(lines, seg);
|
|
72
|
+
}
|
|
73
|
+
L(` }`);
|
|
74
|
+
L(`}`);
|
|
75
|
+
L('');
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
function appendBlock(lines, seg) {
|
|
79
|
+
lines.push('');
|
|
80
|
+
for (const s of seg)
|
|
81
|
+
lines.push(s);
|
|
82
|
+
}
|
|
83
|
+
function renderStep(d, ctx) {
|
|
84
|
+
const op = d.operation;
|
|
85
|
+
// operation 为空 = 内嵌场景引用:正常路径下装载器已递归展开,走到这里说明
|
|
86
|
+
// 展开漏了(如引用了空场景)。静默跳过会让后续步骤在错误页面上跑,故显式报错。
|
|
87
|
+
if (!op) {
|
|
88
|
+
return [
|
|
89
|
+
`throw new Error(${q(`内嵌场景引用未展开(步骤「${String(d.text || '')}」,id=${d.id ?? '未知'})`)});`,
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
if (!(0, ops_js_1.isKnownOperation)(op)) {
|
|
93
|
+
return [
|
|
94
|
+
`throw new Error(${q(`不支持的 DSL 操作「${op}」: ${String(d.text || '')} —— 请移除该步骤,或先为该算子补齐转译规则`)});`,
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
const out = [];
|
|
98
|
+
const stepText = String(d.text || op);
|
|
99
|
+
const expTexts = (d.expectation || []).map((e) => String(e.text || '')).filter(Boolean);
|
|
100
|
+
const expected = expTexts.length ? expTexts.join(';') : '操作完成,页面正常响应';
|
|
101
|
+
const chain = chainExpr(d.context);
|
|
102
|
+
const expLines = [];
|
|
103
|
+
for (const e of d.expectation || []) {
|
|
104
|
+
const a = renderExpectation(e, ctx);
|
|
105
|
+
if (a)
|
|
106
|
+
expLines.push(a);
|
|
107
|
+
}
|
|
108
|
+
const emit = (body) => {
|
|
109
|
+
out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
|
|
110
|
+
for (const b of [...body, ...expLines])
|
|
111
|
+
out.push(` ${b}`);
|
|
112
|
+
out.push(`});`);
|
|
113
|
+
};
|
|
114
|
+
switch (op) {
|
|
115
|
+
case 'OPEN_PAGE': {
|
|
116
|
+
const resolved = resolveOpenPage(d, ctx);
|
|
117
|
+
const body = [];
|
|
118
|
+
if (resolved.url) {
|
|
119
|
+
body.push(`// 目标页面解析来源: ${resolved.via}`);
|
|
120
|
+
body.push(`await engine.navigate(${q(resolved.url)});`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
body.push(`throw new Error(${q(`OPEN_PAGE 无法解析有效 URL(步骤文案「${stepText}」,value=${JSON.stringify(d.value)})`
|
|
124
|
+
+ ' —— 该步骤未配置页面变量,且文案未能匹配到变量表中的页面模板')});`);
|
|
125
|
+
}
|
|
126
|
+
body.push(...waitExprs(d));
|
|
127
|
+
emit(body);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'REDIRECT_TO': {
|
|
131
|
+
// 历史算子:目标 URL 常在 value,也可能写在步骤文案的引号里
|
|
132
|
+
const direct = resolveValue(d.value, ctx);
|
|
133
|
+
const fromText = /[「"'“]([^」"'”]{4,})[」"'”]/.exec(String(d.text || ''))?.[1] || '';
|
|
134
|
+
const raw = direct || fromText;
|
|
135
|
+
const url = /^https?:\/\//i.test(raw) ? raw : raw ? `https://${raw}` : '';
|
|
136
|
+
if (!url) {
|
|
137
|
+
throw new Error(`REDIRECT_TO 未解析到目标 URL: ${stepText}`);
|
|
138
|
+
}
|
|
139
|
+
emit([`await engine.navigate(${q(url)});`, ...waitExprs(d)]);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case 'CLICK_ELEMENT':
|
|
143
|
+
emit([`await engine.click(${chain});`, ...waitExprs(d)]);
|
|
144
|
+
break;
|
|
145
|
+
case 'FORCE_CLICK_ELEMENT':
|
|
146
|
+
emit([`await engine.forceClick(${chain});`, ...waitExprs(d)]);
|
|
147
|
+
break;
|
|
148
|
+
case 'DB_CLICK_ELEMENT':
|
|
149
|
+
emit([`await engine.dblClick(${chain});`, ...waitExprs(d)]);
|
|
150
|
+
break;
|
|
151
|
+
case 'CHECK_TEXT': {
|
|
152
|
+
// 平台把「校验文案」建模成一个步骤,真正的期望放在 expectation 里
|
|
153
|
+
// (type=TEXT_EXIST)。所以这里不产生操作体,只让 expLines 生效。
|
|
154
|
+
// 少数步骤把待检文案直接放在 value:此时补一条包含断言,与老引擎
|
|
155
|
+
// cy.contains(value).should('be.visible') 语义对齐。
|
|
156
|
+
const val = resolveValue(d.value, ctx);
|
|
157
|
+
emit(val ? [`await engine.expectText(${q(val)}, ${chain});`] : []);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'INPUT_ELEMENT': {
|
|
161
|
+
const val = resolveValue(d.value, ctx);
|
|
162
|
+
if (!val) {
|
|
163
|
+
throw new Error(`INPUT_ELEMENT 缺少输入值,拒绝生成可误报通过的跳过步骤: ${stepText}`);
|
|
164
|
+
}
|
|
165
|
+
emit([`await engine.type(${q(val)}, ${chain});`, ...waitExprs(d)]);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case 'MOVE_ELEMENT':
|
|
169
|
+
emit([`await engine.hover(${chain});`, ...waitExprs(d)]);
|
|
170
|
+
break;
|
|
171
|
+
case 'SCROLL':
|
|
172
|
+
emit([`await engine.scroll(${chain});`, ...waitExprs(d)]);
|
|
173
|
+
break;
|
|
174
|
+
case 'CLEAN':
|
|
175
|
+
emit([`await engine.clear(${chain});`, ...waitExprs(d)]);
|
|
176
|
+
break;
|
|
177
|
+
case 'WAIT_EVENTS': {
|
|
178
|
+
// 元素就绪门:context 带 selector 时按元素真实状态等待,不退化成固定延时
|
|
179
|
+
const sel = firstContextSelector(d.context);
|
|
180
|
+
if (sel) {
|
|
181
|
+
const budget = Math.round(waitMs(d) || 10000);
|
|
182
|
+
emit([`await engine.waitForElement(${q(sel)}, { timeout: ${budget} });`]);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
const ms = waitMs(d);
|
|
186
|
+
if (ms > 0)
|
|
187
|
+
emit([`await engine.wait(${ms});`]);
|
|
188
|
+
else
|
|
189
|
+
throw new Error(`WAIT_EVENTS 缺少有效时长或目标元素: ${stepText}`);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'WAIT_RESPONSE': {
|
|
193
|
+
const urlPart = resolveValue(d.value, ctx) || String(d.urlPart || '');
|
|
194
|
+
if (!urlPart) {
|
|
195
|
+
throw new Error(`WAIT_RESPONSE 缺少目标 URL,拒绝生成可误报通过的跳过步骤: ${stepText}`);
|
|
196
|
+
}
|
|
197
|
+
const opts = waitResponseOptions(d);
|
|
198
|
+
emit([`await engine.waitForResponse(${q(urlPart)}${opts});`]);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'REFRESH_PAGE':
|
|
202
|
+
emit([`await engine.refresh();`, ...waitExprs(d)]);
|
|
203
|
+
break;
|
|
204
|
+
case 'DRAGGING': {
|
|
205
|
+
// 拖拽:context 为源,value/child 携带目标链;缺失目标不得伪装成悬停成功
|
|
206
|
+
const valueCtx = (typeof d.value === 'object' && d.value !== null ? d.value.context : undefined);
|
|
207
|
+
const targetCtx = valueCtx || d.child?.context || d.child;
|
|
208
|
+
if (Array.isArray(targetCtx) && targetCtx.length) {
|
|
209
|
+
emit([`await engine.drag(${chain}, ${chainExpr(targetCtx)});`, ...waitExprs(d)]);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
throw new Error(`DRAGGING 缺少目标元素,拒绝退化为悬停: ${stepText}`);
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'WRITE_GLOBAL': {
|
|
217
|
+
// 真实数据里表达式写在 context 的 javascript 跳上,value 只是变量名
|
|
218
|
+
const jsHop = firstHopOfType(d.context, 'javascript');
|
|
219
|
+
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
|
|
223
|
+
throw new Error(`WRITE_GLOBAL 缺少表达式: ${stepText}`);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'SCREEN_SHOT':
|
|
227
|
+
case 'SCREENSHOT':
|
|
228
|
+
case 'TAKE_SCREENSHOT': {
|
|
229
|
+
// 平台截图步骤 → 基线对比断言(无基线建候选,超阈值抛 ScreenshotDiffError)
|
|
230
|
+
const shotName = resolveValue(d.value, ctx) || sanitizeShotKey(stepText);
|
|
231
|
+
const shotChain = chainExprOrNull(d.context);
|
|
232
|
+
// 区域截图容错:元素不在当前页面(权限阻断等)时降级为整页截图,
|
|
233
|
+
// 是否一致由基线比对决定,截图本身不做权限硬门。
|
|
234
|
+
if (shotChain) {
|
|
235
|
+
out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
|
|
236
|
+
out.push(` try {`);
|
|
237
|
+
out.push(` await engine.screenshot(${q(shotName)}, ${shotChain});`);
|
|
238
|
+
out.push(` } catch (_shotErr: any) {`);
|
|
239
|
+
out.push(` // 区域截图失败(元素不在页面上,如权限阻断/加载异常)—— 降级为整页截图`);
|
|
240
|
+
out.push(` if (/timeout|waiting.*locator|not found|no element/i.test(String(_shotErr?.message || ''))) {`);
|
|
241
|
+
out.push(` console.warn('[dsl] 区域截图降级为整页截图:', ${q(shotName)}, _shotErr?.message);`);
|
|
242
|
+
out.push(` await engine.screenshot(${q(shotName)});`);
|
|
243
|
+
out.push(` } else { throw _shotErr; }`);
|
|
244
|
+
out.push(` }`);
|
|
245
|
+
for (const e of d.expectation || []) {
|
|
246
|
+
const a = renderExpectation(e, ctx);
|
|
247
|
+
if (a)
|
|
248
|
+
out.push(` ${a}`);
|
|
249
|
+
}
|
|
250
|
+
out.push(`});`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
emit([`await engine.screenshot(${q(shotName)});`, ...waitExprs(d)]);
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
default:
|
|
258
|
+
// isKnownOperation 已放行全部已知算子;走到这里说明表与 switch 不同步
|
|
259
|
+
emit([
|
|
260
|
+
`throw new Error(${q(`算子「${op}」已在算子表登记但缺少转译实现: ${stepText} —— 请补齐 toDecorator 的 switch 分支`)});`,
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* OPEN_PAGE + 紧随其后的 WAIT_RESPONSE 就绪门,合并为一个 keveGoal 步骤。
|
|
267
|
+
*
|
|
268
|
+
* 预注册时序:
|
|
269
|
+
* const __gate0 = engine.waitForResponse(urlPart, {...}); // 调用瞬间同步注册监听
|
|
270
|
+
* await engine.navigate(url); // 然后才触发导航
|
|
271
|
+
* await __gate0; // 导航期间的响应也能命中
|
|
272
|
+
*/
|
|
273
|
+
function renderOpenPageWithGates(d, gates, ctx) {
|
|
274
|
+
const out = [];
|
|
275
|
+
const stepText = String(d.text || 'OPEN_PAGE');
|
|
276
|
+
const gateTexts = gates.map((g) => String(g.text || '')).filter(Boolean);
|
|
277
|
+
const gateExpects = gates
|
|
278
|
+
.flatMap((g) => (g.expectation || []).map((e) => String(e.text || '')))
|
|
279
|
+
.filter(Boolean);
|
|
280
|
+
const expected = [...gateTexts, ...gateExpects].filter(Boolean).join(';') || '打开目标页面并等待就绪响应';
|
|
281
|
+
const resolved = resolveOpenPage(d, ctx);
|
|
282
|
+
const body = [];
|
|
283
|
+
if (!resolved.url) {
|
|
284
|
+
body.push(`throw new Error(${q(`OPEN_PAGE 无法解析有效 URL(步骤文案「${stepText}」,value=${JSON.stringify(d.value)})`
|
|
285
|
+
+ ' —— 该步骤未配置页面变量,且文案未能匹配到变量表中的页面模板')});`);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
gates.forEach((g, gi) => {
|
|
289
|
+
const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
|
|
290
|
+
if (!urlPart)
|
|
291
|
+
return; // 就绪门无目标 URL 时跳过注册,由普通 WAIT_RESPONSE 分支报错
|
|
292
|
+
body.push(`const __gate${gi} = engine.waitForResponse(${q(urlPart)}${waitResponseOptions(g)}); // 预注册就绪门`);
|
|
293
|
+
});
|
|
294
|
+
body.push(`// 目标页面解析来源: ${resolved.via}`);
|
|
295
|
+
body.push(`await engine.navigate(${q(resolved.url)});`);
|
|
296
|
+
gates.forEach((g, gi) => {
|
|
297
|
+
const urlPart = resolveValue(g.value, ctx) || String(g.urlPart || '');
|
|
298
|
+
if (urlPart)
|
|
299
|
+
body.push(`await __gate${gi}; // 等 ${String(g.text || '就绪响应')}`);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
out.push(`await keveGoal({ step: ${q(stepText)}, expected: ${q(expected)} }, async () => {`);
|
|
303
|
+
for (const b of body)
|
|
304
|
+
out.push(` ${b}`);
|
|
305
|
+
out.push(`});`);
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
// ─── 断言转译 ────────────────────────────────────────────────────────
|
|
309
|
+
/** 渲染一条 expectation;返回 null 表示「纯文案载体,不产生断言」 */
|
|
310
|
+
function renderExpectation(e, ctx) {
|
|
311
|
+
const type = String(e.type || '');
|
|
312
|
+
// 无 type / TEXT:纯「期望文案」载体,只喂 keveGoal.expected,不是断言
|
|
313
|
+
if (!type || (0, ops_js_1.isTextOnlyAssert)(type))
|
|
314
|
+
return null;
|
|
315
|
+
const comment = e.text ? ` // ${e.text}` : '';
|
|
316
|
+
const chain = chainExpr(e.context);
|
|
317
|
+
const val = resolveExpectationValue(e, ctx);
|
|
318
|
+
if (!(0, ops_js_1.isKnownAssert)(type)) {
|
|
319
|
+
// 未知断言不得静默跳过(旧实现只吐注释 = 断言不执行,是假绿的来源之一)
|
|
320
|
+
return `throw new Error(${q(`不支持的断言类型「${type}」: ${e.text || ''} —— 请移除该断言,或先为该类型补齐转译规则`)});`;
|
|
321
|
+
}
|
|
322
|
+
switch (type) {
|
|
323
|
+
case 'ELEMENT_EXIST':
|
|
324
|
+
return `await engine.expectExists(${chain});${comment}`;
|
|
325
|
+
case 'ELEMENT_NOT_EXIST':
|
|
326
|
+
return `await engine.expectNotExists(${chain});${comment}`;
|
|
327
|
+
case 'ELEMENT_VISIBLE':
|
|
328
|
+
return `await engine.expectVisible(${chain});${comment}`;
|
|
329
|
+
case 'ELEMENT_NOT_VISIBLE':
|
|
330
|
+
return `await engine.expectHidden(${chain});${comment}`;
|
|
331
|
+
case 'TEXT_EXIST':
|
|
332
|
+
// 平台把「校验页面标题包含 X」也存成 TEXT_EXIST + 默认 body 上下文。
|
|
333
|
+
// 直接断言 body 文本会把标题里的命中判成失败(页面标题 ≠ 正文文案),
|
|
334
|
+
// 按步骤/断言文案识别出页面标题语义,落到 title 维度。
|
|
335
|
+
if (isPageTitleAssertion(e)) {
|
|
336
|
+
return `await engine.expectTitle(${q(val)});${comment}`;
|
|
337
|
+
}
|
|
338
|
+
return `await engine.expectText(${q(val)}, ${chain});${comment}`;
|
|
339
|
+
case 'TEXT_NOT_EXIST':
|
|
340
|
+
// 平台里存在「只打开页面」类用例:断言存成 TEXT_NOT_EXIST 但期望值为空串。
|
|
341
|
+
// 空串对任何页面都成立,属于无实际约束的占位断言;若照原样下发会变成
|
|
342
|
+
// 「body 文本不包含空串」并永远失败(旧 Cypress 编译器同样只在期望值非空时
|
|
343
|
+
// 才生成 text-absent 校验)。这里按同一语义跳过。
|
|
344
|
+
if (!String(val || '').trim())
|
|
345
|
+
return null;
|
|
346
|
+
return `await engine.expectNotText(${q(val)}, ${chain});${comment}`;
|
|
347
|
+
case 'TEXT_EQUAL':
|
|
348
|
+
return `await engine.expectTextEquals(${q(val)}, ${chain});${comment}`;
|
|
349
|
+
case 'EXPECTED_VALUE':
|
|
350
|
+
return renderExpectedValue(e, val, chain, comment);
|
|
351
|
+
case 'EXPECTED__PAGET_VALUE':
|
|
352
|
+
return renderPageValue(e, val, comment);
|
|
353
|
+
case 'SCREEN_SHOT_COMPARE':
|
|
354
|
+
case 'SCREENSHOT_COMPARE':
|
|
355
|
+
case 'IMAGE_COMPARE': {
|
|
356
|
+
// value 为基线 key(老平台基线 id 可回溯迁移来源)
|
|
357
|
+
const shotName = String(val || e.text || '').trim() || sanitizeShotKey(e.text || 'baseline');
|
|
358
|
+
const shotChain = chainExprOrNull(e.context);
|
|
359
|
+
if (shotChain) {
|
|
360
|
+
return [
|
|
361
|
+
`try { await engine.screenshot(${q(shotName)}, ${shotChain}); }`,
|
|
362
|
+
`catch (_shotErr: any) {`,
|
|
363
|
+
` if (/timeout|waiting.*locator|not found|no element/i.test(String(_shotErr?.message || ''))) {`,
|
|
364
|
+
` console.warn('[dsl] 区域截图降级为整页截图:', ${q(shotName)}, _shotErr?.message);`,
|
|
365
|
+
` await engine.screenshot(${q(shotName)});`,
|
|
366
|
+
` } else { throw _shotErr; }`,
|
|
367
|
+
`}`,
|
|
368
|
+
].join(' ');
|
|
369
|
+
}
|
|
370
|
+
return `await engine.screenshot(${q(shotName)});${comment}`;
|
|
371
|
+
}
|
|
372
|
+
default:
|
|
373
|
+
return `throw new Error(${q(`断言「${type}」已在算子表登记但缺少转译实现: ${e.text || ''} —— 请补齐 toDecorator`)});`;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 判断一条断言表达的是「页面标题」而不是正文文本。
|
|
378
|
+
*
|
|
379
|
+
* 历史数据(stepDsl.expectation)里没有独立的 title 断言类型:
|
|
380
|
+
* 「校验页面标题包含 EVE」被存成 TEXT_EXIST,且 context 为空 → 投影时默认成
|
|
381
|
+
* body 定位,语义从「标题」漂移成「正文」。这里只在文案明确提到页面标题、
|
|
382
|
+
* 且没有显式元素上下文时才改判,避免影响真正的正文断言。
|
|
383
|
+
*/
|
|
384
|
+
function isPageTitleAssertion(e) {
|
|
385
|
+
if (String(e?.ctype || '').toLowerCase() === 'title')
|
|
386
|
+
return true;
|
|
387
|
+
if (collectHops(e?.context).length)
|
|
388
|
+
return false;
|
|
389
|
+
const text = `${e?.text || ''}`;
|
|
390
|
+
return /页面\s*标题|网页\s*标题|page\s*title/i.test(text);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* EXPECTED_VALUE:比「元素某个属性/文本」与期望值的关系。
|
|
394
|
+
*
|
|
395
|
+
* 真实数据里出现的组合(ctype | cexpression):
|
|
396
|
+
* text | include / eq (52 条)
|
|
397
|
+
* class | include / not.include(15 条)
|
|
398
|
+
* 另留 placeholder / style / index 三个历史维度。
|
|
399
|
+
*/
|
|
400
|
+
function renderExpectedValue(e, val, chain, comment) {
|
|
401
|
+
const ctype = String(e.ctype || 'text').toLowerCase();
|
|
402
|
+
const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'include'));
|
|
403
|
+
switch (ctype) {
|
|
404
|
+
case 'text':
|
|
405
|
+
if (mode === 'eq') {
|
|
406
|
+
return negated
|
|
407
|
+
? `await engine.expectTextNotEquals(${q(val)}, ${chain});${comment}`
|
|
408
|
+
: `await engine.expectTextEquals(${q(val)}, ${chain});${comment}`;
|
|
409
|
+
}
|
|
410
|
+
return negated
|
|
411
|
+
? `await engine.expectNotText(${q(val)}, ${chain});${comment}`
|
|
412
|
+
: `await engine.expectText(${q(val)}, ${chain});${comment}`;
|
|
413
|
+
case 'class':
|
|
414
|
+
return negated
|
|
415
|
+
? `await engine.expectNotClass(${q(val)}, ${chain});${comment}`
|
|
416
|
+
: `await engine.expectClass(${q(val)}, ${chain});${comment}`;
|
|
417
|
+
case 'placeholder':
|
|
418
|
+
return `await engine.expectAttribute(${q('placeholder')}, ${q(val)}, ${chain});${comment}`;
|
|
419
|
+
case 'style':
|
|
420
|
+
return mode === 'eq'
|
|
421
|
+
? `await engine.expectAttribute(${q('style')}, ${q(val)}, ${chain});${comment}`
|
|
422
|
+
: `await engine.expectAttributeContains(${q('style')}, ${q(val)}, ${chain});${comment}`;
|
|
423
|
+
case 'index': {
|
|
424
|
+
// index + eq:数「文本非空的元素个数」
|
|
425
|
+
const n = Number(val);
|
|
426
|
+
if (!Number.isFinite(n)) {
|
|
427
|
+
return `throw new Error(${q(`EXPECTED_VALUE(ctype=index) 的期望值不是数字: ${val}`)});`;
|
|
428
|
+
}
|
|
429
|
+
return `await engine.expectNonEmptyCount(${n}, ${chain});${comment}`;
|
|
430
|
+
}
|
|
431
|
+
default:
|
|
432
|
+
// 维度未知时不能当成通过 —— 但也不能硬失败:ctype 缺失的历史数据按 text 处理,
|
|
433
|
+
// 明确写了未知维度的才报错。
|
|
434
|
+
if (!e.ctype) {
|
|
435
|
+
return `await engine.expectText(${q(val)}, ${chain});${comment}`;
|
|
436
|
+
}
|
|
437
|
+
return `throw new Error(${q(`不支持的 EXPECTED_VALUE 比较维度「${ctype}」: ${e.text || ''} —— 请改用 text/class/placeholder/style/index`)});`;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* EXPECTED__PAGET_VALUE:比页面级属性(历史拼写错误,保留原名)。
|
|
442
|
+
* 真实数据里只有 ctype=url + cexpression=eq。
|
|
443
|
+
*/
|
|
444
|
+
function renderPageValue(e, val, comment) {
|
|
445
|
+
const ctype = String(e.ctype || 'url').toLowerCase();
|
|
446
|
+
const { mode, negated } = (0, ops_js_1.parseComparison)(String(e.cexpression || 'eq'));
|
|
447
|
+
if (ctype === 'url') {
|
|
448
|
+
if (mode === 'eq') {
|
|
449
|
+
return negated
|
|
450
|
+
? `await engine.expectUrlNotEquals(${q(val)});${comment}`
|
|
451
|
+
: `await engine.expectUrlEquals(${q(val)});${comment}`;
|
|
452
|
+
}
|
|
453
|
+
return negated
|
|
454
|
+
? `await engine.expectUrlNotContains(${q(val)});${comment}`
|
|
455
|
+
: `await engine.expectUrlContains(${q(val)});${comment}`;
|
|
456
|
+
}
|
|
457
|
+
if (ctype === 'title') {
|
|
458
|
+
return mode === 'eq'
|
|
459
|
+
? `await engine.expectTitleEquals(${q(val)});${comment}`
|
|
460
|
+
: `await engine.expectTitle(${q(val)});${comment}`;
|
|
461
|
+
}
|
|
462
|
+
return `throw new Error(${q(`不支持的页面属性断言维度「${ctype}」: ${e.text || ''} —— 请改用 url/title`)});`;
|
|
463
|
+
}
|
|
464
|
+
// ─── 值 / 链解析 ─────────────────────────────────────────────────────
|
|
465
|
+
/**
|
|
466
|
+
* OPEN_PAGE 目标页面解析(三级):
|
|
467
|
+
* 1) 步骤自带 value → 直接解析
|
|
468
|
+
* 2) value 缺失(平台存在大量 value=NULL 的「页面向导」步骤)→ 按步骤文案关键词
|
|
469
|
+
* 匹配变量表中的页面模板,并做 ${key} 参数替换
|
|
470
|
+
* 3) 仍无解 → 返回空,由调用方生成显式报错(避免空白页导致后续步骤集体超时)
|
|
471
|
+
*/
|
|
472
|
+
function resolveOpenPage(d, ctx) {
|
|
473
|
+
const direct = resolveValue(d.value, ctx);
|
|
474
|
+
if (/^https?:\/\//i.test(direct))
|
|
475
|
+
return { url: direct, via: 'value' };
|
|
476
|
+
const text = String(d.text || '');
|
|
477
|
+
if (text) {
|
|
478
|
+
const row = pickVariableByStepText(text, ctx.variableList);
|
|
479
|
+
if (row) {
|
|
480
|
+
const url = buildVariableUrl(row, ctx.env, textOverrides(text), d.value);
|
|
481
|
+
if (/^https?:\/\//i.test(url))
|
|
482
|
+
return { url, via: `文案匹配变量「${row.name}」` };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return { url: '', via: '' };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 步骤文案关键词 → 变量表页面名片段。
|
|
489
|
+
* 顺序敏感:更具体的模式必须排在前面。
|
|
490
|
+
*
|
|
491
|
+
* ⚠️ 这张表是页面模板的**启发式兜底**,只在 DSL 没有显式绑定页面变量时生效。
|
|
492
|
+
* 新增页面时优先在可视化编辑器里显式绑定变量,而不是往这里加关键词。
|
|
493
|
+
*/
|
|
494
|
+
const PAGE_KEYWORD_MAP = [
|
|
495
|
+
[/数据准备数据集.*(编辑|模型编辑)|数据集.*编辑页|数据集编辑页/, '数据准备数据集-编辑页'],
|
|
496
|
+
[/数据准备数据集.*预览|数据集.*预览/, '数据准备数据集-预览页'],
|
|
497
|
+
[/天策业务门户|业务门户/, '天策业务门户'],
|
|
498
|
+
[/天策个人门户|个人门户/, '天策个人门户'],
|
|
499
|
+
[/看板洞察|洞察/, '看板洞察页面'],
|
|
500
|
+
[/看板编辑|看板.*编辑|编辑页|编辑态/, '看板编辑页'],
|
|
501
|
+
[/看板预览|看板.*预览|预览页|预览态/, '看板预览页'],
|
|
502
|
+
[/智能应用管理/, '智能应用管理页面'],
|
|
503
|
+
[/Agent\s*编辑/, 'Agent编辑页'],
|
|
504
|
+
[/Agent\s*体验/, 'Agent体验页'],
|
|
505
|
+
[/天玑/, '天玑主页'],
|
|
506
|
+
[/KwaiBook\s*管理/, 'KwaiBook管理页'],
|
|
507
|
+
[/KwaiBook\s*编辑/, 'KwaiBook编辑页'],
|
|
508
|
+
[/KwaiBook\s*独立/, 'KwaiBook独立页'],
|
|
509
|
+
[/KwaiBook/, 'KwaiBook首页'],
|
|
510
|
+
[/看板列表|可视化看板列表|可视化首页|看板管理|我的看板/, '可视化首页'],
|
|
511
|
+
[/移动端看板/, '移动端看板'],
|
|
512
|
+
[/大屏/, '大屏'],
|
|
513
|
+
[/组件市场/, '组件市场'],
|
|
514
|
+
[/指标取数/, '指标取数'],
|
|
515
|
+
[/SQL\s*取数|自助取数/, 'SQL取数'],
|
|
516
|
+
[/取数/, '取数'],
|
|
517
|
+
[/多维分析|多维|olap/i, '多维'],
|
|
518
|
+
[/数据准备数据集|数据集管理/, '数据准备数据集'],
|
|
519
|
+
[/数据集/, '数据集'],
|
|
520
|
+
[/数据接入/, '数据接入'],
|
|
521
|
+
[/KIM\s*推送|推送页面|推送/, '推送'],
|
|
522
|
+
[/活动页面|可视化-活动|新建活动/, '活动'],
|
|
523
|
+
[/数据应用/, '数据应用'],
|
|
524
|
+
[/门户/, '门户'],
|
|
525
|
+
[/KwaiBI\s*首页|KwaiBI首页/, 'KwaiBI首页'],
|
|
526
|
+
[/KwaiData/, 'KwaiData首页'],
|
|
527
|
+
[/[((]\s*\d+\s*\/\s*\d+\s*[))]/, '看板预览页'],
|
|
528
|
+
];
|
|
529
|
+
/** 文案中的参数覆盖值:`key=value` + `(dashboardId/sheetId)` 定位对 */
|
|
530
|
+
function textOverrides(text) {
|
|
531
|
+
const out = {};
|
|
532
|
+
const pair = /[((]\s*(\d+)\s*\/\s*(\d+)\s*(?:\/\s*([^))]*))?[))]/.exec(text);
|
|
533
|
+
if (pair) {
|
|
534
|
+
out.dashboardId = pair[1];
|
|
535
|
+
out.sheetId = pair[2];
|
|
536
|
+
}
|
|
537
|
+
const re = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([A-Za-z0-9_\-.:/]+)/g;
|
|
538
|
+
let m;
|
|
539
|
+
while ((m = re.exec(text)))
|
|
540
|
+
out[m[1]] = m[2];
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
/** 按步骤文案挑选变量表中的页面模板(同区域优先) */
|
|
544
|
+
function pickVariableByStepText(text, variableList) {
|
|
545
|
+
if (!Array.isArray(variableList) || !variableList.length)
|
|
546
|
+
return null;
|
|
547
|
+
const wantOversea = /海外|\bsgp\b/i.test(text);
|
|
548
|
+
for (const [re, phrase] of PAGE_KEYWORD_MAP) {
|
|
549
|
+
if (!re.test(text))
|
|
550
|
+
continue;
|
|
551
|
+
const cands = variableList.filter((r) => typeof r.name === 'string' && String(r.name).includes(phrase));
|
|
552
|
+
if (!cands.length)
|
|
553
|
+
continue;
|
|
554
|
+
const sameRegion = cands.filter((r) => (wantOversea ? /海外/.test(String(r.name)) : !/海外/.test(String(r.name))));
|
|
555
|
+
const pool = sameRegion.length ? sameRegion : cands;
|
|
556
|
+
const domestic = pool.filter((r) => /国内/.test(String(r.name)));
|
|
557
|
+
const pickFrom = domestic.length ? domestic : pool;
|
|
558
|
+
return pickFrom.slice().sort((a, b) => String(a.name).length - String(b.name).length)[0];
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
/** 变量行 → 目标 URL(按环境列取值 + 占位符替换) */
|
|
563
|
+
function buildVariableUrl(row, env, overrides, stepValue) {
|
|
564
|
+
const raw = pickVariableValue(row, env);
|
|
565
|
+
const kv = new Map();
|
|
566
|
+
for (const item of row.variable || []) {
|
|
567
|
+
if (item && item.key !== undefined)
|
|
568
|
+
kv.set(String(item.key), String(item.value ?? ''));
|
|
569
|
+
}
|
|
570
|
+
const stepVar = stepValue && typeof stepValue === 'object' ? stepValue.variable : undefined;
|
|
571
|
+
for (const item of stepVar || []) {
|
|
572
|
+
if (item && item.key !== undefined)
|
|
573
|
+
kv.set(String(item.key), String(item.value ?? ''));
|
|
574
|
+
}
|
|
575
|
+
for (const [k, v] of Object.entries(overrides || {}))
|
|
576
|
+
kv.set(k, v);
|
|
577
|
+
let url = String(raw ?? '').replace(/\$\{([^}]+)}/g, (m, k) => kv.has(k) ? String(kv.get(k)) : m);
|
|
578
|
+
// 模板把 id 写成字面量查询参数时 ${key} 替换无效,需直接改写同名查询参数
|
|
579
|
+
for (const [k, v] of kv) {
|
|
580
|
+
if (!v || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(k))
|
|
581
|
+
continue;
|
|
582
|
+
const re = new RegExp(`([?&])${k}=[^&#]*`);
|
|
583
|
+
if (re.test(url))
|
|
584
|
+
url = url.replace(re, `$1${k}=${v}`);
|
|
585
|
+
}
|
|
586
|
+
return url;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 变量行取值(按环境列)。
|
|
590
|
+
*
|
|
591
|
+
* 老系统语义是 `row[env]` 直取,但平台数据里存在两类情况必须兼容:
|
|
592
|
+
* 1) env 列填的是占位值(如 test 列 = "1",表示该环境未单独配置)→ 回退真实地址列
|
|
593
|
+
* 2) 地址列含 `${shareId}` 等占位符 → 交给调用方做变量替换
|
|
594
|
+
*/
|
|
595
|
+
function pickVariableValue(row, env) {
|
|
596
|
+
const cols = [env, 'test', 'online', 'rc', 'pre'];
|
|
597
|
+
const isUrl = (x) => typeof x === 'string' && /^https?:\/\//i.test(x.trim());
|
|
598
|
+
const isPlaceholder = (x) => /^(1|0|true|false)$/i.test(String(x ?? '').trim());
|
|
599
|
+
for (const c of cols)
|
|
600
|
+
if (isUrl(row[c]))
|
|
601
|
+
return String(row[c]);
|
|
602
|
+
for (const c of cols) {
|
|
603
|
+
const val = row[c];
|
|
604
|
+
if (typeof val === 'string' && val.trim() && !isPlaceholder(val))
|
|
605
|
+
return val;
|
|
606
|
+
}
|
|
607
|
+
return String(row[env] ?? '');
|
|
608
|
+
}
|
|
609
|
+
/** DSL value → 字面量:CUSTOM 插值账号占位;VARIABLE 查变量表取环境列 */
|
|
610
|
+
function resolveValue(v, ctx) {
|
|
611
|
+
if (v === null || v === undefined)
|
|
612
|
+
return '';
|
|
613
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
614
|
+
return String(v);
|
|
615
|
+
const raw = String(v.value ?? '');
|
|
616
|
+
if (v.type === 'VARIABLE') {
|
|
617
|
+
const row = ctx.variableList.find((r) => Number(r.id) === Number(raw));
|
|
618
|
+
if (!row)
|
|
619
|
+
return '';
|
|
620
|
+
return buildVariableUrl(row, ctx.env, {}, v);
|
|
621
|
+
}
|
|
622
|
+
// CUSTOM:插值账号占位 + 按变量名引用变量表
|
|
623
|
+
const interpolated = raw
|
|
624
|
+
.replace(/\$\{loginName\}/g, ctx.account?.name ?? '')
|
|
625
|
+
.replace(/\$\{pwd\}/g, ctx.account?.password ?? '');
|
|
626
|
+
return interpolated.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => {
|
|
627
|
+
const row = (ctx.variableList || []).find((r) => String(r.name) === String(name));
|
|
628
|
+
if (!row)
|
|
629
|
+
return m;
|
|
630
|
+
const u = buildVariableUrl(row, ctx.env, {}, v);
|
|
631
|
+
return u || m;
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/** 断言取期望值:VARIABLE 优先查 id,其次按 name(历史数据两种都存过) */
|
|
635
|
+
function resolveExpectationValue(e, ctx) {
|
|
636
|
+
const v = e.value;
|
|
637
|
+
if (v && typeof v === 'object' && v.type === 'VARIABLE') {
|
|
638
|
+
const raw = String(v.value ?? '');
|
|
639
|
+
const byId = ctx.variableList.find((r) => Number(r.id) === Number(raw));
|
|
640
|
+
const byName = byId || ctx.variableList.find((r) => String(r.name) === raw);
|
|
641
|
+
if (byName)
|
|
642
|
+
return pickVariableValue(byName, ctx.env) || raw;
|
|
643
|
+
}
|
|
644
|
+
return resolveValue(v, ctx);
|
|
645
|
+
}
|
|
646
|
+
/** context 多跳 → C(...) 链表达式(selector/class/includes/text 原样透传) */
|
|
647
|
+
function chainExpr(context) {
|
|
648
|
+
const hops = collectHops(context);
|
|
649
|
+
if (!hops.length)
|
|
650
|
+
return `C({ type: 'selector', value: 'body' })`;
|
|
651
|
+
return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${q(h.value)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
|
|
652
|
+
}
|
|
653
|
+
/** 同 chainExpr,但无有效跳时返回 ''(截图对比:无上下文 = 整页截图) */
|
|
654
|
+
function chainExprOrNull(context) {
|
|
655
|
+
const hops = collectHops(context);
|
|
656
|
+
if (!hops.length)
|
|
657
|
+
return '';
|
|
658
|
+
return `C(${hops.map(h => `{ type: ${q(h.type)}, value: ${q(h.value)}${h.index !== undefined ? `, index: ${h.index}` : ''} }`).join(', ')})`;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* 归一化定位跳。
|
|
662
|
+
*
|
|
663
|
+
* `source` 跳是**元素库引用**(context: [{ type:'source', value: <elementId> }]),
|
|
664
|
+
* 需要查 element 表才能拿到真实 selector —— 那是 IO,按分层约定由装载器预先
|
|
665
|
+
* 解析成 selector/class 跳。这里如果还看到 source,说明装载器漏解析了:
|
|
666
|
+
* 直接抛错,而不是把它当作空定位静默退化成 body(那会让点击打到整页元素上)。
|
|
667
|
+
*/
|
|
668
|
+
function collectHops(context) {
|
|
669
|
+
const out = [];
|
|
670
|
+
for (const h of Array.isArray(context) ? context : []) {
|
|
671
|
+
const type = String(h?.type || '');
|
|
672
|
+
const value = String(h?.value ?? h?.selector ?? '');
|
|
673
|
+
if (type === 'source') {
|
|
674
|
+
throw new Error(`定位链里的元素库引用(type=source, value=${value})未被解析成 locator`
|
|
675
|
+
+ ' —— 装载器需要先把 source 跳展开成 selector/class 跳再交给生成器');
|
|
676
|
+
}
|
|
677
|
+
if (type === 'javascript')
|
|
678
|
+
continue; // 仅 WRITE_GLOBAL 用,不参与元素定位
|
|
679
|
+
if (type === '' || value === '')
|
|
680
|
+
continue;
|
|
681
|
+
const idx = h?.index;
|
|
682
|
+
const hasIdx = idx !== undefined && idx !== null && Number(idx) > 0;
|
|
683
|
+
out.push(hasIdx ? { type, value, index: Number(idx) } : { type, value });
|
|
684
|
+
}
|
|
685
|
+
return out;
|
|
686
|
+
}
|
|
687
|
+
/** 截图基线 key 兜底:步骤文案 → 稳定 key(同步骤重复生成不变) */
|
|
688
|
+
function sanitizeShotKey(text) {
|
|
689
|
+
return String(text || 'shot').replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '_').slice(0, 40);
|
|
690
|
+
}
|
|
691
|
+
/** step.wait 数组 → engine.wait 语句 */
|
|
692
|
+
function waitExprs(d) {
|
|
693
|
+
const out = [];
|
|
694
|
+
for (const w of d?.wait || []) {
|
|
695
|
+
const ms = Number(w?.value ?? 0);
|
|
696
|
+
if (ms > 0)
|
|
697
|
+
out.push(`await engine.wait(${ms});`);
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
function waitMs(d) {
|
|
702
|
+
const v = d?.value;
|
|
703
|
+
const n = Number(typeof v === 'object' && v !== null ? v.value : v ?? 0);
|
|
704
|
+
if (n > 0)
|
|
705
|
+
return n;
|
|
706
|
+
for (const w of d?.wait || []) {
|
|
707
|
+
const m = Number(w?.value ?? 0);
|
|
708
|
+
if (m > 0)
|
|
709
|
+
return m;
|
|
710
|
+
}
|
|
711
|
+
return 0;
|
|
712
|
+
}
|
|
713
|
+
/** WAIT_RESPONSE 选项对象字面量(含前导逗号;无选项时返回空串) */
|
|
714
|
+
function waitResponseOptions(d) {
|
|
715
|
+
const w = d?.wait && !Array.isArray(d.wait) ? d.wait : {};
|
|
716
|
+
const opts = [];
|
|
717
|
+
if (Number(w.timeout) > 0)
|
|
718
|
+
opts.push(`timeout: ${Number(w.timeout)}`);
|
|
719
|
+
if (w.method)
|
|
720
|
+
opts.push(`method: ${q(String(w.method))}`);
|
|
721
|
+
if (Array.isArray(w.status) && w.status.length) {
|
|
722
|
+
opts.push(`status: [${w.status.map((x) => Number(x)).filter(Number.isFinite).join(', ')}]`);
|
|
723
|
+
}
|
|
724
|
+
if (w.required === true)
|
|
725
|
+
opts.push(`required: true`);
|
|
726
|
+
if (Number(w.afterMs) > 0)
|
|
727
|
+
opts.push(`afterMs: ${Number(w.afterMs)}`);
|
|
728
|
+
return opts.length ? `, { ${opts.join(', ')} }` : '';
|
|
729
|
+
}
|
|
730
|
+
/** context 中第一个 type=selector 跳的值(元素就绪门真检测用) */
|
|
731
|
+
function firstContextSelector(context) {
|
|
732
|
+
for (const h of Array.isArray(context) ? context : []) {
|
|
733
|
+
if (String(h?.type || '') === 'selector') {
|
|
734
|
+
const v = String(h?.value ?? h?.selector ?? '').trim();
|
|
735
|
+
if (v)
|
|
736
|
+
return v;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return '';
|
|
740
|
+
}
|
|
741
|
+
function firstHopOfType(context, type) {
|
|
742
|
+
return (Array.isArray(context) ? context : []).find((h) => String(h?.type || '') === type);
|
|
743
|
+
}
|
|
744
|
+
/** 字符串字面量(保证生成代码语法安全) */
|
|
745
|
+
function q(s) {
|
|
746
|
+
return JSON.stringify(String(s ?? ''));
|
|
747
|
+
}
|