@kkeve/suite 0.0.13 → 0.0.15

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.
@@ -230,7 +230,10 @@ async function run(options) {
230
230
  console.log(chalk_1.default.blue(` ▶ Round ${round} — Executing Playwright tests...\n`));
231
231
  await ensurePlaywrightBrowsers();
232
232
  if (!env.KEVE_CDP_ENDPOINT && !env.KEVE_CDP_WS_ENDPOINT) {
233
- env.KEVE_CDP_ENDPOINT = 'http://127.0.0.1:9222';
233
+ // 不再盲目设置 KEVE_CDP_ENDPOINT fallback — 如果 browserSetup() 未成功启动 Chrome,
234
+ // 这个 fallback 会让 isCdpMode() 返回 true → getCdpBrowser() 去连一个不存在的 CDP → throw
235
+ // 现在由 getCdpBrowser() 内部处理 graceful fallback 到非 CDP 模式
236
+ console.log(chalk_1.default.gray(' Note: No CDP endpoint configured — Playwright will launch its own browser'));
234
237
  }
235
238
  const exitCode = await new Promise((resolve) => {
236
239
  const child = (0, node_child_process_1.spawn)('npx', pwArgs, {
@@ -73,9 +73,12 @@ function resolveOptions(opts) {
73
73
  // 2. ktest 原始的 TCDPQA100195 / TCDPQA100189 等:「大写字母+数字」连续无分隔
74
74
  // (ktest 命名规则:TC=TestCase DPQA=数据平台QA 100195=序列号)
75
75
  function parseCaseIdFromTitle(title) {
76
- const colonMatch = title.match(/^([A-Z]+(?:-[A-Z]+)*-\d+|[A-Z]+\d+):/);
77
- const bracketMatch = title.match(/\[([A-Z]+(?:-[A-Z]+)*-\d+|[A-Z]+\d+)\]/);
78
- const spaceMatch = title.match(/^([A-Z]+(?:-[A-Z]+)*-\d+|[A-Z]+\d+)\s/);
76
+ // Case ID patterns: "SKILL_SHARE-01:", "AG-01:", "ABC123:", "[REG-02]"
77
+ // Supports: underscores (SKILL_SHARE), hyphens (REG-01), pure alphanumeric (ABC123)
78
+ const caseIdPattern = '[A-Z][A-Z0-9]*(?:[_-][A-Z0-9]+)*-\\d+|[A-Z]+\\d+';
79
+ const colonMatch = title.match(new RegExp(`^(${caseIdPattern}):`));
80
+ const bracketMatch = title.match(new RegExp(`\\[(${caseIdPattern})\\]`));
81
+ const spaceMatch = title.match(new RegExp(`^(${caseIdPattern})\\s`));
79
82
  return colonMatch?.[1] || bracketMatch?.[1] || spaceMatch?.[1] || null;
80
83
  }
81
84
  /**
@@ -1,6 +1,6 @@
1
1
  declare global {
2
2
  var __keve_cdp_browser: import('@playwright/test').Browser | undefined;
3
3
  }
4
- export declare function getCdpBrowser(): Promise<import('@playwright/test').Browser>;
4
+ export declare function getCdpBrowser(): Promise<import('@playwright/test').Browser | null>;
5
5
  export declare function isCdpMode(): boolean;
6
6
  export default function (): Promise<void>;
@@ -50,21 +50,30 @@ async function getCdpBrowser() {
50
50
  if (!wsUrl && process.env.KEVE_CDP_ENDPOINT) {
51
51
  const httpUrl = process.env.KEVE_CDP_ENDPOINT.replace(/\/$/, '');
52
52
  try {
53
- const resp = await fetch(`${httpUrl}/json/version`);
53
+ const resp = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(5000) });
54
54
  const data = await resp.json();
55
55
  wsUrl = data.webSocketDebuggerUrl;
56
56
  console.log(`[keve CDP] Resolved WS endpoint: ${wsUrl}`);
57
57
  }
58
58
  catch (e) {
59
- throw new Error(`Failed to resolve CDP WebSocket URL from ${httpUrl}: ${e.message}`);
59
+ console.warn(`[keve CDP] Failed to resolve CDP WebSocket URL from ${httpUrl}: ${e.message} — falling back to non-CDP mode`);
60
+ return null;
60
61
  }
61
62
  }
62
- if (!wsUrl)
63
- throw new Error('No CDP endpoint configured');
64
- const browser = await chromium.connectOverCDP(wsUrl, { timeout: 15000 });
65
- console.log(`[keve CDP] Connected via ${wsUrl}`);
66
- globalThis.__keve_cdp_browser = browser;
67
- return browser;
63
+ if (!wsUrl) {
64
+ console.warn(`[keve CDP] No CDP endpoint configured — falling back to non-CDP mode`);
65
+ return null;
66
+ }
67
+ try {
68
+ const browser = await chromium.connectOverCDP(wsUrl, { timeout: 15000 });
69
+ console.log(`[keve CDP] Connected via ${wsUrl}`);
70
+ globalThis.__keve_cdp_browser = browser;
71
+ return browser;
72
+ }
73
+ catch (connectErr) {
74
+ console.warn(`[keve CDP] connectOverCDP failed: ${connectErr.message} — falling back to non-CDP mode`);
75
+ return null;
76
+ }
68
77
  }
69
78
  function isCdpMode() {
70
79
  return !!process.env.KEVE_CDP_WS_ENDPOINT || !!process.env.KEVE_CDP_ENDPOINT;
@@ -76,8 +85,9 @@ async function default_1() {
76
85
  if (cdpEndpoint) {
77
86
  console.log(`[keve global-setup] CDP endpoint detected: ${cdpEndpoint}`);
78
87
  // CDP 模式:在 global-setup 中提前连接一次,缓存 Browser 对象
79
- try {
80
- const browser = await getCdpBrowser();
88
+ // getCdpBrowser() 失败时返回 null 并清除 CDP 环境变量 → 后续 fixture 自动 fallback
89
+ const browser = await getCdpBrowser();
90
+ if (browser) {
81
91
  const contexts = browser.contexts();
82
92
  const pages = contexts.length > 0 ? contexts[0].pages() : [];
83
93
  console.log(`[keve global-setup] CDP connected. contexts=${contexts.length}, pages=${pages.length}`);
@@ -85,8 +95,8 @@ async function default_1() {
85
95
  console.log(`[keve global-setup] Existing tabs: ${pages.map(p => p.url()).join(', ')}`);
86
96
  }
87
97
  }
88
- catch (e) {
89
- console.warn(`[keve global-setup] CDP connection failed: ${e.message}`);
98
+ else {
99
+ console.warn(`[keve global-setup] CDP connection failed — tests will use non-CDP fallback (chromium.launch)`);
90
100
  }
91
101
  if (!fs.existsSync(storageStatePath)) {
92
102
  const authDir = path.dirname(storageStatePath);
@@ -104,8 +104,17 @@ exports.test = test_1.test.extend({
104
104
  browser: async ({}, use, testInfo) => {
105
105
  if ((0, global_setup_1.isCdpMode)()) {
106
106
  const browser = await (0, global_setup_1.getCdpBrowser)(); // 复用全局共享的 CDP browser
107
- await use(browser);
108
- // CDP 模式下不关闭浏览器(共享连接,关闭会影响后续用例)
107
+ if (browser) {
108
+ await use(browser);
109
+ // CDP 模式下不关闭浏览器(共享连接,关闭会影响后续用例)
110
+ }
111
+ else {
112
+ // CDP 不可达 → graceful fallback 到独立启动浏览器
113
+ console.log(`[keve-test] CDP unavailable, falling back to chromium.launch()`);
114
+ const fallback = await test_1.chromium.launch(testInfo.project?.use?.launchOptions || {});
115
+ await use(fallback);
116
+ await fallback.close();
117
+ }
109
118
  }
110
119
  else {
111
120
  const browser = await test_1.chromium.launch(testInfo.project?.use?.launchOptions || {});
@@ -114,7 +123,8 @@ exports.test = test_1.test.extend({
114
123
  }
115
124
  },
116
125
  page: async ({ browser }, use) => {
117
- if ((0, global_setup_1.isCdpMode)()) {
126
+ const isCdp = (0, global_setup_1.isCdpMode)() && browser.isConnected();
127
+ if (isCdp) {
118
128
  const contexts = browser.contexts();
119
129
  const cdpContext = contexts.length > 0 ? contexts[0] : await browser.newContext();
120
130
  // CDP 模式:优先复用已有 page(保留登录状态),仅在无可用 page 时新开
@@ -224,43 +234,69 @@ exports.test = test_1.test.extend({
224
234
  };
225
235
  }
226
236
  else {
227
- // ── Per-goal timeout: prevent one slow goal from exhausting the test timeout ──
228
- // Default 300s per goal; total test timeout should be ≥ (goals × 300s + overhead)
229
- const GOAL_TIMEOUT_MS = 300_000;
230
- const goalTimeoutController = new AbortController();
231
- const goalTimeout = setTimeout(() => {
232
- goalTimeoutController.abort();
233
- console.log(`[keveGoal] ⏰ "${options.step}" timed out after ${GOAL_TIMEOUT_MS}ms — stopping agent`);
234
- }, GOAL_TIMEOUT_MS);
237
+ // ── Page liveness check: if fn killed the page, skip Agent Re-Act ──
238
+ // When fn error involves page/browser close, the page is dead —
239
+ // Agent cannot take a11y snapshots or interact. Skip immediately.
240
+ let pageAlive = true;
235
241
  try {
236
- reactResult = await (0, agent_1.reactLoop)(page, options.step, options.expected, {
237
- learnedActionsHint: learnedHint,
238
- specFilePath: ctx.specFilePath,
239
- fnSource,
240
- fnResult: fnError ? { error: fnError } : { success: true },
241
- signal: goalTimeoutController.signal,
242
- goalScreenshotBefore: goalScreenshotBefore || undefined,
243
- fnAfterScreenshot: fnAfterScreenshot || undefined,
244
- });
242
+ await page.url();
243
+ }
244
+ catch {
245
+ pageAlive = false;
245
246
  }
246
- catch (reactErr) {
247
- reactTimedOut = true;
248
- const isGoalTimeout = goalTimeoutController.signal.aborted;
249
- const msg = isGoalTimeout
250
- ? `Goal timed out (${GOAL_TIMEOUT_MS}ms)`
251
- : (reactErr?.message || String(reactErr));
252
- console.log(`[keveGoal] reactLoop interrupted: ${msg.slice(0, 200)}`);
247
+ if (!pageAlive) {
248
+ console.log(`[keveGoal] 🚫 "${options.step}" BLOCKED — page is dead after fn error, skipping agent Re-Act`);
253
249
  reactResult = {
254
250
  expectedMet: false,
255
- actions: [],
251
+ actions: [{
252
+ action: { tool: 'done', verdict: 'blocked', text: `Page closed after fn error: ${fnError}` },
253
+ toolOutput: '',
254
+ result: 'ok',
255
+ }],
256
256
  finalSnapshot: '',
257
257
  agentScreenshots: [],
258
- conclusion: isGoalTimeout ? 'blocked' : 'fail',
258
+ conclusion: 'blocked',
259
259
  };
260
260
  }
261
- finally {
262
- clearTimeout(goalTimeout);
263
- }
261
+ else {
262
+ // ── Per-goal timeout: prevent one slow goal from exhausting the test timeout ──
263
+ // Default 300s per goal; total test timeout should be ≥ (goals × 300s + overhead)
264
+ const GOAL_TIMEOUT_MS = 300_000;
265
+ const goalTimeoutController = new AbortController();
266
+ const goalTimeout = setTimeout(() => {
267
+ goalTimeoutController.abort();
268
+ console.log(`[keveGoal] ⏰ "${options.step}" timed out after ${GOAL_TIMEOUT_MS}ms — stopping agent`);
269
+ }, GOAL_TIMEOUT_MS);
270
+ try {
271
+ reactResult = await (0, agent_1.reactLoop)(page, options.step, options.expected, {
272
+ learnedActionsHint: learnedHint,
273
+ specFilePath: ctx.specFilePath,
274
+ fnSource,
275
+ fnResult: fnError ? { error: fnError } : { success: true },
276
+ signal: goalTimeoutController.signal,
277
+ goalScreenshotBefore: goalScreenshotBefore || undefined,
278
+ fnAfterScreenshot: fnAfterScreenshot || undefined,
279
+ });
280
+ }
281
+ catch (reactErr) {
282
+ reactTimedOut = true;
283
+ const isGoalTimeout = goalTimeoutController.signal.aborted;
284
+ const msg = isGoalTimeout
285
+ ? `Goal timed out (${GOAL_TIMEOUT_MS}ms)`
286
+ : (reactErr?.message || String(reactErr));
287
+ console.log(`[keveGoal] reactLoop interrupted: ${msg.slice(0, 200)}`);
288
+ reactResult = {
289
+ expectedMet: false,
290
+ actions: [],
291
+ finalSnapshot: '',
292
+ agentScreenshots: [],
293
+ conclusion: isGoalTimeout ? 'blocked' : 'fail',
294
+ };
295
+ }
296
+ finally {
297
+ clearTimeout(goalTimeout);
298
+ }
299
+ } // end of page-alive else (agent Re-Act path)
264
300
  } // end of else (non-blocked path)
265
301
  // ── Deterministic override: fn assertion failure cannot be overridden by Agent "pass" ──
266
302
  // When fn has a hard assertion failure (Expected X, Received Y), the value mismatch
@@ -466,6 +502,16 @@ function isEnvironmentBlockedError(err) {
466
502
  || msg.includes('err_too_many_redirects')) {
467
503
  return true;
468
504
  }
505
+ // Page/context/browser closed — dead page, Agent cannot explore
506
+ // e.g. "page.waitForLoadState: Target page, context or browser has been closed"
507
+ // e.g. "page.ariaSnapshot: Target page, context or browser has been closed"
508
+ // When the CDP connection drops or the user closes the tab, any page API throws this.
509
+ // Agent Re-Act on a dead page is a waste of time → BLOCKED immediately.
510
+ if (msg.includes('target page, context or browser has been closed')
511
+ || msg.includes('page has been closed') || msg.includes('browser has been closed')
512
+ || msg.includes('context has been closed')) {
513
+ return true;
514
+ }
469
515
  return false;
470
516
  }
471
517
  /**
@@ -85,6 +85,19 @@ export declare class KevePageAgent {
85
85
  /** Current execute options (set at start of execute(), accessible from hooks) */
86
86
  options?: AgentOptions;
87
87
  private hooks?;
88
+ _dialogHandler: {
89
+ accept: boolean;
90
+ promptText?: string;
91
+ } | null;
92
+ _lastDialogInfo: {
93
+ type: string;
94
+ message: string;
95
+ accepted: boolean;
96
+ } | null;
97
+ _pendingFileChooser: any;
98
+ _consoleMessages: any[];
99
+ _networkRequests: any[];
100
+ _pageListenersSetup: boolean;
88
101
  constructor(page: Page, options?: {
89
102
  maxSteps?: number;
90
103
  customSystemPrompt?: string;
@@ -92,6 +105,9 @@ export declare class KevePageAgent {
92
105
  });
93
106
  /** Stop the current execution */
94
107
  stop(): void;
108
+ /** Set up page event listeners for dialog, console, network, and file chooser capture.
109
+ * Called once per page; subsequent calls are no-ops. */
110
+ private setupPageListeners;
95
111
  /** Main Re-Act execute loop */
96
112
  execute(step: string, expected: string, options?: AgentOptions): Promise<AgentResult>;
97
113
  private pushObservation;
@@ -157,6 +157,13 @@ class KevePageAgent {
157
157
  /** Current execute options (set at start of execute(), accessible from hooks) */
158
158
  options;
159
159
  hooks;
160
+ // Page event listener state (used by handle_dialog, console_messages, file_upload, network_requests)
161
+ _dialogHandler = null;
162
+ _lastDialogInfo = null;
163
+ _pendingFileChooser = null;
164
+ _consoleMessages = [];
165
+ _networkRequests = [];
166
+ _pageListenersSetup = false;
160
167
  constructor(page, options) {
161
168
  this.page = page;
162
169
  this.maxSteps = options?.maxSteps ?? 8;
@@ -175,12 +182,95 @@ class KevePageAgent {
175
182
  stop() {
176
183
  this.abortController.abort();
177
184
  }
185
+ /** Set up page event listeners for dialog, console, network, and file chooser capture.
186
+ * Called once per page; subsequent calls are no-ops. */
187
+ setupPageListeners() {
188
+ if (this._pageListenersSetup)
189
+ return;
190
+ this._pageListenersSetup = true;
191
+ // ── Dialog handler ──
192
+ // Playwright auto-dismisses dialogs unless a handler is registered.
193
+ // Our handler auto-accepts by default, but _dialogHandler can override.
194
+ this.page.on('dialog', async (dialog) => {
195
+ const handler = this._dialogHandler;
196
+ const accept = handler?.accept ?? true;
197
+ const promptText = handler?.promptText;
198
+ try {
199
+ if (accept) {
200
+ await dialog.accept(promptText || '');
201
+ }
202
+ else {
203
+ await dialog.dismiss();
204
+ }
205
+ }
206
+ catch {
207
+ // Fallback: always accept to unblock the page
208
+ try {
209
+ await dialog.accept();
210
+ }
211
+ catch { /* give up */ }
212
+ }
213
+ this._lastDialogInfo = {
214
+ type: dialog.type(),
215
+ message: dialog.message(),
216
+ accepted: accept,
217
+ };
218
+ this._dialogHandler = null;
219
+ // Notify the Agent so it knows a dialog appeared
220
+ this.pushObservation(`🔔 Native dialog [${dialog.type()}] appeared: "${dialog.message().slice(0, 100)}" — was ${accept ? 'accepted' : 'dismissed'}.`);
221
+ });
222
+ // ── Console messages ──
223
+ this.page.on('console', (msg) => {
224
+ this._consoleMessages.push({ level: msg.type(), text: msg.text() });
225
+ if (this._consoleMessages.length > 200)
226
+ this._consoleMessages.shift();
227
+ });
228
+ this.page.on('pageerror', (err) => {
229
+ this._consoleMessages.push({ level: 'error', text: `Uncaught: ${err.message}` });
230
+ });
231
+ // ── Network requests ──
232
+ this.page.on('request', (req) => {
233
+ const url = req.url();
234
+ const isStatic = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map)(\?|$)/i.test(url);
235
+ this._networkRequests.push({
236
+ _req: req,
237
+ method: req.method(),
238
+ url,
239
+ isStatic,
240
+ status: null,
241
+ });
242
+ if (this._networkRequests.length > 100)
243
+ this._networkRequests.shift();
244
+ });
245
+ this.page.on('response', (res) => {
246
+ const req = res.request();
247
+ // Match by reference identity — Playwright guarantees same Request object
248
+ for (let i = this._networkRequests.length - 1; i >= 0; i--) {
249
+ if (this._networkRequests[i]._req === req) {
250
+ this._networkRequests[i].status = res.status();
251
+ break;
252
+ }
253
+ }
254
+ });
255
+ // ── File chooser ──
256
+ this.page.on('filechooser', (fileChooser) => {
257
+ this._pendingFileChooser = fileChooser;
258
+ });
259
+ }
178
260
  /** Main Re-Act execute loop */
179
261
  async execute(step, expected, options) {
180
262
  // Reset state
181
263
  this.events = [];
182
264
  this.abortController = new AbortController();
183
265
  this.options = options;
266
+ // Reset page event listener state
267
+ this._dialogHandler = null;
268
+ this._lastDialogInfo = null;
269
+ this._pendingFileChooser = null;
270
+ this._consoleMessages = [];
271
+ this._networkRequests = [];
272
+ // Set up page event listeners (idempotent — only runs once per page)
273
+ this.setupPageListeners();
184
274
  const hooks = options?.hooks ?? this.hooks;
185
275
  // Link external signal (e.g. per-goal timeout) to internal abort controller
186
276
  const externalSignal = options?.signal;
@@ -293,7 +293,7 @@ You MUST output a JSON object with the following FLAT structure every step:
293
293
  "evaluation_previous_goal": "Concise one-sentence analysis of your last action. State success, failure, or uncertain. Reference the screenshot if it confirms or contradicts the expected state.",
294
294
  "memory": "1-3 concise sentences of key observations that will help in future steps.",
295
295
  "next_goal": "State the next immediate goal and action to achieve it.",
296
- "tool": "The action tool name (click, type, hover, navigate, scroll, wait, done, visual_locate, visual_assert, etc.)",
296
+ "tool": "The action tool name (click, type, hover, navigate, scroll, wait, wait_for, done, drag, verify_value, handle_dialog, console_messages, file_upload, network_requests, resize_viewport, visual_locate, visual_assert, select_option, fill_form, pressKey, execute_javascript, etc.)",
297
297
  "ref": "Element ref from the accessibility tree, e.g. 'f5e13' (for click/type/hover)",
298
298
  "text": "Text to type (for type) OR description for done/visual_locate/visual_assert",
299
299
  ...other tool-specific fields...
@@ -499,7 +499,7 @@ exports.tools.set('navigate', tool({
499
499
  }
500
500
  url = new URL(url, base).href;
501
501
  }
502
- await this.page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
502
+ await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
503
503
  return `✅ Navigated to ${url}`;
504
504
  },
505
505
  }));
@@ -747,6 +747,302 @@ exports.tools.set('fill_form', tool({
747
747
  return results.join('\n');
748
748
  },
749
749
  }));
750
+ // --- drag ---
751
+ exports.tools.set('drag', tool({
752
+ 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.
753
+
754
+ Two targeting modes:
755
+ 1. By ref — drags from the element's center (use for drag-to-reorder)
756
+ 2. By coordinates (startX/startY) — precise point (use for resize handles found via visual_locate)
757
+
758
+ Target specification:
759
+ - Relative: use deltaX/deltaY (e.g. deltaX=200 moves 200px right)
760
+ - Absolute: use endX/endY (e.g. endX=800, endY=400)
761
+
762
+ NOT for: scrolling (use scroll), clicking (use click), HTML5 DnD between elements.`,
763
+ inputSchema: zod_1.z.object({
764
+ ref: zod_1.z.string().optional()
765
+ .describe('Element ref to start dragging from. Drags from element center. Use startX/startY for resize handles.'),
766
+ startX: zod_1.z.number().optional()
767
+ .describe('X coordinate to start dragging from (viewport pixels). Use when you have precise coordinates from visual_locate.'),
768
+ startY: zod_1.z.number().optional()
769
+ .describe('Y coordinate to start dragging from (viewport pixels).'),
770
+ deltaX: zod_1.z.number().optional()
771
+ .describe('Horizontal displacement in pixels. Positive=right, negative=left. Use with ref or startX/startY.'),
772
+ deltaY: zod_1.z.number().optional()
773
+ .describe('Vertical displacement in pixels. Positive=down, negative=up.'),
774
+ endX: zod_1.z.number().optional()
775
+ .describe('Absolute X coordinate to drag to (viewport pixels). Use deltaX/deltaY for relative, endX/endY for absolute.'),
776
+ endY: zod_1.z.number().optional()
777
+ .describe('Absolute Y coordinate to drag to (viewport pixels).'),
778
+ steps: zod_1.z.number().min(1).max(100).default(10)
779
+ .describe('Number of intermediate mousemove steps. More=smoother animation. Default 10. Use 20+ for CSS resize.'),
780
+ }),
781
+ execute: async function (input) {
782
+ // 1. Determine start coordinates
783
+ let sx, sy;
784
+ if (input.ref) {
785
+ const locator = this.page.locator(`aria-ref=${input.ref}`);
786
+ try {
787
+ const box = await locator.boundingBox({ timeout: 5000 });
788
+ if (!box)
789
+ return `❌ Cannot drag: element [ref=${input.ref}] not visible or detached`;
790
+ sx = box.x + box.width / 2;
791
+ sy = box.y + box.height / 2;
792
+ }
793
+ catch (err) {
794
+ return `❌ Cannot drag: element [ref=${input.ref}] error: ${err.message}`;
795
+ }
796
+ }
797
+ else if (input.startX !== undefined && input.startY !== undefined) {
798
+ sx = input.startX;
799
+ sy = input.startY;
800
+ }
801
+ else {
802
+ return `❌ Cannot drag: provide either ref or startX/startY`;
803
+ }
804
+ // 2. Determine end coordinates
805
+ let ex, ey;
806
+ if (input.endX !== undefined && input.endY !== undefined) {
807
+ ex = input.endX;
808
+ ey = input.endY;
809
+ }
810
+ else if (input.deltaX !== undefined || input.deltaY !== undefined) {
811
+ ex = sx + (input.deltaX || 0);
812
+ ey = sy + (input.deltaY || 0);
813
+ }
814
+ else {
815
+ return `❌ Cannot drag: provide either deltaX/deltaY or endX/endY`;
816
+ }
817
+ // 3. Execute real mouse drag with intermediate steps
818
+ const steps = input.steps ?? 10;
819
+ try {
820
+ await this.page.mouse.move(sx, sy);
821
+ await this.page.mouse.down();
822
+ for (let i = 1; i <= steps; i++) {
823
+ const progress = i / steps;
824
+ await this.page.mouse.move(sx + (ex - sx) * progress, sy + (ey - sy) * progress);
825
+ await this.page.waitForTimeout(16); // ~60fps
826
+ }
827
+ await this.page.mouse.up();
828
+ await this.page.waitForTimeout(300); // wait for resize/layout to settle
829
+ 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)})`;
830
+ }
831
+ catch (err) {
832
+ try {
833
+ await this.page.mouse.up();
834
+ }
835
+ catch { /* ensure mouse released */ }
836
+ return `❌ Drag failed: ${err.message}`;
837
+ }
838
+ },
839
+ }));
840
+ // --- wait_for ---
841
+ exports.tools.set('wait_for', tool({
842
+ description: `Wait for text to appear or disappear on the page, or for a specified duration. More efficient than polling with wait(time).
843
+
844
+ - waitForText: wait until specific text becomes visible on the page (e.g. wait for "保存成功" toast)
845
+ - waitForTextGone: wait until specific text disappears (e.g. wait for "加载中..." spinner to vanish)
846
+ - seconds: simple time-based wait (max 30s)
847
+
848
+ At least one parameter is required. You can combine them (e.g. wait for text + timeout).`,
849
+ inputSchema: zod_1.z.object({
850
+ waitForText: zod_1.z.string().optional()
851
+ .describe('Wait for this text to appear on the page (visible)'),
852
+ waitForTextGone: zod_1.z.string().optional()
853
+ .describe('Wait for this text to disappear from the page (hidden)'),
854
+ seconds: zod_1.z.number().min(0.1).max(30).optional()
855
+ .describe('Wait time in seconds (max 30)'),
856
+ }),
857
+ execute: async function (input) {
858
+ if (!input.waitForText && !input.waitForTextGone && !input.seconds) {
859
+ return `❌ wait_for: provide at least one of waitForText, waitForTextGone, or seconds`;
860
+ }
861
+ const results = [];
862
+ if (input.seconds) {
863
+ await this.page.waitForTimeout(input.seconds * 1000);
864
+ results.push(`waited ${input.seconds}s`);
865
+ }
866
+ if (input.waitForTextGone) {
867
+ const locator = this.page.getByText(input.waitForTextGone).first();
868
+ try {
869
+ await locator.waitFor({ state: 'hidden', timeout: 15000 });
870
+ results.push(`"${input.waitForTextGone}" disappeared`);
871
+ }
872
+ catch {
873
+ results.push(`⚠️ "${input.waitForTextGone}" still visible after 15s`);
874
+ }
875
+ }
876
+ if (input.waitForText) {
877
+ const locator = this.page.getByText(input.waitForText).first();
878
+ try {
879
+ await locator.waitFor({ state: 'visible', timeout: 15000 });
880
+ results.push(`"${input.waitForText}" appeared`);
881
+ }
882
+ catch {
883
+ results.push(`⚠️ "${input.waitForText}" not found after 15s`);
884
+ }
885
+ }
886
+ return `✅ ${results.join(', ')}`;
887
+ },
888
+ }));
889
+ // --- verify_value ---
890
+ exports.tools.set('verify_value', tool({
891
+ description: `Deterministically verify a form element's current value — no LLM needed. Faster and more precise than visual_assert for value checks.
892
+
893
+ - textbox/slider/combobox → checks locator.inputValue()
894
+ - checkbox/radio → checks locator.isChecked()
895
+
896
+ Returns ✅ if value matches, ❌ if mismatch. Use for precise value assertions instead of visual inspection.`,
897
+ inputSchema: zod_1.z.object({
898
+ ref: zod_1.z.string().describe('Element ref from the accessibility tree'),
899
+ type: zod_1.z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider'])
900
+ .describe('Element type: textbox/slider/combobox → check inputValue; checkbox/radio → check isChecked'),
901
+ value: zod_1.z.string().describe('Expected value. For checkbox/radio: "true" or "false". For textbox: expected text. For combobox: expected selected value.'),
902
+ }),
903
+ execute: async function (input) {
904
+ const locator = this.page.locator(`aria-ref=${input.ref}`);
905
+ try {
906
+ if (input.type === 'checkbox' || input.type === 'radio') {
907
+ const checked = await locator.isChecked({ timeout: 5000 });
908
+ const expected = input.value === 'true';
909
+ if (checked === expected) {
910
+ return `✅ Verify [ref=${input.ref}] ${input.type}: checked=${checked} (expected ${input.value})`;
911
+ }
912
+ return `❌ Verify [ref=${input.ref}] ${input.type}: checked=${checked} (expected ${input.value})`;
913
+ }
914
+ // textbox, slider, combobox — check inputValue
915
+ const actualValue = await locator.inputValue({ timeout: 5000 });
916
+ if (actualValue === input.value) {
917
+ return `✅ Verify [ref=${input.ref}] ${input.type}: value="${actualValue}" (expected "${input.value}")`;
918
+ }
919
+ // For combobox, also check textContent (custom components may not expose value via inputValue)
920
+ if (input.type === 'combobox') {
921
+ const textContent = await locator.textContent({ timeout: 2000 }).catch(() => '');
922
+ if (textContent?.includes(input.value)) {
923
+ return `✅ Verify [ref=${input.ref}] combobox: text contains "${input.value}" (inputValue="${actualValue}")`;
924
+ }
925
+ }
926
+ return `❌ Verify [ref=${input.ref}] ${input.type}: value="${actualValue}" (expected "${input.value}")`;
927
+ }
928
+ catch (err) {
929
+ return `❌ Verify [ref=${input.ref}] failed: ${err.message}`;
930
+ }
931
+ },
932
+ }));
933
+ // --- handle_dialog ---
934
+ exports.tools.set('handle_dialog', tool({
935
+ description: `Handle native browser dialogs (alert, confirm, prompt). Native dialogs are auto-accepted by default when they appear.
936
+
937
+ Two usage modes:
938
+ 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.
939
+ 2. AFTER a dialog appeared: call handle_dialog to check what dialog was captured and how it was handled.
940
+
941
+ 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.`,
942
+ inputSchema: zod_1.z.object({
943
+ accept: zod_1.z.boolean().default(true).describe('Whether to accept (true) or dismiss (false) the next native dialog. Default true.'),
944
+ promptText: zod_1.z.string().optional().describe('Text to enter in prompt dialog (only when accept=true and dialog is a prompt)'),
945
+ }),
946
+ execute: async function (input) {
947
+ // Set handler for the NEXT dialog
948
+ this._dialogHandler = { accept: input.accept, promptText: input.promptText };
949
+ // Also report the last captured dialog (if any)
950
+ const last = this._lastDialogInfo;
951
+ if (last) {
952
+ return `Last dialog: [${last.type}] "${last.message.slice(0, 80)}" — was ${last.accepted ? 'accepted' : 'dismissed'}. Next dialog will be ${input.accept ? 'accepted' : 'dismissed'}.`;
953
+ }
954
+ return `Next native dialog will be ${input.accept ? 'accepted' : 'dismissed'}${input.promptText ? ` with text "${input.promptText}"` : ''}.`;
955
+ },
956
+ }));
957
+ // --- console_messages ---
958
+ exports.tools.set('console_messages', tool({
959
+ 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.`,
960
+ inputSchema: zod_1.z.object({
961
+ level: zod_1.z.enum(['error', 'warning', 'info', 'debug']).default('info')
962
+ .describe('Minimum log level. "error"=only errors, "warning"=errors+warnings, "info"=add info logs, "debug"=all. Default "info".'),
963
+ }),
964
+ execute: async function (input) {
965
+ const messages = this._consoleMessages || [];
966
+ const levelPriority = { debug: 0, info: 1, warning: 2, error: 3 };
967
+ const minPriority = levelPriority[input.level] ?? 1;
968
+ const filtered = messages.filter((m) => (levelPriority[m.level] ?? 0) >= minPriority);
969
+ if (filtered.length === 0) {
970
+ return `No console messages at level "${input.level}" or above. Total captured: ${messages.length}.`;
971
+ }
972
+ const formatted = filtered.map((m, i) => `[${i + 1}] ${m.level.toUpperCase()}: ${m.text.slice(0, 200)}`).join('\n');
973
+ return `Console messages (${filtered.length}/${messages.length} at level ≥ "${input.level}"):\n${formatted}`;
974
+ },
975
+ }));
976
+ // --- file_upload ---
977
+ exports.tools.set('file_upload', tool({
978
+ 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.
979
+
980
+ Flow: 1) Click the file input button (e.g. "上传文件", "选择文件"), 2) Call file_upload with the file paths.
981
+
982
+ Paths must be absolute. The file chooser is single-use — after uploading, a new click is needed for another upload.`,
983
+ inputSchema: zod_1.z.object({
984
+ paths: zod_1.z.array(zod_1.z.string()).describe('Absolute file paths to upload'),
985
+ }),
986
+ execute: async function (input) {
987
+ const fileChooser = this._pendingFileChooser;
988
+ if (!fileChooser) {
989
+ return `❌ No pending file chooser. Click a file input element first to trigger a file chooser dialog.`;
990
+ }
991
+ try {
992
+ await fileChooser.setFiles(input.paths);
993
+ this._pendingFileChooser = null;
994
+ return `✅ Uploaded ${input.paths.length} file(s): ${input.paths.map(p => p.split('/').pop()).join(', ')}`;
995
+ }
996
+ catch (err) {
997
+ this._pendingFileChooser = null;
998
+ return `❌ File upload failed: ${err.message}`;
999
+ }
1000
+ },
1001
+ }));
1002
+ // --- network_requests ---
1003
+ exports.tools.set('network_requests', tool({
1004
+ 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.
1005
+
1006
+ Filter by URL pattern (regex) and optionally include static resources (images, fonts, scripts). By default, only API/XHR requests are shown.`,
1007
+ inputSchema: zod_1.z.object({
1008
+ filter: zod_1.z.string().optional()
1009
+ .describe('Regex pattern to filter URLs (e.g. "/api/.*user")'),
1010
+ includeStatic: zod_1.z.boolean().default(false)
1011
+ .describe('Include static resources (images, fonts, scripts). Default false.'),
1012
+ }),
1013
+ execute: async function (input) {
1014
+ const requests = this._networkRequests || [];
1015
+ const filter = input.filter ? new RegExp(input.filter) : undefined;
1016
+ const lines = [];
1017
+ for (let i = 0; i < requests.length; i++) {
1018
+ const req = requests[i];
1019
+ if (!input.includeStatic && req.isStatic)
1020
+ continue;
1021
+ if (filter && !filter.test(req.url))
1022
+ continue;
1023
+ const status = req.status ? ` [${req.status}]` : '';
1024
+ const method = req.method || '?';
1025
+ lines.push(`[${i + 1}] ${method} ${req.url.slice(0, 120)}${status}`);
1026
+ }
1027
+ if (lines.length === 0) {
1028
+ return `No matching network requests. Total captured: ${requests.length}.`;
1029
+ }
1030
+ return `Network requests (${lines.length} shown, ${requests.length} total):\n${lines.join('\n')}`;
1031
+ },
1032
+ }));
1033
+ // --- resize_viewport ---
1034
+ exports.tools.set('resize_viewport', tool({
1035
+ 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).`,
1036
+ inputSchema: zod_1.z.object({
1037
+ width: zod_1.z.number().min(320).max(3840).describe('Viewport width in pixels'),
1038
+ height: zod_1.z.number().min(240).max(2160).describe('Viewport height in pixels'),
1039
+ }),
1040
+ execute: async function (input) {
1041
+ await this.page.setViewportSize({ width: input.width, height: input.height });
1042
+ await this.page.waitForTimeout(300);
1043
+ return `✅ Viewport resized to ${input.width}×${input.height}`;
1044
+ },
1045
+ }));
750
1046
  // ── verify_expected fully removed (Phase 6) ──
751
1047
  // LLM now sees screenshots via multimodal prompt and self-evaluates.
752
1048
  // 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.15",
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": {