@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,598 @@
1
+ "use strict";
2
+ /**
3
+ * bridge — Cypress spec(浏览器) ↔ Node 的同步通道
4
+ *
5
+ * 为什么需要它:`cy.task` 在 `cy.then(async …)` 内只入队、立刻 resolve 成 undefined,
6
+ * 不能作为「可 await 的 Node 往返」(探针 15/19 实测)。而截图对比、写文件、
7
+ * 生成报告这些能力必须在 Node 侧执行。这里用 `setupNodeEvents` 起一个本地 HTTP
8
+ * 服务,浏览器侧用 `fetch` 直接 await —— 这是唯一可靠的同步通道。
9
+ *
10
+ * 端口经 `config.env.KEVE_BRIDGE_PORT` 注入 spec,spec 内用
11
+ * `Cypress.env('KEVE_BRIDGE_PORT')` 取。
12
+ *
13
+ * 报告一致性:本模块与 keve-report.ts 写同一份 confidence-data.jsonl 与
14
+ * report-data.json,并且都调用 core/confidence.ts 的分类与
15
+ * core/generateReport/reportData.ts 的汇总,保证 Cypress / Playwright 报告同构。
16
+ */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.setupKeveBridge = setupKeveBridge;
52
+ const fs = __importStar(require("node:fs"));
53
+ const http = __importStar(require("node:http"));
54
+ const path = __importStar(require("node:path"));
55
+ const screenshotDiff_js_1 = require("../core/judge/screenshotDiff.js");
56
+ const confidence_js_1 = require("../core/judge/confidence.js");
57
+ const reportData_js_1 = require("../core/generateReport/reportData.js");
58
+ function resultDirOf() {
59
+ // 优先使用 runner 注入的报告目录;缺失时回退旧布局,兼容历史执行环境。
60
+ if (process.env.KEVE_RESULT_DIR)
61
+ return path.resolve(process.env.KEVE_RESULT_DIR);
62
+ const taskDir = process.env.KEVE_TASK_DIR || '.keve';
63
+ const round = process.env.KEVE_ROUND || 'latest';
64
+ return path.resolve(taskDir, 'test-artifacts', `round-${round}`);
65
+ }
66
+ function taskDirOf() {
67
+ return path.resolve(process.env.KEVE_TASK_DIR || '.keve');
68
+ }
69
+ function decodeBase64(data) {
70
+ return Buffer.from(String(data || ''), 'base64');
71
+ }
72
+ /** 解码 Cypress attachment 的 JSON body(浏览器侧以 base64 发送) */
73
+ function parseJsonAttachmentBody(body) {
74
+ if (!body)
75
+ return null;
76
+ let text = '';
77
+ try {
78
+ if (typeof body === 'object' && !Buffer.isBuffer(body))
79
+ return body;
80
+ text = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
81
+ const decoded = Buffer.from(text, 'base64').toString('utf8');
82
+ return JSON.parse(decoded);
83
+ }
84
+ catch {
85
+ try {
86
+ return JSON.parse(text);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ }
93
+ /** 按 goal 顺序匹配该用例已落盘截图(文件名形如 goal-before-0-<step>-<ts>.png) */
94
+ function findScreenshotByOrder(dir, prefix, order) {
95
+ if (!fs.existsSync(dir))
96
+ return undefined;
97
+ const files = fs.readdirSync(dir);
98
+ const marker = `${prefix}-${order}-`;
99
+ return files.find((f) => f.startsWith(marker) && f.endsWith('.png'));
100
+ }
101
+ /**
102
+ * 失败中断时现场兜底:keveGoal 可能还没写 attachment 就被 Cypress 中断,
103
+ * 但 goal-before / fn-after 截图已经落盘。这里按截图文件名与用例日志补回
104
+ * keveGoalResult,让 reportData 至少能还原步骤、执行前/后截图。
105
+ */
106
+ function restoreInterruptedScreenshots(incoming, state) {
107
+ const attachments = incoming.attachments || [];
108
+ const screenshotsDir = screenshotsDirOf(state);
109
+ if (!fs.existsSync(screenshotsDir))
110
+ return;
111
+ // 从日志提取被中断的 step 文案(Cypress keveGoal 固定输出格式)
112
+ const parseStepFromLogs = () => {
113
+ for (const line of incoming.logs || []) {
114
+ const m = String(line).match(/\[keveGoal\]\s+"([^"]+)"/);
115
+ if (m?.[1])
116
+ return m[1];
117
+ }
118
+ return '';
119
+ };
120
+ const goalAttachments = attachments.filter((a) => a.name === 'keveGoalResult');
121
+ const goals = goalAttachments
122
+ .map((a) => parseJsonAttachmentBody(a.body))
123
+ .filter((b) => b && b.order !== undefined);
124
+ for (const goal of goals) {
125
+ const order = Number(goal.order);
126
+ if (!goal.goalScreenshotBefore) {
127
+ const shot = findScreenshotByOrder(screenshotsDir, 'goal-before', order);
128
+ if (shot)
129
+ goal.goalScreenshotBefore = path.relative(state.taskDir, path.join(screenshotsDir, shot));
130
+ }
131
+ if (!goal.goalScreenshotAfter) {
132
+ const shot = findScreenshotByOrder(screenshotsDir, 'goal-after-fn', order);
133
+ if (shot)
134
+ goal.goalScreenshotAfter = path.relative(state.taskDir, path.join(screenshotsDir, shot));
135
+ }
136
+ if (!goal.agentScreenshots?.length && goal.goalScreenshotAfter) {
137
+ goal.agentScreenshots = [goal.goalScreenshotAfter];
138
+ }
139
+ const att = goalAttachments[goals.indexOf(goal)];
140
+ if (att && att.body) {
141
+ att.body = Buffer.from(JSON.stringify(goal)).toString('base64');
142
+ }
143
+ }
144
+ // 连 attachment 都没写到:按顺序 0 的截图重建一条失败 goal
145
+ if (goals.length === 0) {
146
+ const step = parseStepFromLogs() || '打开页面';
147
+ const before = findScreenshotByOrder(screenshotsDir, 'goal-before', 0);
148
+ const after = findScreenshotByOrder(screenshotsDir, 'goal-after-fn', 0);
149
+ if (!before && !after)
150
+ return;
151
+ const beforePath = before ? path.relative(state.taskDir, path.join(screenshotsDir, before)) : undefined;
152
+ const afterPath = after ? path.relative(state.taskDir, path.join(screenshotsDir, after)) : undefined;
153
+ attachments.push({
154
+ name: 'keveGoalResult',
155
+ contentType: 'application/json',
156
+ body: Buffer.from(JSON.stringify({
157
+ step,
158
+ expected: '操作完成,页面正常响应',
159
+ order: 0,
160
+ success: false,
161
+ conclusion: 'fail',
162
+ actions: [],
163
+ goalScreenshotBefore: beforePath,
164
+ goalScreenshotAfter: afterPath,
165
+ agentScreenshots: afterPath ? [afterPath] : [],
166
+ })).toString('base64'),
167
+ });
168
+ }
169
+ }
170
+ /** 截图落盘目录(与 Playwright 侧同一轮次的 screenshots 目录一致) */
171
+ function screenshotsDirOf(state) {
172
+ return path.join(state.resultDir, 'screenshots');
173
+ }
174
+ /** 报告轮次根目录:新布局为 <taskRoot>/reports,旧布局为 <taskDir>/test-artifacts */
175
+ function reportsRootOf(resultDir) {
176
+ return path.dirname(resultDir);
177
+ }
178
+ function safeFileSegment(text) {
179
+ // 保留连字符:keveGoal 传入的 name 形如 goal-before-0-step,
180
+ // 连字符属于模板分隔符,需与 Playwright captureScreenshot 的文件名同形。
181
+ return String(text || 'shot').replace(/[^a-zA-Z0-9一-鿿-]/g, '_').slice(0, 60);
182
+ }
183
+ /** 把收集到的 Cypress 结果写成 Playwright 同构的 test-results.json */
184
+ function writeTestResults(state) {
185
+ // 结构对齐 Playwright JSON reporter:顶层 suite = spec 文件,二级 suite = @keveModel
186
+ // 的 `M_…` describe。reportData.buildCaseResultMap 从 suitePath 的末段取模块名,
187
+ // 之前这里是扁平结构(顶层 title 为空、specs 直接挂上),模块名会退化成 'default'。
188
+ const byFile = new Map();
189
+ for (const r of state.results) {
190
+ const file = r.file || '';
191
+ if (!byFile.has(file))
192
+ byFile.set(file, new Map());
193
+ const bySuite = byFile.get(file);
194
+ if (!bySuite.has(r.suiteTitle))
195
+ bySuite.set(r.suiteTitle, []);
196
+ bySuite.get(r.suiteTitle).push(r);
197
+ }
198
+ const toSpec = (r) => ({
199
+ title: r.title,
200
+ file: r.file || '',
201
+ column: 0,
202
+ line: 0,
203
+ tests: [{
204
+ timeout: 0,
205
+ annotations: [],
206
+ expectedStatus: 'passed',
207
+ projectId: '',
208
+ projectName: '',
209
+ results: [{
210
+ workerIndex: 0,
211
+ status: r.status,
212
+ duration: r.duration,
213
+ startTime: r.startTime,
214
+ errors: r.error ? [{ message: r.error }] : [],
215
+ error: r.error ? { message: r.error } : undefined,
216
+ // Playwright JSON reporter 的 stdout 是 [{ text }] 数组,reportData 再拼成字符串
217
+ stdout: (r.logs || []).map((text) => ({ text })),
218
+ stderr: [],
219
+ attachments: r.attachments,
220
+ steps: [],
221
+ }],
222
+ status: r.status,
223
+ }],
224
+ ok: r.status === 'passed',
225
+ });
226
+ const suites = [...byFile.entries()].map(([file, bySuite]) => ({
227
+ title: file ? path.basename(file) : '',
228
+ file,
229
+ column: 0,
230
+ line: 0,
231
+ specs: [],
232
+ suites: [...bySuite.entries()].map(([suiteTitle, records]) => ({
233
+ title: suiteTitle,
234
+ file,
235
+ column: 0,
236
+ line: 0,
237
+ specs: records.map(toSpec),
238
+ })),
239
+ }));
240
+ const out = {
241
+ config: {
242
+ rootDir: taskDirOf(),
243
+ version: 'cypress-engine',
244
+ metadata: { engine: 'cypress' },
245
+ projects: [],
246
+ reporter: [['json', { outputFile: path.join(state.resultDir, 'test-results.json') }]],
247
+ },
248
+ suites,
249
+ errors: [],
250
+ stats: {
251
+ startTime: new Date().toISOString(),
252
+ duration: state.results.reduce((a, r) => a + (r.duration || 0), 0),
253
+ expected: state.results.length,
254
+ skipped: state.results.filter((r) => r.status === 'skipped').length,
255
+ unexpected: state.results.filter((r) => r.status === 'failed' || r.status === 'timedOut').length,
256
+ flaky: 0,
257
+ },
258
+ };
259
+ const target = path.join(state.resultDir, 'test-results.json');
260
+ fs.mkdirSync(state.resultDir, { recursive: true });
261
+ fs.writeFileSync(target, JSON.stringify(out, null, 2));
262
+ return target;
263
+ }
264
+ /**
265
+ * 起桥 + 注册 Cypress task。
266
+ * 返回补好 `config.env.KEVE_BRIDGE_PORT` 的 config(Cypress 要求 return config)。
267
+ */
268
+ async function setupKeveBridge(on, config) {
269
+ const state = {
270
+ resultDir: resultDirOf(),
271
+ confidencePath: path.join(resultDirOf(), 'confidence-data.jsonl'),
272
+ taskDir: taskDirOf(),
273
+ results: [],
274
+ };
275
+ fs.mkdirSync(state.resultDir, { recursive: true });
276
+ /** 写 test-results.json → report-data.json(/report/finalize 与 after:run 共用) */
277
+ const finalize = async () => {
278
+ const resultsPath = writeTestResults(state);
279
+ const casesYaml = path.join(state.taskDir, 'cases', 'test-cases.yaml');
280
+ const reportData = await (0, reportData_js_1.generateReportData)({
281
+ projectRoot: state.taskDir,
282
+ resultsPath,
283
+ confidencePath: state.confidencePath,
284
+ casesPath: fs.existsSync(casesYaml) ? casesYaml : undefined,
285
+ });
286
+ const reportDataPath = path.join(state.resultDir, 'report-data.json');
287
+ fs.writeFileSync(reportDataPath, JSON.stringify(reportData, null, 2));
288
+ // latest 软链(与 keve-report.onEnd 一致)
289
+ const latestDir = path.join(reportsRootOf(state.resultDir), 'latest');
290
+ if (path.resolve(latestDir) !== path.resolve(state.resultDir)) {
291
+ try {
292
+ if (fs.existsSync(latestDir)) {
293
+ const stat = fs.lstatSync(latestDir);
294
+ if (stat.isSymbolicLink())
295
+ fs.unlinkSync(latestDir);
296
+ else
297
+ fs.rmSync(latestDir, { recursive: true, force: true });
298
+ }
299
+ fs.symlinkSync(path.basename(state.resultDir), latestDir, 'junction');
300
+ }
301
+ catch { /* 软链失败不影响报告生成 */ }
302
+ }
303
+ return { ok: true, resultsPath, reportDataPath, cases: reportData?.summary?.total ?? undefined };
304
+ };
305
+ const handle = async (route, payload) => {
306
+ switch (route) {
307
+ case '/echo':
308
+ return { ok: true, echoed: payload, at: Date.now() };
309
+ case '/fs/mkdir': {
310
+ fs.mkdirSync(String(payload.dir), { recursive: true });
311
+ return { ok: true };
312
+ }
313
+ case '/fs/write': {
314
+ const file = String(payload.file);
315
+ fs.mkdirSync(path.dirname(file), { recursive: true });
316
+ const data = payload.base64 ? decodeBase64(payload.data) : String(payload.data ?? '');
317
+ if (payload.append)
318
+ fs.appendFileSync(file, data);
319
+ else
320
+ fs.writeFileSync(file, data);
321
+ return { ok: true, file, bytes: Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data) };
322
+ }
323
+ case '/fs/read': {
324
+ const file = String(payload.file);
325
+ if (!fs.existsSync(file))
326
+ return { ok: false, error: `ENOENT: ${file}` };
327
+ return { ok: true, data: fs.readFileSync(file, 'utf8') };
328
+ }
329
+ /** 读取二进制文件(Agent 初始多模态截图):返回 base64,不落任何临时文件 */
330
+ case '/fs/read-binary': {
331
+ const file = String(payload.file);
332
+ if (!fs.existsSync(file))
333
+ return { ok: false, error: `ENOENT: ${file}` };
334
+ return { ok: true, base64: fs.readFileSync(file).toString('base64') };
335
+ }
336
+ /**
337
+ * LLM 转发:浏览器侧的 Agent 循环通过此路由调 OpenAI 兼容接口。
338
+ *
339
+ * apiKey 只留在 Node 侧(payload 里不带 key,返回体原样透传),Cypress spec
340
+ * 因此不需要把密钥注入浏览器。`url` 必须是 http(s) 绝对地址。
341
+ */
342
+ case '/llm/chat': {
343
+ const url = String(payload.url || '');
344
+ if (!/^https?:\/\//i.test(url)) {
345
+ return { ok: false, code: 'BAD_URL', error: `llm/chat: url 非法: ${JSON.stringify(url)}` };
346
+ }
347
+ try {
348
+ // apiKey 只留在 Node 侧:浏览器侧不带 Authorization,由这里按环境变量补齐。
349
+ const reqHeaders = { ...(payload.headers || {}) };
350
+ const hasAuth = Object.keys(reqHeaders).some((k) => k.toLowerCase() === 'authorization');
351
+ const apiKey = process.env.KEVE_LLM_API_KEY || process.env.LLM_API_KEY || '';
352
+ if (!hasAuth && apiKey)
353
+ reqHeaders.Authorization = `Bearer ${apiKey}`;
354
+ const resp = await fetch(url, {
355
+ method: String(payload.method || 'POST'),
356
+ headers: reqHeaders,
357
+ body: typeof payload.body === 'string' ? payload.body : JSON.stringify(payload.body ?? {}),
358
+ });
359
+ const body = await resp.text();
360
+ const headers = {};
361
+ resp.headers.forEach((v, k) => { headers[k] = v; });
362
+ return { ok: true, status: resp.status, statusText: resp.statusText, headers, body };
363
+ }
364
+ catch (err) {
365
+ return { ok: false, code: 'LLM_FETCH_ERROR', error: err?.message || String(err) };
366
+ }
367
+ }
368
+ /** 浏览器侧截图落盘:返回相对 taskDir 的路径(与 Playwright 侧 captureScreenshot 同形) */
369
+ case '/shot/save': {
370
+ const dir = screenshotsDirOf(state);
371
+ fs.mkdirSync(dir, { recursive: true });
372
+ const name = `${safeFileSegment(payload.name)}-${Date.now()}.png`;
373
+ const file = path.join(dir, name);
374
+ fs.writeFileSync(file, decodeBase64(payload.pngBase64));
375
+ return { ok: true, path: path.relative(state.taskDir, file) };
376
+ }
377
+ // 截图基线:语义与 PlaywrightEngine.expectScreenshot 完全一致
378
+ case '/shot/compare': {
379
+ const png = decodeBase64(payload.pngBase64);
380
+ try {
381
+ const result = payload.create
382
+ ? (0, screenshotDiff_js_1.createBaseline)(String(payload.key), png, { threshold: payload.threshold, sourceUrl: payload.sourceUrl })
383
+ : (0, screenshotDiff_js_1.compareWithBaseline)(String(payload.key), png, { threshold: payload.threshold, mask: payload.mask });
384
+ return { ok: true, result };
385
+ }
386
+ catch (err) {
387
+ if (err instanceof screenshotDiff_js_1.ScreenshotDiffError) {
388
+ return { ok: false, code: 'SHOT_DIFF_EXCEEDED', error: err.message, shotDiff: err.shotDiff };
389
+ }
390
+ return { ok: false, code: 'SHOT_DIFF_ERROR', error: err?.message || String(err) };
391
+ }
392
+ }
393
+ // 记录一条 confidence 记录(分类逻辑与 Playwright Reporter 同源)
394
+ case '/report/record': {
395
+ const { record, log } = (0, confidence_js_1.buildConfidenceRecord)({
396
+ title: String(payload.title),
397
+ status: String(payload.status),
398
+ error: payload.error || null,
399
+ steps: payload.steps || [],
400
+ attachments: payload.attachments || [],
401
+ });
402
+ fs.appendFileSync(state.confidencePath, JSON.stringify((0, confidence_js_1.sanitizeRecord)(record)) + '\n');
403
+ return { ok: true, record, log };
404
+ }
405
+ // 记录一条测试结果(与 Playwright JSON reporter 的字段对齐)
406
+ case '/report/result': {
407
+ const incoming = payload;
408
+ state.results = state.results.filter((r) => r.title !== incoming.title);
409
+ state.results.push(incoming);
410
+ return { ok: true, count: state.results.length };
411
+ }
412
+ /**
413
+ * 一条用例收口:同时写 confidence-data.jsonl 与 test-results 记录。
414
+ * 浏览器侧只发一次请求,避免「分类逻辑两端各写一份」。
415
+ */
416
+ case '/report/test': {
417
+ const incoming = payload;
418
+ restoreInterruptedScreenshots(incoming, state);
419
+ const { record, log } = (0, confidence_js_1.buildConfidenceRecord)({
420
+ title: incoming.title,
421
+ status: incoming.status,
422
+ error: incoming.error ? { message: incoming.error } : null,
423
+ steps: [],
424
+ attachments: incoming.attachments || [],
425
+ });
426
+ fs.appendFileSync(state.confidencePath, JSON.stringify((0, confidence_js_1.sanitizeRecord)(record)) + '\n');
427
+ state.results = state.results.filter((r) => r.title !== incoming.title);
428
+ state.results.push(incoming);
429
+ return { ok: true, count: state.results.length, log };
430
+ }
431
+ // 写 test-results.json → 生成 report-data.json(与 keve-report.onEnd 同路径)
432
+ case '/report/finalize':
433
+ return await finalize();
434
+ default:
435
+ return { ok: false, error: `unknown route ${route}` };
436
+ }
437
+ };
438
+ const server = http.createServer((req, res) => {
439
+ const corsHeaders = {
440
+ 'access-control-allow-origin': '*',
441
+ 'access-control-allow-headers': '*',
442
+ 'access-control-allow-methods': 'POST, OPTIONS',
443
+ };
444
+ /**
445
+ * 浏览器侧的 fetch 是跨域请求(spec/runner 与桥不同源),每个 POST 前都会先发
446
+ * OPTIONS 预检。若不在此短路,预检的空 body 会被当成一次真实调用落进
447
+ * `/report/test` —— 报告里会凭空多出一条无标题的空用例(已实测)。
448
+ */
449
+ if (req.method === 'OPTIONS') {
450
+ res.writeHead(204, corsHeaders);
451
+ res.end();
452
+ return;
453
+ }
454
+ const chunks = [];
455
+ req.on('data', (c) => chunks.push(c));
456
+ req.on('end', async () => {
457
+ const route = String(req.url || '').split('?')[0];
458
+ let payload = {};
459
+ try {
460
+ const raw = Buffer.concat(chunks).toString('utf8');
461
+ payload = raw ? JSON.parse(raw) : {};
462
+ }
463
+ catch {
464
+ payload = {};
465
+ }
466
+ let out;
467
+ try {
468
+ out = await handle(route, payload);
469
+ }
470
+ catch (err) {
471
+ out = { ok: false, error: err?.message || String(err) };
472
+ }
473
+ res.writeHead(200, {
474
+ 'content-type': 'application/json',
475
+ ...corsHeaders,
476
+ });
477
+ res.end(JSON.stringify(out));
478
+ });
479
+ });
480
+ await new Promise((resolve, reject) => {
481
+ server.once('error', reject);
482
+ server.listen(0, '127.0.0.1', () => resolve());
483
+ });
484
+ const port = server.address()?.port;
485
+ /**
486
+ * 浏览器侧要从 `Cypress.env()` 读到的运行时配置。
487
+ *
488
+ * LLM 的 baseURL/model 与目标 URL 属于非敏感值;API key 绝不下发浏览器 ——
489
+ * `/llm/chat` 在 Node 侧按环境变量补齐 Authorization。
490
+ *
491
+ * SSO cookies 必须下发:Playwright 由 global-setup 直接读 process.env 写
492
+ * storageState,而 Cypress 没有 storageState,只能在 host.ts 里通过
493
+ * `cy.env('KEVE_SSO_COOKIES')` 取到后再用 CDP 注入。只放进 process.env
494
+ * 而不放进 config.env,浏览器侧读不到,用例会直接掉进 SSO 登录页。
495
+ */
496
+ const browserEnv = {};
497
+ for (const name of [
498
+ 'KEVE_TARGET_URL',
499
+ 'KEVE_LLM_BASE_URL',
500
+ 'KEVE_LLM_MODEL_NAME',
501
+ 'KEVE_LLM_MODEL',
502
+ // 登录态必须下发给浏览器侧:Cypress 无法像 Playwright 那样读 storageState,
503
+ // host.ts 依赖 cy.env('KEVE_SSO_COOKIES') 通过 CDP 注入 cookie。
504
+ // 仅在 process.env 里存在而不放进 config.env,浏览器侧读不到 → 用例必然掉进 SSO 登录页。
505
+ 'KEVE_SSO_COOKIES',
506
+ 'KEVE_IDENTITY_SSO_COOKIES',
507
+ ]) {
508
+ const value = process.env[name];
509
+ if (value)
510
+ browserEnv[name] = value;
511
+ }
512
+ /**
513
+ * 调用方(eve-llm 的 Cypress 执行器)经 KEVE_CONFIG_ENV 传入敏感/动态配置
514
+ * (如服务端解析好的 SSO cookies)。不写进 config.mjs 源码,避免 cookie 落盘。
515
+ */
516
+ let injectedEnv = {};
517
+ const injectedRaw = process.env.KEVE_CONFIG_ENV;
518
+ if (injectedRaw) {
519
+ try {
520
+ const parsed = JSON.parse(injectedRaw);
521
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
522
+ injectedEnv = parsed;
523
+ }
524
+ }
525
+ catch {
526
+ console.warn('[keve-bridge] KEVE_CONFIG_ENV 解析失败,忽略注入配置');
527
+ }
528
+ }
529
+ config.env = { ...(config.env || {}), ...browserEnv, ...injectedEnv, KEVE_BRIDGE_PORT: port };
530
+ console.log(`[keve-bridge] listening on 127.0.0.1:${port} → ${state.resultDir}`);
531
+ // 报告收口放在 after:run:此时所有 spec 的 /report/test 都已落盘,
532
+ // 且不依赖任何场景级的 afterAll(硬失败/中断也能出报告)。
533
+ on('after:run', async () => {
534
+ try {
535
+ const out = await finalize();
536
+ console.log(`[keve-bridge] report-data.json → ${out.reportDataPath}(用例 ${out.cases ?? 0})`);
537
+ }
538
+ catch (err) {
539
+ console.error(`[keve-bridge] 报告生成失败:${err?.message || err}`);
540
+ }
541
+ try {
542
+ server.close();
543
+ }
544
+ catch { /* noop */ }
545
+ });
546
+ /**
547
+ * 视频上报:Cypress 的 `results.video` 是 spec 级(整段录制),
548
+ * 但报告按用例聚合,需要把同一 spec 的视频挂到该 spec 的每条结果上,
549
+ * 才能让 reportData 的 `videoPath` 与 Playwright 的 per-test 视频等价。
550
+ *
551
+ * after:spec 早于 after:run 触发,此时 results 已由浏览器侧 /report/test 落盘。
552
+ */
553
+ on('after:spec', async (_spec, results) => {
554
+ const video = results?.video;
555
+ if (video && fs.existsSync(video)) {
556
+ // 相对 taskDir,与 Playwright 侧 videoPath 的语义一致
557
+ const rel = path.relative(state.taskDir, video);
558
+ const specFile = String(results?.spec?.relative || '');
559
+ const titles = new Set((results?.tests || [])
560
+ .map((t) => (Array.isArray(t?.title) ? t.title[t.title.length - 1] : t?.title))
561
+ .filter(Boolean)
562
+ .map((t) => String(t)));
563
+ // 先按 spec 文件收窄,再按标题匹配;标题对不上时退化为该 spec 的全部用例
564
+ const byFile = state.results.filter((r) => !specFile || !r.file || r.file === specFile);
565
+ const targets = byFile.filter((r) => titles.size === 0 || titles.has(r.title));
566
+ const finalTargets = targets.length > 0 ? targets : byFile;
567
+ for (const record of finalTargets) {
568
+ const attachments = (record.attachments || []).filter((a) => a.name !== 'video');
569
+ attachments.push({ name: 'video', contentType: 'video/mp4', path: rel });
570
+ record.attachments = attachments;
571
+ }
572
+ console.log(`[keve-bridge] video attachment → ${rel}(用例 ${finalTargets.length})`);
573
+ }
574
+ // Cypress 在 after:spec 后还会压缩视频,而压缩期间若宿主进程重启,
575
+ // after:run 的最终报告落盘会被打断。这里先出一版 report-data.json,
576
+ // 确保测试结果不会丢;after:run 仍会基于完整结果再收口一次。
577
+ try {
578
+ const out = await finalize();
579
+ console.log(`[keve-bridge] report-data.json checkpoint → ${out.reportDataPath}(用例 ${out.cases ?? 0})`);
580
+ }
581
+ catch (err) {
582
+ console.warn(`[keve-bridge] 报告检查点生成失败:${err?.message || err}`);
583
+ }
584
+ });
585
+ // Cypress 偶发不触发 after:run(硬失败/中断)——兜底退出时关闭
586
+ process.once('exit', () => {
587
+ try {
588
+ finalize();
589
+ }
590
+ catch { /* 同步兜底,失败不阻塞退出 */ }
591
+ try {
592
+ server.close();
593
+ }
594
+ catch { /* noop */ }
595
+ });
596
+ return config;
597
+ }
598
+ exports.default = setupKeveBridge;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * cdp — Cypress 下的 AUT(被测应用)驱动层
3
+ *
4
+ * 背景:Cypress 的命令队列无法承载 async 的 EngineAdapter(详见
5
+ * EVE/docs/cypress-engine-cdp-findings.md)。这里完全绕开命令队列,只用
6
+ * `Cypress.automation('remote:debugger:protocol')` 直连 CDP,得到与 Playwright
7
+ * 等价的「读 DOM / 交互 / 导航 / 截图」能力。
8
+ *
9
+ * 三条硬约束(探针实测,改动前先看 findings 文档):
10
+ * 1. 严禁对主会话发 Page.navigate / Page.reload —— 会导航 Cypress runner 顶层页,
11
+ * 摧毁 runner。导航/刷新一律在 AUT 的 isolated world 内改 location。
12
+ * 2. 所有 DOM 读写必须走 `Page.createIsolatedWorld` 得到的 executionContextId,
13
+ * 否则读到的是 Cypress runner 自己的 UI(cy.$$ / Cypress.$ 同理不可用)。
14
+ * 3. Page.captureScreenshot 不带 clip 会拍到 runner 界面;必须用
15
+ * `iframe.aut-iframe` 的 rect 裁剪,才能得到干净的 AUT 截图。
16
+ *
17
+ * 本模块零依赖,运行在 Cypress 的 spec 上下文(浏览器)。
18
+ */
19
+ export type CdpSend = (command: string, params?: Record<string, unknown>) => Promise<any>;
20
+ export interface CdpFrame {
21
+ id: string;
22
+ url: string;
23
+ }
24
+ export interface AutSnapshot {
25
+ frameId: string;
26
+ url: string;
27
+ readyState: string;
28
+ title: string;
29
+ }
30
+ /** 判断 frame URL 是否属于 Cypress runner 自身(AUT 诊断采集应跳过) */
31
+ export declare function isRunnerFrameUrl(url: string): boolean;
32
+ /** 轮询工具:`fn` 返回 undefined 表示「还没就绪」,继续轮询 */
33
+ export declare function pollUntil<T>(fn: () => Promise<T | undefined>, timeout: number, interval?: number): Promise<T | undefined>;
34
+ export declare function createCdp(send: CdpSend): {
35
+ send: CdpSend;
36
+ frames: () => Promise<CdpFrame[]>;
37
+ autFrame: () => Promise<CdpFrame>;
38
+ context: (force?: boolean) => Promise<number>;
39
+ evaluateInAut: <T = any>(expression: string, opts?: {
40
+ awaitPromise?: boolean;
41
+ }) => Promise<T>;
42
+ evaluateInFrame: <T = any>(frameId: string, expression: string, opts?: {
43
+ awaitPromise?: boolean;
44
+ }) => Promise<T>;
45
+ evaluateInRunner: <T = any>(expression: string) => Promise<T>;
46
+ snapshot: () => Promise<AutSnapshot>;
47
+ currentUrl: () => Promise<string>;
48
+ goto: (url: string, timeout?: number) => Promise<void>;
49
+ reload: (timeout?: number) => Promise<void>;
50
+ autRect: () => Promise<{
51
+ x: number;
52
+ y: number;
53
+ w: number;
54
+ h: number;
55
+ } | null>;
56
+ screenshot: () => Promise<string>;
57
+ screenshotClip: (clip: {
58
+ x: number;
59
+ y: number;
60
+ width: number;
61
+ height: number;
62
+ }) => Promise<string>;
63
+ injectLocator: (source: string) => Promise<void>;
64
+ installPreloadScript: (source: string) => Promise<void>;
65
+ ensureLocator: () => Promise<void>;
66
+ setCookie: (cookie: {
67
+ name: string;
68
+ value: string;
69
+ domain?: string;
70
+ path?: string;
71
+ secure?: boolean;
72
+ httpOnly?: boolean;
73
+ expires?: number;
74
+ }) => Promise<void>;
75
+ axTree: () => Promise<any[]>;
76
+ invalidateWorld: () => void;
77
+ };
78
+ export type Cdp = ReturnType<typeof createCdp>;