@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.
Files changed (117) hide show
  1. package/dist/bin/keve.js +2 -1
  2. package/dist/commands/clean.js +2 -2
  3. package/dist/commands/config.js +7 -7
  4. package/dist/commands/init.js +2 -2
  5. package/dist/commands/report.js +7 -7
  6. package/dist/commands/run.js +16 -12
  7. package/dist/commands/test.js +7 -7
  8. package/dist/config.js +7 -7
  9. package/dist/core/action-log-writer.js +4 -1
  10. package/dist/core/decorator/keve-decorators.d.ts +128 -0
  11. package/dist/core/decorator/keve-decorators.js +254 -0
  12. package/dist/core/decorator/keve-host.d.ts +40 -0
  13. package/dist/core/decorator/keve-host.js +33 -0
  14. package/dist/core/{keve-registry.d.ts → decorator/keve-registry.d.ts} +19 -0
  15. package/dist/core/{keve-registry.js → decorator/keve-registry.js} +13 -1
  16. package/dist/core/generateReport/report.d.ts +1 -1
  17. package/dist/core/generateReport/report.js +2 -2
  18. package/dist/core/generateReport/reportData.d.ts +3 -0
  19. package/dist/core/generateReport/reportData.js +55 -18
  20. package/dist/core/judge/confidence.d.ts +119 -0
  21. package/dist/core/judge/confidence.js +295 -0
  22. package/dist/core/judge/goal-heuristics.d.ts +32 -0
  23. package/dist/core/judge/goal-heuristics.js +128 -0
  24. package/dist/core/judge/pngCodec.d.ts +23 -0
  25. package/dist/core/judge/pngCodec.js +215 -0
  26. package/dist/core/judge/screenshotDiff.d.ts +112 -0
  27. package/dist/core/judge/screenshotDiff.js +420 -0
  28. package/dist/core/page-object/definePage.d.ts +95 -0
  29. package/dist/core/page-object/definePage.js +62 -0
  30. package/dist/core/page-object/elementResolver.d.ts +32 -0
  31. package/dist/core/page-object/elementResolver.js +236 -0
  32. package/dist/core/page-object/engine-types.d.ts +140 -0
  33. package/dist/core/page-object/engine-types.js +34 -0
  34. package/dist/core/sso-cookie.d.ts +18 -0
  35. package/dist/core/sso-cookie.js +10 -0
  36. package/dist/dsl/index.d.ts +15 -0
  37. package/dist/dsl/index.js +29 -0
  38. package/dist/dsl/locatorSource.d.ts +25 -0
  39. package/dist/dsl/locatorSource.js +323 -0
  40. package/dist/dsl/ops.d.ts +46 -0
  41. package/dist/dsl/ops.js +95 -0
  42. package/dist/dsl/toDecorator.d.ts +98 -0
  43. package/dist/dsl/toDecorator.js +747 -0
  44. package/dist/engine-cypress/adapter.d.ts +108 -0
  45. package/dist/engine-cypress/adapter.js +514 -0
  46. package/dist/engine-cypress/agent-page.d.ts +159 -0
  47. package/dist/engine-cypress/agent-page.js +566 -0
  48. package/dist/engine-cypress/agentDom.d.ts +22 -0
  49. package/dist/engine-cypress/agentDom.js +590 -0
  50. package/dist/engine-cypress/bridge.d.ts +39 -0
  51. package/dist/engine-cypress/bridge.js +598 -0
  52. package/dist/engine-cypress/cdp.d.ts +78 -0
  53. package/dist/engine-cypress/cdp.js +339 -0
  54. package/dist/engine-cypress/host.d.ts +22 -0
  55. package/dist/engine-cypress/host.js +480 -0
  56. package/dist/engine-cypress/index.d.ts +26 -0
  57. package/dist/engine-cypress/index.js +58 -0
  58. package/dist/engine-cypress/keveGoal.d.ts +43 -0
  59. package/dist/engine-cypress/keveGoal.js +265 -0
  60. package/dist/engine-cypress/pageOps.d.ts +41 -0
  61. package/dist/engine-cypress/pageOps.js +439 -0
  62. package/dist/engine-cypress/reporter.d.ts +20 -0
  63. package/dist/engine-cypress/reporter.js +128 -0
  64. package/dist/engine-cypress/setup.d.ts +21 -0
  65. package/dist/engine-cypress/setup.js +27 -0
  66. package/dist/engine-cypress/shotBuffer.d.ts +40 -0
  67. package/dist/engine-cypress/shotBuffer.js +41 -0
  68. package/dist/engine-cypress/state.d.ts +56 -0
  69. package/dist/engine-cypress/state.js +81 -0
  70. package/dist/engine-playwright/engineAdapter.d.ts +78 -0
  71. package/dist/engine-playwright/engineAdapter.js +371 -0
  72. package/dist/engine-playwright/global-setup.d.ts +28 -0
  73. package/dist/engine-playwright/global-setup.js +142 -0
  74. package/dist/{core → engine-playwright}/keve-report.d.ts +0 -1
  75. package/dist/engine-playwright/keve-report.js +137 -0
  76. package/dist/{core → engine-playwright}/keve-test.d.ts +10 -8
  77. package/dist/{core → engine-playwright}/keve-test.js +257 -207
  78. package/dist/{core → engine-playwright}/pw-config.js +8 -7
  79. package/dist/index.d.ts +13 -8
  80. package/dist/index.js +43 -28
  81. package/dist/page-agent/agent.d.ts +98 -7
  82. package/dist/page-agent/agent.js +338 -84
  83. package/dist/page-agent/diagnostics.d.ts +184 -0
  84. package/dist/page-agent/diagnostics.js +377 -0
  85. package/dist/page-agent/hooks.d.ts +3 -2
  86. package/dist/page-agent/hooks.js +4 -3
  87. package/dist/page-agent/page-like.d.ts +116 -0
  88. package/dist/page-agent/page-like.js +14 -0
  89. package/dist/page-agent/playwright-page.d.ts +20 -0
  90. package/dist/page-agent/playwright-page.js +98 -0
  91. package/dist/page-agent/system-prompt.d.ts +7 -0
  92. package/dist/page-agent/system-prompt.js +10 -0
  93. package/dist/page-agent/system_prompt.md +1 -1
  94. package/dist/page-agent/tools.d.ts +1 -1
  95. package/dist/page-agent/tools.js +441 -9
  96. package/doc/skills/keve-e2e-gen/references/script-generation-guide.md +11 -0
  97. package/package.json +24 -11
  98. package/scripts/gen-system-prompt.mjs +36 -0
  99. package/scripts/run-dsl-task.mjs +619 -0
  100. package/scripts/runtime/cypress-preprocessor.mjs +44 -0
  101. package/scripts/runtime/cypress.config.mjs +35 -0
  102. package/scripts/runtime/playwright.config.mjs +41 -0
  103. package/scripts/verify-shot-diff.mjs +243 -0
  104. package/dist/commands/auth-update.d.ts +0 -16
  105. package/dist/commands/auth-update.js +0 -88
  106. package/dist/core/auth.d.ts +0 -17
  107. package/dist/core/auth.js +0 -224
  108. package/dist/core/cdp-resolver.d.ts +0 -5
  109. package/dist/core/cdp-resolver.js +0 -93
  110. package/dist/core/global-setup.d.ts +0 -6
  111. package/dist/core/global-setup.js +0 -113
  112. package/dist/core/keve-decorators.d.ts +0 -51
  113. package/dist/core/keve-decorators.js +0 -95
  114. package/dist/core/keve-report.js +0 -376
  115. /package/dist/core/{keve-aspect.d.ts → decorator/keve-aspect.d.ts} +0 -0
  116. /package/dist/core/{keve-aspect.js → decorator/keve-aspect.js} +0 -0
  117. /package/dist/{core → engine-playwright}/pw-config.d.ts +0 -0
