@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
@@ -18,6 +18,134 @@ const zod_1 = require("zod");
18
18
  function tool(options) {
19
19
  return options;
20
20
  }
21
+ /** 常见业务 UI 的弹窗容器(dialog / message-box / modal)。 */
22
+ const DIALOG_ROOT_SELECTOR = [
23
+ '[role="dialog"]',
24
+ '[role="alertdialog"]',
25
+ '.ks-dialog',
26
+ '.el-dialog',
27
+ '.el-message-box',
28
+ '.ant-modal',
29
+ '.ant-modal-confirm',
30
+ '[class*="message-box"]',
31
+ '[class*="confirm-dialog"]',
32
+ ].join(', ');
33
+ /**
34
+ * 取当前最上层可见弹窗的文本与按钮。返回空时说明没有可见弹窗。
35
+ * 通过 page.evaluate 直接跑在浏览器上下文,Playwright/Cypress 共用同一实现。
36
+ */
37
+ async function currentDialogInfo(page) {
38
+ try {
39
+ const selectors = DIALOG_ROOT_SELECTOR.split(',').map(s => s.trim()).filter(Boolean);
40
+ const result = await page.evaluate(`(() => {
41
+ var selectors = ${JSON.stringify(selectors)};
42
+ var seen = [];
43
+ for (var i = 0; i < selectors.length; i += 1) {
44
+ var nodes = Array.prototype.slice.call(document.querySelectorAll(selectors[i]));
45
+ for (var j = 0; j < nodes.length; j += 1) seen.push(nodes[j]);
46
+ }
47
+ var visible = [];
48
+ for (var k = 0; k < seen.length; k += 1) {
49
+ var el = seen[k];
50
+ if (!el || !el.getBoundingClientRect) continue;
51
+ var rect = el.getBoundingClientRect();
52
+ if (!rect.width && !rect.height) continue;
53
+ var style = window.getComputedStyle(el);
54
+ if (!style) continue;
55
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
56
+ if (Number(style.opacity) === 0) continue;
57
+ if (visible.indexOf(el) < 0) visible.push(el);
58
+ }
59
+ var root = visible.length ? visible[visible.length - 1] : null;
60
+ if (!root) return null;
61
+ var text = String(root.innerText || root.textContent || '').replace(/\\s+/g, ' ').trim();
62
+ var buttons = [];
63
+ var candidates = Array.prototype.slice.call(root.querySelectorAll('button, [role="button"], a'));
64
+ for (var b = 0; b < candidates.length; b += 1) {
65
+ var btn = candidates[b];
66
+ if (btn.disabled) continue;
67
+ var br = btn.getBoundingClientRect();
68
+ if (!br.width && !br.height) continue;
69
+ var label = String(btn.textContent || btn.getAttribute('aria-label') || '').replace(/\\s+/g, ' ').trim();
70
+ if (label && buttons.indexOf(label) < 0) buttons.push(label);
71
+ }
72
+ return { text: text.slice(0, 300), buttons: buttons.slice(0, 20) };
73
+ })()`);
74
+ if (result && (result.text || result.buttons?.length)) {
75
+ return { text: String(result.text || ''), buttons: Array.isArray(result.buttons) ? result.buttons.map(String) : [] };
76
+ }
77
+ return null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * 自动关闭“是否/确认”类弹窗。
85
+ *
86
+ * 只有弹窗文本明确包含确认语义时才点正向按钮,避免把普通提示误当操作框;
87
+ * 点完再检查弹窗是否真正关闭。返回 null 表示不需要/无法自动处理。
88
+ */
89
+ async function autoConfirmDialog(page, info) {
90
+ const text = String(info?.text || '');
91
+ const hasConfirmationWording = /确认|是否|关闭所有|关闭全部|删除|覆盖|确定/.test(text);
92
+ if (!hasConfirmationWording || !info?.buttons?.length)
93
+ return null;
94
+ const wantedLabels = ['确定', '确认', '是', '关闭全部', '关闭所有'];
95
+ const label = wantedLabels.find(l => info.buttons.includes(l));
96
+ if (!label)
97
+ return null;
98
+ const clickExpression = `(() => {
99
+ var selectors = ${JSON.stringify(DIALOG_ROOT_SELECTOR.split(',').map(s => s.trim()).filter(Boolean))};
100
+ var seen = [];
101
+ for (var i = 0; i < selectors.length; i += 1) {
102
+ var nodes = Array.prototype.slice.call(document.querySelectorAll(selectors[i]));
103
+ for (var j = 0; j < nodes.length; j += 1) seen.push(nodes[j]);
104
+ }
105
+ var visible = [];
106
+ for (var k = 0; k < seen.length; k += 1) {
107
+ var el = seen[k];
108
+ if (!el || !el.getBoundingClientRect) continue;
109
+ var rect = el.getBoundingClientRect();
110
+ if (!rect.width && !rect.height) continue;
111
+ var style = window.getComputedStyle(el);
112
+ if (!style || style.display === 'none' || style.visibility === 'hidden') continue;
113
+ if (Number(style.opacity) === 0) continue;
114
+ if (visible.indexOf(el) < 0) visible.push(el);
115
+ }
116
+ var root = visible.length ? visible[visible.length - 1] : null;
117
+ if (!root) return null;
118
+ var wanted = ${JSON.stringify(wantedLabels)};
119
+ var buttons = Array.prototype.slice.call(root.querySelectorAll('button, [role="button"], a'));
120
+ for (var w = 0; w < wanted.length; w += 1) {
121
+ for (var b = 0; b < buttons.length; b += 1) {
122
+ var btn = buttons[b];
123
+ if (btn.disabled) continue;
124
+ var label = String(btn.textContent || btn.getAttribute('aria-label') || '').replace(/\\s+/g, ' ').trim();
125
+ if (label === wanted[w]) {
126
+ if (typeof btn.click === 'function') btn.click();
127
+ else btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
128
+ return label;
129
+ }
130
+ }
131
+ }
132
+ return null;
133
+ })()`;
134
+ try {
135
+ const clicked = await page.evaluate(clickExpression);
136
+ if (!clicked)
137
+ return null;
138
+ await page.waitForTimeout(600);
139
+ const after = await currentDialogInfo(page);
140
+ if (!after) {
141
+ return `✅ Auto-confirmed blocking dialog via "${clicked}" — dialog closed`;
142
+ }
143
+ return `⚠️ Clicked dialog content then auto-confirmed "${clicked}", but dialog still open: "${after.text.slice(0, 80)}". Read the accessibility tree to handle the remaining dialog.`;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
21
149
  // ─── Tool Registry ────────────────────────────────────────────────────
22
150
  exports.tools = new Map();
23
151
  // --- done ---
@@ -63,7 +191,7 @@ exports.tools.set('click', tool({
63
191
  let inDialog = false;
64
192
  try {
65
193
  const preClickInfo = await locator.evaluate((el) => {
66
- const dialog = el.closest('[role="dialog"], [role="alertdialog"], .ks-dialog, .el-dialog, .ant-modal');
194
+ const dialog = el.closest(DIALOG_ROOT_SELECTOR);
67
195
  const role = el.getAttribute('role') || el.tagName.toLowerCase();
68
196
  const name = el.textContent?.trim()?.slice(0, 40) || el.getAttribute('aria-label') || el.getAttribute('title') || '';
69
197
  const isButton = el.tagName === 'BUTTON' || el.getAttribute('role') === 'button' || el.tagName === 'A';
@@ -83,6 +211,16 @@ exports.tools.set('click', tool({
83
211
  : elementRole || `element`;
84
212
  await locator.click({ timeout: 5000, force: true });
85
213
  await this.page.waitForTimeout(300);
214
+ // 点击弹窗内容(非按钮)没有自动关闭弹窗时,常见确认框会一直盖住页面;
215
+ // 这里在 Agent 继续尝试前先扫描弹窗按钮,避免反复 execute_javascript 碰运气。
216
+ if (inDialog && !dialogContext) {
217
+ const dialogInfo = await currentDialogInfo(this.page);
218
+ if (dialogInfo) {
219
+ const autoResult = await autoConfirmDialog(this.page, dialogInfo);
220
+ if (autoResult)
221
+ return autoResult;
222
+ }
223
+ }
86
224
  // Post-click check: if we clicked a dialog button, check if dialog closed
87
225
  if (dialogContext) {
88
226
  try {
@@ -486,20 +624,20 @@ exports.tools.set('navigate', tool({
486
624
  // ── Resolve environment variable references (e.g., "process.env.PAGE_BI") ──
487
625
  const envRefMatch = url.match(/^process\.env\.(\w+)$/);
488
626
  if (envRefMatch) {
489
- const envValue = process.env[envRefMatch[1]] || '';
627
+ const envValue = this.getEnv(envRefMatch[1]);
490
628
  if (!envValue) {
491
629
  return `❌ Cannot navigate: environment variable ${envRefMatch[1]} is empty/undefined. This is a test data configuration issue — call done(verdict="blocked") immediately.`;
492
630
  }
493
631
  url = envValue;
494
632
  }
495
633
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
496
- const base = process.env.KEVE_TARGET_URL || process.env.BASE_URL || '';
634
+ const base = this.targetUrl || this.getEnv('BASE_URL');
497
635
  if (!base) {
498
636
  return `❌ Cannot navigate: no base URL available (KEVE_TARGET_URL and BASE_URL are both empty). Call done(verdict="blocked") immediately.`;
499
637
  }
500
638
  url = new URL(url, base).href;
501
639
  }
502
- await this.page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
640
+ await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
503
641
  return `✅ Navigated to ${url}`;
504
642
  },
505
643
  }));
@@ -589,8 +727,7 @@ exports.tools.set('visual_assert', tool({
589
727
  // 1. Capture screenshot
590
728
  let screenshotBase64;
591
729
  try {
592
- const buf = await this.page.screenshot({ type: 'png', timeout: 10000 });
593
- screenshotBase64 = buf.toString('base64');
730
+ screenshotBase64 = (await this.page.screenshot({ type: 'png', timeout: 10000 })).base64;
594
731
  }
595
732
  catch (e) {
596
733
  throw new Error(`visual_assert screenshot failed: ${e.message}`);
@@ -613,8 +750,7 @@ exports.tools.set('visual_locate', tool({
613
750
  // 1. Capture screenshot
614
751
  let screenshotBase64;
615
752
  try {
616
- const buf = await this.page.screenshot({ type: 'png', timeout: 10000 });
617
- screenshotBase64 = buf.toString('base64');
753
+ screenshotBase64 = (await this.page.screenshot({ type: 'png', timeout: 10000 })).base64;
618
754
  }
619
755
  catch (e) {
620
756
  throw new Error(`visual_locate screenshot failed: ${e.message}`);
@@ -625,7 +761,7 @@ exports.tools.set('visual_locate', tool({
625
761
  return `❌ Visual locate failed: element "${input.description}" not found. ${locateResult.analysis || ''}`;
626
762
  }
627
763
  // 3. Convert normalized bbox (0-1000) to viewport pixel coordinates
628
- const viewport = this.page.viewportSize() || { width: 1280, height: 720 };
764
+ const viewport = (await this.page.viewportSize()) || { width: 1280, height: 720 };
629
765
  const [x1, y1, x2, y2] = locateResult.bbox;
630
766
  const centerX = Math.round(((x1 + x2) / 2) * viewport.width / 1000);
631
767
  const centerY = Math.round(((y1 + y2) / 2) * viewport.height / 1000);
@@ -747,6 +883,302 @@ exports.tools.set('fill_form', tool({
747
883
  return results.join('\n');
748
884
  },
749
885
  }));
886
+ // --- drag ---
887
+ exports.tools.set('drag', tool({
888
+ description: `Drag from a starting point to an ending point using real mouse events (mousedown → mousemove steps → mouseup). Use for: resizing elements via drag handles, drag-to-reorder lists, any UI requiring hold+move interaction.
889
+
890
+ Two targeting modes:
891
+ 1. By ref — drags from the element's center (use for drag-to-reorder)
892
+ 2. By coordinates (startX/startY) — precise point (use for resize handles found via visual_locate)
893
+
894
+ Target specification:
895
+ - Relative: use deltaX/deltaY (e.g. deltaX=200 moves 200px right)
896
+ - Absolute: use endX/endY (e.g. endX=800, endY=400)
897
+
898
+ NOT for: scrolling (use scroll), clicking (use click), HTML5 DnD between elements.`,
899
+ inputSchema: zod_1.z.object({
900
+ ref: zod_1.z.string().optional()
901
+ .describe('Element ref to start dragging from. Drags from element center. Use startX/startY for resize handles.'),
902
+ startX: zod_1.z.number().optional()
903
+ .describe('X coordinate to start dragging from (viewport pixels). Use when you have precise coordinates from visual_locate.'),
904
+ startY: zod_1.z.number().optional()
905
+ .describe('Y coordinate to start dragging from (viewport pixels).'),
906
+ deltaX: zod_1.z.number().optional()
907
+ .describe('Horizontal displacement in pixels. Positive=right, negative=left. Use with ref or startX/startY.'),
908
+ deltaY: zod_1.z.number().optional()
909
+ .describe('Vertical displacement in pixels. Positive=down, negative=up.'),
910
+ endX: zod_1.z.number().optional()
911
+ .describe('Absolute X coordinate to drag to (viewport pixels). Use deltaX/deltaY for relative, endX/endY for absolute.'),
912
+ endY: zod_1.z.number().optional()
913
+ .describe('Absolute Y coordinate to drag to (viewport pixels).'),
914
+ steps: zod_1.z.number().min(1).max(100).default(10)
915
+ .describe('Number of intermediate mousemove steps. More=smoother animation. Default 10. Use 20+ for CSS resize.'),
916
+ }),
917
+ execute: async function (input) {
918
+ // 1. Determine start coordinates
919
+ let sx, sy;
920
+ if (input.ref) {
921
+ const locator = this.page.locator(`aria-ref=${input.ref}`);
922
+ try {
923
+ const box = await locator.boundingBox({ timeout: 5000 });
924
+ if (!box)
925
+ return `❌ Cannot drag: element [ref=${input.ref}] not visible or detached`;
926
+ sx = box.x + box.width / 2;
927
+ sy = box.y + box.height / 2;
928
+ }
929
+ catch (err) {
930
+ return `❌ Cannot drag: element [ref=${input.ref}] error: ${err.message}`;
931
+ }
932
+ }
933
+ else if (input.startX !== undefined && input.startY !== undefined) {
934
+ sx = input.startX;
935
+ sy = input.startY;
936
+ }
937
+ else {
938
+ return `❌ Cannot drag: provide either ref or startX/startY`;
939
+ }
940
+ // 2. Determine end coordinates
941
+ let ex, ey;
942
+ if (input.endX !== undefined && input.endY !== undefined) {
943
+ ex = input.endX;
944
+ ey = input.endY;
945
+ }
946
+ else if (input.deltaX !== undefined || input.deltaY !== undefined) {
947
+ ex = sx + (input.deltaX || 0);
948
+ ey = sy + (input.deltaY || 0);
949
+ }
950
+ else {
951
+ return `❌ Cannot drag: provide either deltaX/deltaY or endX/endY`;
952
+ }
953
+ // 3. Execute real mouse drag with intermediate steps
954
+ const steps = input.steps ?? 10;
955
+ try {
956
+ await this.page.mouse.move(sx, sy);
957
+ await this.page.mouse.down();
958
+ for (let i = 1; i <= steps; i++) {
959
+ const progress = i / steps;
960
+ await this.page.mouse.move(sx + (ex - sx) * progress, sy + (ey - sy) * progress);
961
+ await this.page.waitForTimeout(16); // ~60fps
962
+ }
963
+ await this.page.mouse.up();
964
+ await this.page.waitForTimeout(300); // wait for resize/layout to settle
965
+ return `✅ Dragged from (${Math.round(sx)}, ${Math.round(sy)}) to (${Math.round(ex)}, ${Math.round(ey)}) in ${steps} steps (Δx=${Math.round(ex - sx)}, Δy=${Math.round(ey - sy)})`;
966
+ }
967
+ catch (err) {
968
+ try {
969
+ await this.page.mouse.up();
970
+ }
971
+ catch { /* ensure mouse released */ }
972
+ return `❌ Drag failed: ${err.message}`;
973
+ }
974
+ },
975
+ }));
976
+ // --- wait_for ---
977
+ exports.tools.set('wait_for', tool({
978
+ description: `Wait for text to appear or disappear on the page, or for a specified duration. More efficient than polling with wait(time).
979
+
980
+ - waitForText: wait until specific text becomes visible on the page (e.g. wait for "保存成功" toast)
981
+ - waitForTextGone: wait until specific text disappears (e.g. wait for "加载中..." spinner to vanish)
982
+ - seconds: simple time-based wait (max 30s)
983
+
984
+ At least one parameter is required. You can combine them (e.g. wait for text + timeout).`,
985
+ inputSchema: zod_1.z.object({
986
+ waitForText: zod_1.z.string().optional()
987
+ .describe('Wait for this text to appear on the page (visible)'),
988
+ waitForTextGone: zod_1.z.string().optional()
989
+ .describe('Wait for this text to disappear from the page (hidden)'),
990
+ seconds: zod_1.z.number().min(0.1).max(30).optional()
991
+ .describe('Wait time in seconds (max 30)'),
992
+ }),
993
+ execute: async function (input) {
994
+ if (!input.waitForText && !input.waitForTextGone && !input.seconds) {
995
+ return `❌ wait_for: provide at least one of waitForText, waitForTextGone, or seconds`;
996
+ }
997
+ const results = [];
998
+ if (input.seconds) {
999
+ await this.page.waitForTimeout(input.seconds * 1000);
1000
+ results.push(`waited ${input.seconds}s`);
1001
+ }
1002
+ if (input.waitForTextGone) {
1003
+ const locator = this.page.getByText(input.waitForTextGone).first();
1004
+ try {
1005
+ await locator.waitFor({ state: 'hidden', timeout: 15000 });
1006
+ results.push(`"${input.waitForTextGone}" disappeared`);
1007
+ }
1008
+ catch {
1009
+ results.push(`⚠️ "${input.waitForTextGone}" still visible after 15s`);
1010
+ }
1011
+ }
1012
+ if (input.waitForText) {
1013
+ const locator = this.page.getByText(input.waitForText).first();
1014
+ try {
1015
+ await locator.waitFor({ state: 'visible', timeout: 15000 });
1016
+ results.push(`"${input.waitForText}" appeared`);
1017
+ }
1018
+ catch {
1019
+ results.push(`⚠️ "${input.waitForText}" not found after 15s`);
1020
+ }
1021
+ }
1022
+ return `✅ ${results.join(', ')}`;
1023
+ },
1024
+ }));
1025
+ // --- verify_value ---
1026
+ exports.tools.set('verify_value', tool({
1027
+ description: `Deterministically verify a form element's current value — no LLM needed. Faster and more precise than visual_assert for value checks.
1028
+
1029
+ - textbox/slider/combobox → checks locator.inputValue()
1030
+ - checkbox/radio → checks locator.isChecked()
1031
+
1032
+ Returns ✅ if value matches, ❌ if mismatch. Use for precise value assertions instead of visual inspection.`,
1033
+ inputSchema: zod_1.z.object({
1034
+ ref: zod_1.z.string().describe('Element ref from the accessibility tree'),
1035
+ type: zod_1.z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider'])
1036
+ .describe('Element type: textbox/slider/combobox → check inputValue; checkbox/radio → check isChecked'),
1037
+ value: zod_1.z.string().describe('Expected value. For checkbox/radio: "true" or "false". For textbox: expected text. For combobox: expected selected value.'),
1038
+ }),
1039
+ execute: async function (input) {
1040
+ const locator = this.page.locator(`aria-ref=${input.ref}`);
1041
+ try {
1042
+ if (input.type === 'checkbox' || input.type === 'radio') {
1043
+ const checked = await locator.isChecked({ timeout: 5000 });
1044
+ const expected = input.value === 'true';
1045
+ if (checked === expected) {
1046
+ return `✅ Verify [ref=${input.ref}] ${input.type}: checked=${checked} (expected ${input.value})`;
1047
+ }
1048
+ return `❌ Verify [ref=${input.ref}] ${input.type}: checked=${checked} (expected ${input.value})`;
1049
+ }
1050
+ // textbox, slider, combobox — check inputValue
1051
+ const actualValue = await locator.inputValue({ timeout: 5000 });
1052
+ if (actualValue === input.value) {
1053
+ return `✅ Verify [ref=${input.ref}] ${input.type}: value="${actualValue}" (expected "${input.value}")`;
1054
+ }
1055
+ // For combobox, also check textContent (custom components may not expose value via inputValue)
1056
+ if (input.type === 'combobox') {
1057
+ const textContent = await locator.textContent({ timeout: 2000 }).catch(() => '');
1058
+ if (textContent?.includes(input.value)) {
1059
+ return `✅ Verify [ref=${input.ref}] combobox: text contains "${input.value}" (inputValue="${actualValue}")`;
1060
+ }
1061
+ }
1062
+ return `❌ Verify [ref=${input.ref}] ${input.type}: value="${actualValue}" (expected "${input.value}")`;
1063
+ }
1064
+ catch (err) {
1065
+ return `❌ Verify [ref=${input.ref}] failed: ${err.message}`;
1066
+ }
1067
+ },
1068
+ }));
1069
+ // --- handle_dialog ---
1070
+ exports.tools.set('handle_dialog', tool({
1071
+ description: `Handle native browser dialogs (alert, confirm, prompt). Native dialogs are auto-accepted by default when they appear.
1072
+
1073
+ Two usage modes:
1074
+ 1. BEFORE a dialog-triggering action: call handle_dialog(accept=false) to override the default auto-accept behavior. Then click the button that triggers the dialog.
1075
+ 2. AFTER a dialog appeared: call handle_dialog to check what dialog was captured and how it was handled.
1076
+
1077
+ Native dialogs are auto-accepted unless you pre-set a different action. If a click triggers a dialog, it will be handled automatically based on your pre-set preference.`,
1078
+ inputSchema: zod_1.z.object({
1079
+ accept: zod_1.z.boolean().default(true).describe('Whether to accept (true) or dismiss (false) the next native dialog. Default true.'),
1080
+ promptText: zod_1.z.string().optional().describe('Text to enter in prompt dialog (only when accept=true and dialog is a prompt)'),
1081
+ }),
1082
+ execute: async function (input) {
1083
+ // Set handler for the NEXT dialog
1084
+ this._dialogHandler = { accept: input.accept, promptText: input.promptText };
1085
+ // Also report the last captured dialog (if any)
1086
+ const last = this._lastDialogInfo;
1087
+ if (last) {
1088
+ return `Last dialog: [${last.type}] "${last.message.slice(0, 80)}" — was ${last.accepted ? 'accepted' : 'dismissed'}. Next dialog will be ${input.accept ? 'accepted' : 'dismissed'}.`;
1089
+ }
1090
+ return `Next native dialog will be ${input.accept ? 'accepted' : 'dismissed'}${input.promptText ? ` with text "${input.promptText}"` : ''}.`;
1091
+ },
1092
+ }));
1093
+ // --- console_messages ---
1094
+ exports.tools.set('console_messages', tool({
1095
+ description: `Get browser console log messages captured during page execution. Use to check for JavaScript errors, verify log output, or debug page issues. More reliable than visual checking for error toasts or log messages.`,
1096
+ inputSchema: zod_1.z.object({
1097
+ level: zod_1.z.enum(['error', 'warning', 'info', 'debug']).default('info')
1098
+ .describe('Minimum log level. "error"=only errors, "warning"=errors+warnings, "info"=add info logs, "debug"=all. Default "info".'),
1099
+ }),
1100
+ execute: async function (input) {
1101
+ const messages = this._consoleMessages || [];
1102
+ const levelPriority = { debug: 0, info: 1, warning: 2, error: 3 };
1103
+ const minPriority = levelPriority[input.level] ?? 1;
1104
+ const filtered = messages.filter((m) => (levelPriority[m.level] ?? 0) >= minPriority);
1105
+ if (filtered.length === 0) {
1106
+ return `No console messages at level "${input.level}" or above. Total captured: ${messages.length}.`;
1107
+ }
1108
+ const formatted = filtered.map((m, i) => `[${i + 1}] ${m.level.toUpperCase()}: ${m.text.slice(0, 200)}`).join('\n');
1109
+ return `Console messages (${filtered.length}/${messages.length} at level ≥ "${input.level}"):\n${formatted}`;
1110
+ },
1111
+ }));
1112
+ // --- file_upload ---
1113
+ exports.tools.set('file_upload', tool({
1114
+ description: `Upload one or more files via a file input element. When you click a file input button, a file chooser dialog appears. This tool uploads files to the pending file chooser.
1115
+
1116
+ Flow: 1) Click the file input button (e.g. "上传文件", "选择文件"), 2) Call file_upload with the file paths.
1117
+
1118
+ Paths must be absolute. The file chooser is single-use — after uploading, a new click is needed for another upload.`,
1119
+ inputSchema: zod_1.z.object({
1120
+ paths: zod_1.z.array(zod_1.z.string()).describe('Absolute file paths to upload'),
1121
+ }),
1122
+ execute: async function (input) {
1123
+ const fileChooser = this._pendingFileChooser;
1124
+ if (!fileChooser) {
1125
+ return `❌ No pending file chooser. Click a file input element first to trigger a file chooser dialog.`;
1126
+ }
1127
+ try {
1128
+ await fileChooser.setFiles(input.paths);
1129
+ this._pendingFileChooser = null;
1130
+ return `✅ Uploaded ${input.paths.length} file(s): ${input.paths.map(p => p.split('/').pop()).join(', ')}`;
1131
+ }
1132
+ catch (err) {
1133
+ this._pendingFileChooser = null;
1134
+ return `❌ File upload failed: ${err.message}`;
1135
+ }
1136
+ },
1137
+ }));
1138
+ // --- network_requests ---
1139
+ exports.tools.set('network_requests', tool({
1140
+ description: `List network requests made by the page. Use to verify API calls were made, check response status codes, or debug network issues. Returns a numbered list with method, URL, and status code.
1141
+
1142
+ Filter by URL pattern (regex) and optionally include static resources (images, fonts, scripts). By default, only API/XHR requests are shown.`,
1143
+ inputSchema: zod_1.z.object({
1144
+ filter: zod_1.z.string().optional()
1145
+ .describe('Regex pattern to filter URLs (e.g. "/api/.*user")'),
1146
+ includeStatic: zod_1.z.boolean().default(false)
1147
+ .describe('Include static resources (images, fonts, scripts). Default false.'),
1148
+ }),
1149
+ execute: async function (input) {
1150
+ const requests = this._networkRequests || [];
1151
+ const filter = input.filter ? new RegExp(input.filter) : undefined;
1152
+ const lines = [];
1153
+ for (let i = 0; i < requests.length; i++) {
1154
+ const req = requests[i];
1155
+ if (!input.includeStatic && req.isStatic)
1156
+ continue;
1157
+ if (filter && !filter.test(req.url))
1158
+ continue;
1159
+ const status = req.status ? ` [${req.status}]` : '';
1160
+ const method = req.method || '?';
1161
+ lines.push(`[${i + 1}] ${method} ${req.url.slice(0, 120)}${status}`);
1162
+ }
1163
+ if (lines.length === 0) {
1164
+ return `No matching network requests. Total captured: ${requests.length}.`;
1165
+ }
1166
+ return `Network requests (${lines.length} shown, ${requests.length} total):\n${lines.join('\n')}`;
1167
+ },
1168
+ }));
1169
+ // --- resize_viewport ---
1170
+ exports.tools.set('resize_viewport', tool({
1171
+ description: `Resize the browser viewport (window size). Use for responsive design testing or to trigger layout changes at specific viewport sizes. Common breakpoints: 375×667 (mobile), 768×1024 (tablet), 1280×720 (desktop), 1920×1080 (full HD).`,
1172
+ inputSchema: zod_1.z.object({
1173
+ width: zod_1.z.number().min(320).max(3840).describe('Viewport width in pixels'),
1174
+ height: zod_1.z.number().min(240).max(2160).describe('Viewport height in pixels'),
1175
+ }),
1176
+ execute: async function (input) {
1177
+ await this.page.setViewportSize({ width: input.width, height: input.height });
1178
+ await this.page.waitForTimeout(300);
1179
+ return `✅ Viewport resized to ${input.width}×${input.height}`;
1180
+ },
1181
+ }));
750
1182
  // ── verify_expected fully removed (Phase 6) ──
751
1183
  // LLM now sees screenshots via multimodal prompt and self-evaluates.
752
1184
  // No separate verification tool or function needed — each step's screenshot
@@ -51,6 +51,7 @@
51
51
  > 2. **绝不硬编码等待时间**:不使用 `waitForTimeout()`,通过 POM waitForReady 或断言等待
52
52
  > 3. **绝不硬编码环境数据**(D05):ID/配置值从 `fixtures/test-data.ts` 导入
53
53
  > 4. **每个测试覆盖恰好一个排列**:测试体内无 if/else 分支逻辑
54
+ > 5. **绝不使用 `waitForLoadState('networkidle')`**:BI 仪表盘等页面有 WebSocket/SSE/长轮询等持久连接,`networkidle` 永远不会 resolve 导致无限挂起。使用 `waitForLoadState('domcontentloaded')` 或 `page.goto(url, { waitUntil: 'domcontentloaded' })` 代替
54
55
 
55
56
  ### 正确模式 vs 禁止模式
56
57
 
@@ -64,6 +65,16 @@ await expect(page.locator('[data-e2e-name="智能应用列表-筛选区域"]')).
64
65
  await page.locator('.filter-area').click();
65
66
  await page.waitForTimeout(5000);
66
67
  await agentDetailPage.navigate(636, 'edit');
68
+
69
+ // ❌ 禁止:waitForLoadState('networkidle') — BI 页面有持久连接会无限挂起
70
+ await page.goto(url);
71
+ await page.waitForLoadState('networkidle'); // ← 永远不会 resolve!
72
+
73
+ // ✅ 正确:使用 domcontentloaded 替代
74
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
75
+ // 或
76
+ await page.goto(url);
77
+ await page.waitForLoadState('domcontentloaded');
67
78
  ```
68
79
 
69
80
  ### fixtures/test-data.ts 结构
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kkeve/suite",
3
- "version": "0.0.13",
3
+ "version": "0.0.17",
4
4
  "description": "AI-powered full-stack test agent CLI - static analysis, unit test, component test, E2E test, execute, evaluate, and report",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -13,24 +13,32 @@
13
13
  "default": "./dist/index.js"
14
14
  },
15
15
  "./global-setup": {
16
- "types": "./dist/core/global-setup.d.ts",
17
- "default": "./dist/core/global-setup.js"
16
+ "types": "./dist/engine-playwright/global-setup.d.ts",
17
+ "default": "./dist/engine-playwright/global-setup.js"
18
18
  },
19
19
  "./keve-report": {
20
- "types": "./dist/core/keve-report.d.ts",
21
- "default": "./dist/core/keve-report.js"
20
+ "types": "./dist/engine-playwright/keve-report.d.ts",
21
+ "default": "./dist/engine-playwright/keve-report.js"
22
22
  },
23
23
  "./keve-test": {
24
- "types": "./dist/core/keve-test.d.ts",
25
- "default": "./dist/core/keve-test.js"
24
+ "types": "./dist/engine-playwright/keve-test.d.ts",
25
+ "default": "./dist/engine-playwright/keve-test.js"
26
+ },
27
+ "./engine-cypress": {
28
+ "types": "./dist/engine-cypress/index.d.ts",
29
+ "default": "./dist/engine-cypress/index.js"
30
+ },
31
+ "./engine-cypress/setup": {
32
+ "types": "./dist/engine-cypress/setup.d.ts",
33
+ "default": "./dist/engine-cypress/setup.js"
26
34
  },
27
35
  "./page-agent": {
28
36
  "types": "./dist/page-agent/agent.d.ts",
29
37
  "default": "./dist/page-agent/agent.js"
30
38
  },
31
39
  "./keve-aspect": {
32
- "types": "./dist/core/keve-aspect.d.ts",
33
- "default": "./dist/core/keve-aspect.js"
40
+ "types": "./dist/core/decorator/keve-aspect.d.ts",
41
+ "default": "./dist/core/decorator/keve-aspect.js"
34
42
  },
35
43
  "./action-log": {
36
44
  "types": "./dist/core/action-log-writer.d.ts",
@@ -40,6 +48,10 @@
40
48
  "types": "./dist/core/learned-actions.d.ts",
41
49
  "default": "./dist/core/learned-actions.js"
42
50
  },
51
+ "./dsl": {
52
+ "types": "./dist/dsl/index.d.ts",
53
+ "default": "./dist/dsl/index.js"
54
+ },
43
55
  "./spec/keve-read-doc": {
44
56
  "types": "./dist/spec/keve-read-doc.d.ts",
45
57
  "default": "./dist/spec/keve-read-doc.js"
@@ -47,11 +59,12 @@
47
59
  },
48
60
  "files": [
49
61
  "dist",
62
+ "scripts",
50
63
  "doc",
51
64
  "README.md"
52
65
  ],
53
66
  "scripts": {
54
- "build": "rm -rf dist && tsc && chmod +x dist/bin/keve.js && cp src/page-agent/system_prompt.md dist/page-agent/",
67
+ "build": "node scripts/gen-system-prompt.mjs && rm -rf dist && tsc && chmod +x dist/bin/keve.js && cp src/page-agent/system_prompt.md dist/page-agent/",
55
68
  "dev": "tsc --watch",
56
69
  "typecheck": "tsc --noEmit",
57
70
  "keve": "node dist/bin/keve.js",
@@ -62,7 +75,7 @@
62
75
  "url": "https://github.com/bubblem1029-tech/EVE.git"
63
76
  },
64
77
  "dependencies": {
65
- "@kkeve/core": "^0.0.4",
78
+ "@kkeve/core": "^0.0.5",
66
79
  "@playwright/test": "*",
67
80
  "@vitest/coverage-v8": "^4.1.9",
68
81
  "@vue/test-utils": "^2.4.6",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 把 page-agent 的 system_prompt.md 编译成浏览器安全的 TS 模块。
3
+ *
4
+ * 为什么需要:Cypress 引擎会把 page-agent 打进浏览器 bundle,运行时用
5
+ * `node:fs` 读 md 文件会让 esbuild 直接失败。这里在构建期把文本内联为
6
+ * 一个普通字符串常量,两个引擎共用同一份 prompt,避免出现两套文案漂移。
7
+ *
8
+ * 用法:node scripts/gen-system-prompt.mjs
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import * as url from 'node:url';
13
+
14
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
15
+ const src = path.join(here, '..', 'src', 'page-agent', 'system_prompt.md');
16
+ const out = path.join(here, '..', 'src', 'page-agent', 'system-prompt.ts');
17
+
18
+ const text = fs.readFileSync(src, 'utf8');
19
+
20
+ fs.writeFileSync(
21
+ out,
22
+ `/**
23
+ * system-prompt — 由 scripts/gen-system-prompt.mjs 从 system_prompt.md 生成,请勿手改。
24
+ *
25
+ * 生成物是纯字符串常量,不含任何 Node 运行时依赖,因此可以被 Cypress 的浏览器
26
+ * bundle 安全地打包。修改 prompt 请改 system_prompt.md 后重新生成本文件。
27
+ */
28
+
29
+ export const SYSTEM_PROMPT: string = ${JSON.stringify(text)};
30
+ `,
31
+ 'utf8',
32
+ );
33
+
34
+ console.log(
35
+ `[gen-system-prompt] ${path.relative(process.cwd(), src)} → ${path.relative(process.cwd(), out)} (${text.length} chars)`,
36
+ );