@@ -0,0 +1,619 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * run-dsl-task — 平台任务统一执行入口(Cypress / Playwright)
4
+ *
5
+ * 职责边界:
6
+ * - eve-backend 负责取数(stepGroup / stepDsl / element / variable / account)
7
+ * 并把已展开、已解析的 dslList 写进 execution-input.json;
8
+ * - 本 runner 只做「投影 → 落盘 → 执行 → 出报告」,不直连业务库。
9
+ *
10
+ * 两个引擎共用同一份 @kkeve/suite 装饰器脚本与同一份 report-data.json 生成逻辑,
11
+ * 因此 Cypress / Playwright 的报告结构完全一致。
12
+ *
13
+ * 用法:
14
+ * node run-dsl-task.mjs --input execution-input.json
15
+ *
16
+ * 输出:
17
+ * <taskRoot>/runs/execution-result.json(执行摘要,供 backend 回写任务终态)
18
+ * <taskRoot>/runs/manifest.json(任务产物索引,供 backend 单点读取)
19
+ * <taskRoot>/reports/round-<round>/report-data.json(前端报告)
20
+ *
21
+ * 任务目录结构(两个引擎完全一致):
22
+ * <taskRoot>/specs/ 脚本
23
+ * <taskRoot>/reports/round-<round>/ 报告 + 媒体(latest 软链指向最新轮次)
24
+ * <taskRoot>/runs/ 单次执行编排产物
25
+ * <taskRoot>/cases/test-cases.yaml 报告补全步骤文案的输入
26
+ *
27
+ * 引擎配置不再写入任务目录:统一使用 scripts/runtime/ 下的静态模板,
28
+ * 通过 KEVE_TASK_DIR / KEVE_RESULT_DIR 等环境变量注入任务差异。
29
+ */
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ import * as url from 'node:url';
33
+ import { spawnSync } from 'node:child_process';
34
+
35
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
36
+ const suiteRoot = path.resolve(__dirname, '..');
37
+ const runtimeDir = path.join(__dirname, 'runtime');
38
+
39
+ /**
40
+ * Cypress / esbuild / @playwright/test 由 eve-backend(平台执行侧)声明并安装。
41
+ * 这里按「环境变量显式指定 → eve-backend/node_modules → suite/node_modules」回退,
42
+ * 保证 runner 既能被 backend spawn,也能在 suite 本地调试。
43
+ */
44
+ const defaultBackendRoot = path.resolve(suiteRoot, '..', '..', '..', 'eve-backend');
45
+
46
+ function resolveCypressBin(backendRoot) {
47
+ const candidates = [
48
+ process.env.KEVE_CYPRESS_BIN,
49
+ path.join(backendRoot, 'node_modules', '.bin', 'cypress'),
50
+ path.join(suiteRoot, 'node_modules', '.bin', 'cypress'),
51
+ ].filter(Boolean);
52
+ return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
53
+ }
54
+
55
+ function resolveEsbuildModule(backendRoot) {
56
+ const candidates = [
57
+ process.env.KEVE_ESBUILD_MODULE,
58
+ path.join(backendRoot, 'node_modules', 'esbuild', 'lib', 'main.js'),
59
+ path.join(suiteRoot, 'node_modules', 'esbuild', 'lib', 'main.js'),
60
+ ].filter(Boolean);
61
+ return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
62
+ }
63
+
64
+ function resolvePlaywrightBin(backendRoot) {
65
+ const candidates = [
66
+ process.env.KEVE_PLAYWRIGHT_BIN,
67
+ path.join(backendRoot, 'node_modules', '.bin', 'playwright'),
68
+ path.join(suiteRoot, 'node_modules', '.bin', 'playwright'),
69
+ ].filter(Boolean);
70
+ return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
71
+ }
72
+
73
+ function parseArgs(argv) {
74
+ const args = {};
75
+ for (let i = 0; i < argv.length; i++) {
76
+ const key = argv[i];
77
+ if (!key?.startsWith('--')) continue;
78
+ const name = key.slice(2);
79
+ const next = argv[i + 1];
80
+ if (next === undefined || next.startsWith('--')) args[name] = true;
81
+ else { args[name] = next; i++; }
82
+ }
83
+ return args;
84
+ }
85
+
86
+ function fail(message) {
87
+ console.error(`[run-dsl-task] ${message}`);
88
+ process.exit(1);
89
+ }
90
+
91
+ function readJson(file, label) {
92
+ if (!fs.existsSync(file)) fail(`${label} 不存在: ${file}`);
93
+ try {
94
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
95
+ } catch (err) {
96
+ return fail(`${label} 不是合法 JSON: ${err.message}`);
97
+ }
98
+ }
99
+
100
+ /** DSL 字段在 DB 里可能是 JSON 字符串,投影前统一还原 */
101
+ function parseJsonValue(value) {
102
+ if (typeof value !== 'string') return value;
103
+ const trimmed = value.trim();
104
+ if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('[') && !trimmed.startsWith('"'))) {
105
+ return value;
106
+ }
107
+ try { return JSON.parse(trimmed); } catch { return value; }
108
+ }
109
+
110
+ /**
111
+ * 归一化 DSL 步骤,使其满足 toDecorator 的输入契约。
112
+ * 任务里存的 dslList 已经过 backend 的 traveseFn + handleElement,
113
+ * 这里只做「JSON 字符串还原」的兜底,不重复解析元素库。
114
+ */
115
+ function normalizeStep(step) {
116
+ const expectations = parseJsonValue(step?.expectation) || [];
117
+ return {
118
+ ...step,
119
+ value: parseJsonValue(step?.value),
120
+ context: parseJsonValue(step?.context) || [],
121
+ wait: parseJsonValue(step?.wait) || [],
122
+ expectation: (Array.isArray(expectations) ? expectations : []).map((item) => ({
123
+ ...item,
124
+ value: parseJsonValue(item?.value),
125
+ context: parseJsonValue(item?.context) || [],
126
+ })),
127
+ child: parseJsonValue(step?.child),
128
+ children: parseJsonValue(step?.children),
129
+ stepIds: parseJsonValue(step?.stepIds),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * 投影 DSL 步骤为报告 outline,语义与 toDecorator 的 keveGoal 参数保持一致。
135
+ * OPEN_PAGE 后紧跟的 WAIT_RESPONSE 会合并进同一个导航步骤,避免报告步骤数
136
+ * 与脚本实际 keveGoal 数不一致。
137
+ */
138
+ function buildCaseSteps(dslList) {
139
+ const steps = [];
140
+ for (let i = 0; i < dslList.length; i++) {
141
+ const d = dslList[i];
142
+ const op = String(d?.operation || '');
143
+ if (op === 'OPEN_PAGE') {
144
+ let j = i + 1;
145
+ const gates = [];
146
+ while (j < dslList.length && String(dslList[j]?.operation || '') === 'WAIT_RESPONSE') {
147
+ gates.push(dslList[j++]);
148
+ }
149
+ if (gates.length) {
150
+ const gateTexts = gates.map((g) => String(g?.text || '')).filter(Boolean);
151
+ const gateExpects = gates
152
+ .flatMap((g) => (g?.expectation || []).map((e) => String(e?.text || '')))
153
+ .filter(Boolean);
154
+ steps.push({
155
+ step: String(d?.text || 'OPEN_PAGE'),
156
+ expected: [...gateTexts, ...gateExpects].filter(Boolean).join(';') || '打开目标页面并等待就绪响应',
157
+ });
158
+ i = j - 1;
159
+ continue;
160
+ }
161
+ }
162
+ const stepText = String(d?.text || op || '');
163
+ const expTexts = (d?.expectation || []).map((e) => String(e?.text || '')).filter(Boolean);
164
+ const expected = expTexts.length ? expTexts.join(';') : '操作完成,页面正常响应';
165
+ if (!stepText) continue;
166
+ steps.push({ step: stepText, expected });
167
+ }
168
+ return steps;
169
+ }
170
+
171
+ /** 从账号信息里提取 toDecorator 需要的占位账号(不下发密码到脚本正文以外的位置) */
172
+ function resolveAccount(accountInfo) {
173
+ const account = accountInfo?.accountInfo || accountInfo;
174
+ if (!account?.name) return undefined;
175
+ return { name: String(account.name), password: String(account.password || '') };
176
+ }
177
+
178
+ async function main() {
179
+ const args = parseArgs(process.argv.slice(2));
180
+ if (!args.input) fail('缺少 --input <execution-input.json>');
181
+
182
+ const inputFile = path.resolve(args.input);
183
+ const input = readJson(inputFile, '执行输入');
184
+
185
+ const engine = input.engine === 'cypress' ? 'cypress' : 'playwright';
186
+ const taskId = String(input.taskId ?? 'unknown');
187
+ const round = String(input.round ?? 'latest');
188
+ // taskRoot 是任务级自包含目录:脚本、报告、执行元数据都在其下。
189
+ const taskRoot = path.resolve(
190
+ input.taskDir || path.join(input.backendRoot || defaultBackendRoot, 'e2e', `task-${taskId}`),
191
+ );
192
+ const backendRoot = input.backendRoot || defaultBackendRoot;
193
+ const specDir = path.join(taskRoot, 'specs');
194
+ const reportsDir = path.join(taskRoot, 'reports');
195
+ const runsDir = path.join(taskRoot, 'runs');
196
+ const resultDir = path.join(reportsDir, `round-${round}`);
197
+ const resultFile = path.join(runsDir, 'execution-result.json');
198
+ const manifestFile = path.join(runsDir, 'manifest.json');
199
+
200
+ const cases = Array.isArray(input.cases) ? input.cases : [];
201
+ if (!cases.length) fail('执行输入里没有 cases,拒绝空跑');
202
+
203
+ // Cypress host 需要一个 baseUrl 兜底(Playwright 侧同理)。
204
+ // 优先使用 DSL 的真实 OPEN_PAGE 地址,避免环境映射覆盖用例目标。
205
+ input.targetUrl = deriveTargetUrl(input, cases);
206
+ // DSL 步骤里的本地地址(localhost / 127.0.0.1)会跟着用户手工用例原样落库,
207
+ // 在远端执行时不可达;当调度入参带真实目标地址时,用目标地址的 host/port 替换。
208
+ rewriteOpenPageTargets(input, cases);
209
+
210
+ fs.rmSync(resultDir, { recursive: true, force: true });
211
+ fs.rmSync(specDir, { recursive: true, force: true });
212
+ fs.mkdirSync(specDir, { recursive: true });
213
+ fs.mkdirSync(resultDir, { recursive: true });
214
+ fs.mkdirSync(runsDir, { recursive: true });
215
+ // 上一次执行可能在报告收口前被外部重启打断,旧 result/manifest 会
216
+ // 让 backend 误以为是本次结果;开始前必须清掉,只认本次新写出的文件。
217
+ fs.rmSync(resultFile, { force: true });
218
+ fs.rmSync(manifestFile, { force: true });
219
+
220
+ const { toDecorator } = await import(
221
+ url.pathToFileURL(path.join(suiteRoot, 'dist/dsl/index.js')).href
222
+ );
223
+
224
+ // ── 逐条用例投影成装饰器脚本 ──
225
+ const caseEntries = [];
226
+ for (const item of cases) {
227
+ const stepGroupId = Number(item.stepGroupId);
228
+ if (!Number.isFinite(stepGroupId)) fail(`用例缺少合法 stepGroupId: ${JSON.stringify(item.stepGroupId)}`);
229
+ const rawList = Array.isArray(item.dslList) ? item.dslList : [];
230
+ if (!rawList.length) fail(`用例 ${stepGroupId} 的 dslList 为空,拒绝生成空脚本`);
231
+
232
+ const dslList = rawList.map(normalizeStep);
233
+ const importFrom = engine === 'cypress'
234
+ ? '@kkeve/suite/engine-cypress'
235
+ : '@kkeve/suite/keve-test';
236
+ const code = toDecorator({
237
+ stepGroupId,
238
+ caseName: item.caseName || `用例 ${stepGroupId}`,
239
+ dslList,
240
+ env: input.env || 'test',
241
+ variableList: Array.isArray(item.variableList) ? item.variableList : [],
242
+ account: resolveAccount(item.accountInfo || input.accountInfo),
243
+ ssoInject: input.ssoInject !== false,
244
+ importFrom,
245
+ });
246
+
247
+ const fileName = `sg${stepGroupId}.spec.ts`;
248
+ fs.writeFileSync(path.join(specDir, fileName), code, 'utf-8');
249
+ const dslSteps = buildCaseSteps(dslList);
250
+ caseEntries.push({
251
+ caseId: `S${stepGroupId}`,
252
+ stepGroupId,
253
+ caseName: item.caseName || `用例 ${stepGroupId}`,
254
+ specFile: fileName,
255
+ stepCount: dslList.length,
256
+ steps: dslSteps,
257
+ });
258
+ console.log(`[run-dsl-task] 生成脚本 ${fileName}(${dslList.length} 步)`);
259
+ }
260
+
261
+ // ── 用例定义写入 cases/test-cases.yaml,让报告能补全步骤文案 ──
262
+ const casesDir = path.join(taskRoot, 'cases');
263
+ fs.mkdirSync(casesDir, { recursive: true });
264
+ fs.writeFileSync(
265
+ path.join(casesDir, 'test-cases.yaml'),
266
+ buildCasesYaml(caseEntries),
267
+ 'utf-8',
268
+ );
269
+
270
+ // ── 执行 ──
271
+ const runner = engine === 'cypress' ? runCypress : runPlaywright;
272
+ const exitCode = runner({ input, taskDir: taskRoot, specDir, resultDir, round });
273
+
274
+ // ── 汇总结果 ──
275
+ // report-data.json 的顶层 summary 只带 total,逐条状态在 caseResults 里,
276
+ // 因此以 caseResults 为准统计 passed/failed/skipped,summary 仅作兜底。
277
+ const reportData = readReportData(resultDir);
278
+ // 引擎退出码是 0 且报告缺失时,旧的实现会把 0 误判成成功。
279
+ // 报告是前端明细与任务终态的唯一事实来源,缺失时无论退出码如何都按失败处理。
280
+ if (!reportData) {
281
+ fail(`执行结束后 report-data.json 不存在: ${path.join(resultDir, 'report-data.json')}(exitCode=${exitCode})`);
282
+ }
283
+ const stats = summarizeReport(reportData);
284
+ if (stats.total === 0) {
285
+ fail(`report-data.json 没有可用用例结果(exitCode=${exitCode}),拒绝写成功结果`);
286
+ }
287
+ const passed = stats.passed;
288
+ const failed = stats.failed;
289
+ const skipped = stats.skipped;
290
+
291
+ // ── 报告轮次索引:latest 软链 + manifest ──
292
+ updateLatestLink(reportsDir, resultDir);
293
+ writeManifest({
294
+ manifestFile,
295
+ taskRoot,
296
+ taskId,
297
+ engine,
298
+ casePath: input.casePath || '',
299
+ round,
300
+ reportDataPath: path.join(resultDir, 'report-data.json'),
301
+ });
302
+
303
+ const result = {
304
+ ok: exitCode === 0 && failed === 0,
305
+ engine,
306
+ taskId,
307
+ exitCode,
308
+ passed,
309
+ failed,
310
+ skipped,
311
+ total: stats.total || passed + failed + skipped,
312
+ caseEntries,
313
+ taskDir: taskRoot,
314
+ taskRoot,
315
+ resultDir,
316
+ reportDataPath: path.join(resultDir, 'report-data.json'),
317
+ manifestPath: manifestFile,
318
+ specDir,
319
+ finishedAt: new Date().toISOString(),
320
+ };
321
+ fs.writeFileSync(resultFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
322
+ console.log(`[run-dsl-task] ${engine} 执行结束: passed=${passed} failed=${failed} skipped=${skipped}`);
323
+ process.exit(result.ok ? 0 : 1);
324
+ }
325
+
326
+ /** reports/latest 软链指向最新轮次,便于 backend 按任务读取最近一次报告 */
327
+ function updateLatestLink(reportsDir, resultDir) {
328
+ const latestDir = path.join(reportsDir, 'latest');
329
+ if (path.resolve(latestDir) === path.resolve(resultDir)) return;
330
+ try {
331
+ if (fs.existsSync(latestDir)) {
332
+ const stat = fs.lstatSync(latestDir);
333
+ if (stat.isSymbolicLink()) fs.unlinkSync(latestDir);
334
+ else fs.rmSync(latestDir, { recursive: true, force: true });
335
+ }
336
+ fs.symlinkSync(path.basename(resultDir), latestDir, 'junction');
337
+ } catch (err) {
338
+ console.warn(`[run-dsl-task] 创建 latest 软链失败: ${err.message}`);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * 写任务产物索引。backend 侧只用这一份 manifest 定位脚本 / 报告 / 轮次,
344
+ * 不再各自拼接引擎相关的目录名。
345
+ */
346
+ function writeManifest({ manifestFile, taskRoot, taskId, engine, casePath, round, reportDataPath }) {
347
+ let previous = {};
348
+ try {
349
+ if (fs.existsSync(manifestFile)) previous = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'));
350
+ } catch { /* 索引损坏时重建 */ }
351
+
352
+ const rel = (target) => path.relative(taskRoot, target).split(path.sep).join('/');
353
+ const rounds = Array.isArray(previous.rounds) ? previous.rounds.filter(Boolean) : [];
354
+ const roundNumber = Number(round);
355
+ const entry = {
356
+ round: Number.isFinite(roundNumber) ? roundNumber : round,
357
+ resultDir: rel(path.dirname(reportDataPath)),
358
+ reportData: rel(reportDataPath),
359
+ updatedAt: new Date().toISOString(),
360
+ };
361
+ const existingIndex = rounds.findIndex((item) => String(item.round) === String(entry.round));
362
+ if (existingIndex >= 0) rounds[existingIndex] = entry;
363
+ else rounds.push(entry);
364
+ rounds.sort((a, b) => Number(a.round) - Number(b.round));
365
+
366
+ const manifest = {
367
+ taskId,
368
+ engine,
369
+ casePath,
370
+ taskRoot,
371
+ specDir: 'specs',
372
+ reportsDir: 'reports',
373
+ latestRound: entry.round,
374
+ rounds,
375
+ updatedAt: entry.updatedAt,
376
+ };
377
+ fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
378
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
379
+ return manifest;
380
+ }
381
+
382
+ /** 供报告补全步骤文案的最小用例清单 */
383
+ function buildCasesYaml(caseEntries) {
384
+ const lines = ['modules:', ' - name: plan', ' cases:'];
385
+ for (const entry of caseEntries) {
386
+ lines.push(` - id: ${entry.caseId}`);
387
+ lines.push(` title: ${JSON.stringify(entry.caseName)}`);
388
+ if (Array.isArray(entry.steps) && entry.steps.length) {
389
+ lines.push(' steps:');
390
+ for (const step of entry.steps) {
391
+ lines.push(` - step: ${JSON.stringify(step.step || '')}`);
392
+ lines.push(` expected: ${JSON.stringify(step.expected || '')}`);
393
+ }
394
+ } else {
395
+ lines.push(' steps: []');
396
+ }
397
+ }
398
+ return lines.join('\n') + '\n';
399
+ }
400
+
401
+ function readReportData(resultDir) {
402
+ const file = path.join(resultDir, 'report-data.json');
403
+ if (!fs.existsSync(file)) return null;
404
+ try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return null; }
405
+ }
406
+
407
+ /**
408
+ * 目标地址解析:优先使用用例 OPEN_PAGE 中的真实业务地址。
409
+ * 只有 DSL 里的地址是本机占位地址,或 DSL 没有可用地址时,才使用
410
+ * 调度入参 / 环境映射,避免 env=rc 覆盖用例明确声明的目标站点。
411
+ */
412
+ function deriveTargetUrl(input, cases) {
413
+ for (const item of cases) {
414
+ for (const raw of Array.isArray(item?.dslList) ? item.dslList : []) {
415
+ const step = normalizeStep(raw);
416
+ if (String(step?.operation || '') !== 'OPEN_PAGE') continue;
417
+ const value = step.value;
418
+ const candidate = typeof value === 'string'
419
+ ? value
420
+ : value?.url || value?.value || '';
421
+ const candidateUrl = String(candidate || '').trim();
422
+ if (!/^https?:\/\//i.test(candidateUrl)) continue;
423
+ if (isLocalPlaceholderUrl(candidateUrl)) {
424
+ continue;
425
+ }
426
+ return candidateUrl;
427
+ }
428
+ }
429
+
430
+ const explicit = String(input?.targetUrl || '').trim();
431
+ if (/^https?:\/\//i.test(explicit)) {
432
+ return explicit;
433
+ }
434
+ const envTarget = resolveEnvTargetUrl(input?.env);
435
+ if (envTarget) return envTarget;
436
+ return '';
437
+ }
438
+
439
+ function isLocalPlaceholderUrl(value) {
440
+ try {
441
+ const host = new URL(value).hostname;
442
+ return /^(localhost|127\.0\.0\.1|0\.0\.0\.0)$/i.test(host);
443
+ } catch {
444
+ return false;
445
+ }
446
+ }
447
+
448
+ function resolveEnvTargetUrl(env) {
449
+ const normalized = String(env || '').trim().toLowerCase();
450
+ return {
451
+ online: 'https://keve.corp.kuaishou.com',
452
+ pre: 'https://keve.corp.kuaishou.com',
453
+ rc: 'https://rc-eve.corp.kuaishou.com',
454
+ test: 'http://127.0.0.1:8080',
455
+ local: 'http://127.0.0.1:8080',
456
+ }[normalized] || '';
457
+ }
458
+
459
+ /**
460
+ * 把 DSL 中指向本机的 OPEN_PAGE 地址投影为真实执行目标地址。
461
+ *
462
+ * 仅当调度入参 targetUrl 已明确解析时生效;保留原 URL 的 path/search/hash,
463
+ * 避免破坏用例里「打开某页面 + dashboadId=xxx」这类带参数的导航。
464
+ */
465
+ function rewriteOpenPageTargets(input, cases) {
466
+ const targetUrl = String(input?.targetUrl || '').trim();
467
+ if (!/^https?:\/\//i.test(targetUrl)) return;
468
+
469
+ let target;
470
+ try {
471
+ target = new URL(targetUrl);
472
+ } catch {
473
+ return;
474
+ }
475
+
476
+ const localHostRe = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/i;
477
+ const rewrite = (value) => {
478
+ if (typeof value !== 'string') return value;
479
+ const raw = value.trim();
480
+ if (!/^https?:\/\//i.test(raw)) return value;
481
+ let parsed;
482
+ try {
483
+ parsed = new URL(raw);
484
+ } catch {
485
+ return value;
486
+ }
487
+ if (!localHostRe.test(parsed.host)) return value;
488
+ parsed.protocol = target.protocol;
489
+ parsed.hostname = target.hostname;
490
+ parsed.port = target.port;
491
+ return parsed.toString();
492
+ };
493
+
494
+ for (const item of cases) {
495
+ if (!Array.isArray(item?.dslList)) continue;
496
+ for (const step of item.dslList) {
497
+ if (String(step?.operation || '') !== 'OPEN_PAGE') continue;
498
+ if (step.value && typeof step.value === 'object' && 'value' in step.value) {
499
+ step.value.value = rewrite(step.value.value);
500
+ } else {
501
+ step.value = rewrite(step.value);
502
+ }
503
+ }
504
+ }
505
+ }
506
+
507
+ /**
508
+ * 逐条统计 caseResults 状态。
509
+ * 状态语义与 core/generateReport 保持一致:
510
+ * passed / failed 之外的一律按 skipped 计入,保证 total = passed + failed + skipped。
511
+ */
512
+ function summarizeReport(reportData) {
513
+ const cases = Array.isArray(reportData?.caseResults) ? reportData.caseResults : [];
514
+ if (cases.length) {
515
+ const passed = cases.filter((item) => item?.status === 'passed').length;
516
+ const failed = cases.filter((item) => item?.status === 'failed').length;
517
+ const skipped = cases.length - passed - failed;
518
+ return { passed, failed, skipped, total: cases.length };
519
+ }
520
+ const summary = reportData?.summary?.summary || reportData?.summary || {};
521
+ const passed = Number(summary.passed || 0);
522
+ const failed = Number(summary.failed || 0);
523
+ const skipped = Number(summary.skipped || 0);
524
+ return {
525
+ passed,
526
+ failed,
527
+ skipped,
528
+ total: Number(summary.total || passed + failed + skipped),
529
+ };
530
+ }
531
+
532
+ function runPlaywright({ input, taskDir, specDir, resultDir, round }) {
533
+ const configPath = path.join(runtimeDir, 'playwright.config.mjs');
534
+ const backendRoot = input.backendRoot || defaultBackendRoot;
535
+ const cli = resolvePlaywrightBin(backendRoot);
536
+ if (!cli || !fs.existsSync(cli)) fail('找不到 Playwright 可执行文件,请设置 KEVE_PLAYWRIGHT_BIN');
537
+ const result = spawnSync(cli, ['test', '--config', configPath], {
538
+ cwd: taskDir,
539
+ stdio: 'inherit',
540
+ env: buildExecEnv({ input, taskDir, round, engine: 'playwright' }),
541
+ });
542
+ if (result.error) fail(`Playwright 启动失败: ${result.error.message}`);
543
+ return result.status ?? 1;
544
+ }
545
+
546
+ function useCypressConfig({ taskDir, specDir, resultDir }) {
547
+ return path.join(runtimeDir, 'cypress.config.mjs');
548
+ }
549
+
550
+ function runCypress({ input, taskDir, specDir, resultDir, round }) {
551
+ const configPath = useCypressConfig({ taskDir, specDir, resultDir });
552
+ const backendRoot = input.backendRoot || defaultBackendRoot;
553
+ const cypressBin = resolveCypressBin(backendRoot);
554
+ if (!cypressBin || !fs.existsSync(cypressBin)) {
555
+ fail('找不到 Cypress 可执行文件,请设置 KEVE_CYPRESS_BIN');
556
+ }
557
+ const result = spawnSync(cypressBin, [
558
+ 'run',
559
+ '--project', taskDir,
560
+ '--config-file', configPath,
561
+ '--browser', process.env.KEVE_CYPRESS_BROWSER || 'electron',
562
+ ], {
563
+ cwd: taskDir,
564
+ stdio: 'inherit',
565
+ env: {
566
+ ...buildExecEnv({ input, taskDir, round, engine: 'cypress' }),
567
+ // Cypress 内部会 fork Electron;清空该变量避免以纯 Node 模式启动
568
+ ELECTRON_RUN_AS_NODE: '',
569
+ },
570
+ });
571
+ if (result.error) fail(`Cypress 启动失败: ${result.error.message}`);
572
+ return result.status ?? 1;
573
+ }
574
+
575
+ /** 两个引擎共用同一套环境变量契约 */
576
+ function buildExecEnv({ input, taskDir, round, engine }) {
577
+ const backendRoot = input.backendRoot || defaultBackendRoot;
578
+ return {
579
+ ...process.env,
580
+ KEVE_TASK_DIR: taskDir,
581
+ KEVE_RESULT_DIR: path.join(taskDir, 'reports', `round-${round}`),
582
+ KEVE_CASES_DIR: path.join(taskDir, 'cases'),
583
+ // 媒体相对路径以任务根为基准,保证 report-data.json 里不出现 `..`
584
+ KEVE_ARTIFACT_BASE: taskDir,
585
+ KEVE_ROUND: round,
586
+ KEVE_ENV: input.env || 'test',
587
+ KEVE_TARGET_URL: input.targetUrl || '',
588
+ KEVE_STORAGE_STATE: path.join(taskDir, '.auth', 'storage-state.json'),
589
+ KEVE_SSO_COOKIES: input.ssoCookiesJson || '[]',
590
+ KEVE_IDENTITY_SSO_COOKIES: JSON.stringify(input.identitySsoCookies || {}),
591
+ KEVE_LLM_BASE_URL: input.llm?.baseUrl || process.env.KEVE_LLM_BASE_URL || '',
592
+ KEVE_LLM_MODEL_NAME: input.llm?.model || process.env.KEVE_LLM_MODEL_NAME || '',
593
+ KEVE_LLM_API_KEY: input.llm?.apiKey || process.env.KEVE_LLM_API_KEY || '',
594
+ KEVE_ENGINE: engine,
595
+ // Cypress 预处理器位于 suite 静态配置目录,esbuild 需由 backend 提供
596
+ KEVE_ESBUILD_MODULE: resolveEsbuildModule(backendRoot),
597
+ };
598
+ }
599
+
600
+ export {
601
+ deriveTargetUrl,
602
+ isLocalPlaceholderUrl,
603
+ resolveEnvTargetUrl,
604
+ rewriteOpenPageTargets,
605
+ };
606
+
607
+ /** 入口判定:node_modules 软链会让 argv[1] 与 import.meta.url 不是同一路径,需按真实路径比较。 */
608
+ function isMainEntry() {
609
+ if (!process.argv[1]) return false;
610
+ try {
611
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(url.fileURLToPath(import.meta.url));
612
+ } catch {
613
+ return import.meta.url === url.pathToFileURL(path.resolve(process.argv[1])).href;
614
+ }
615
+ }
616
+
617
+ if (isMainEntry()) {
618
+ main().catch((err) => fail(err?.stack || err?.message || String(err)));
619
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cypress TypeScript 预处理器(静态模板)
3
+ *
4
+ * esbuild 由 eve-backend 声明并安装,通过 KEVE_ESBUILD_MODULE 指向其入口,
5
+ * 避免任务目录再生成配置文件或软链依赖。
6
+ */
7
+ import { createRequire } from 'node:module';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const esbuildEntry = process.env.KEVE_ESBUILD_MODULE;
11
+ if (!esbuildEntry) {
12
+ throw new Error('缺少 KEVE_ESBUILD_MODULE,无法定位 esbuild 入口');
13
+ }
14
+ const esbuild = require(esbuildEntry);
15
+
16
+ export async function preprocess(file) {
17
+ await esbuild.build({
18
+ entryPoints: [file.filePath],
19
+ outfile: file.outputPath,
20
+ bundle: true,
21
+ format: 'iife',
22
+ platform: 'browser',
23
+ target: 'chrome100',
24
+ sourcemap: 'inline',
25
+ logLevel: 'warning',
26
+ // 任务目录位于 eve-backend/ 下,esbuild 会自动继承后端的 tsconfig.json,
27
+ // 而那里开了 experimentalDecorators: true —— 会把 keveScene/keveModel 降级成
28
+ // 旧式 (target, key, descriptor) 调用,但装饰器实现走的是 TC39 标准签名
29
+ // (value, context),运行期直接报 context.addInitializer is not a function。
30
+ // 这里显式覆盖,保证与 suite 自身 tsconfig 的装饰器语义一致。
31
+ tsconfigRaw: {
32
+ compilerOptions: {
33
+ experimentalDecorators: false,
34
+ useDefineForClassFields: false,
35
+ },
36
+ },
37
+ define: {
38
+ 'process.env.KEVE_ENV': JSON.stringify(process.env.KEVE_ENV || 'test'),
39
+ 'process.env.KEVE_CY_TEST_TIMEOUT': JSON.stringify(process.env.KEVE_CY_TEST_TIMEOUT || ''),
40
+ 'process.env.KEVE_TARGET_URL': JSON.stringify(process.env.KEVE_TARGET_URL || ''),
41
+ },
42
+ });
43
+ return file.outputPath;
44
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cypress 任务级配置(静态模板)
3
+ *
4
+ * 以 `--project <taskDir> --config-file <本文件>` 方式加载:
5
+ * 项目根仍是任务目录(specPattern 相对它解析),
6
+ * 轮次产物目录则通过 KEVE_RESULT_DIR 注入。
7
+ */
8
+ import * as path from 'node:path';
9
+ import * as url from 'node:url';
10
+ import { cypressSetup } from '../../dist/engine-cypress/setup.js';
11
+ import { preprocess } from './cypress-preprocessor.mjs';
12
+
13
+ const runtimeDir = path.dirname(url.fileURLToPath(import.meta.url));
14
+ const suiteRoot = path.resolve(runtimeDir, '..', '..');
15
+ const taskDir = process.env.KEVE_TASK_DIR || path.resolve(suiteRoot, '.keve', 'default');
16
+ const resultDir = process.env.KEVE_RESULT_DIR || path.join(taskDir, 'reports', 'round-latest');
17
+ const targetUrl = process.env.KEVE_TARGET_URL || '';
18
+
19
+ export default {
20
+ e2e: {
21
+ ...(targetUrl ? { baseUrl: targetUrl } : {}),
22
+ specPattern: 'specs/**/*.spec.ts',
23
+ supportFile: false,
24
+ video: true,
25
+ videosFolder: path.join(resultDir, 'videos'),
26
+ screenshotsFolder: path.join(resultDir, 'screenshots'),
27
+ downloadsFolder: path.join(resultDir, 'downloads'),
28
+ screenshotOnRunFailure: false,
29
+ defaultCommandTimeout: 10000,
30
+ setupNodeEvents(on, config) {
31
+ on('file:preprocessor', preprocess);
32
+ return cypressSetup(on, config);
33
+ },
34
+ },
35
+ };