From c2adb7a34faa5d93003373a338960cc9494eb18f Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 15 Oct 2025 13:57:24 +0800 Subject: [PATCH 1/3] feat: add star gif in readme --- README.md | 8 ++++++++ README_zh-CN.md | 8 ++++++++ docs/images/star.gif | Bin 0 -> 32738 bytes 3 files changed, 16 insertions(+) create mode 100644 docs/images/star.gif diff --git a/README.md b/README.md index 43c5d21..663e0e4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@

English · 简体中文

+

+ If you like WebQA Agent, please give us a ⭐ on GitHub! +
+ + Click Star + +

+

🤖 WebQA Agent is an autonomous web browser agent that audits performance, functionality & UX for engineers and vibe-coding creators. ✨

diff --git a/README_zh-CN.md b/README_zh-CN.md index 1649fc9..9e9134f 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -28,6 +28,14 @@

English · 简体中文

+

+ 如果觉得有帮助,欢迎在 GitHub 上点个 ⭐ 支持! +
+ + Click Star + +

+

🤖 WebQA Agent 是全自动网页评估测试 Agent,一键完成性能、功能与交互体验的测试评估 ✨

diff --git a/docs/images/star.gif b/docs/images/star.gif new file mode 100644 index 0000000000000000000000000000000000000000..8141f435e07d0eb91e179485b96844316d54af0a GIT binary patch literal 32738 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 Date: Tue, 21 Oct 2025 18:27:43 +0800 Subject: [PATCH 2/3] feat: 1. add schema for action tool and verify tool; 2.synchronize actions with action_executor.py; 3. update crawl parameters --- webqa_agent/actions/action_executor.py | 4 +- webqa_agent/testers/case_gen/graph.py | 2 +- .../testers/case_gen/prompts/agent_prompts.py | 28 ++-- .../case_gen/tools/element_action_tool.py | 129 ++++++++++++++++-- 4 files changed, 137 insertions(+), 26 deletions(-) diff --git a/webqa_agent/actions/action_executor.py b/webqa_agent/actions/action_executor.py index 7c4a917..a9dd6cd 100644 --- a/webqa_agent/actions/action_executor.py +++ b/webqa_agent/actions/action_executor.py @@ -145,7 +145,7 @@ async def _execute_keyboard_press(self, action): else: return {"success": False, "message": "Keyboard press failed."} - async def _execute_get_new_page(self, action): + async def _execute_get_new_page(self): """Execute get new page action.""" success = await self._actions.get_new_page() if success: @@ -313,7 +313,7 @@ async def _execute_go_to_page(self, action): logging.error(f"Go to page action failed: {str(e)}") return {"success": False, "message": f"Navigation failed: {str(e)}", "playwright_error": str(e)} - async def _execute_go_back(self, action): + async def _execute_go_back(self): """Execute browser back navigation action.""" try: if hasattr(self._actions, 'go_back'): diff --git a/webqa_agent/testers/case_gen/graph.py b/webqa_agent/testers/case_gen/graph.py index 21d3f31..7250628 100644 --- a/webqa_agent/testers/case_gen/graph.py +++ b/webqa_agent/testers/case_gen/graph.py @@ -296,7 +296,7 @@ async def reflect_and_replan(state: MainGraphState) -> dict: logging.debug(f"current page crawled result: {page_content_summary}") screenshot = await ui_tester._actions.b64_page_screenshot(file_name="reflection", save_to_log=False, full_page=False) await dp.remove_marker() - await dp.crawl(highlight=False, highlight_text=True, viewport_only=True) + await dp.crawl(highlight=False, filter_text=True, viewport_only=True) page_structure = dp.get_text() logging.debug(f"----- reflection ---- Page structure: {page_structure}") diff --git a/webqa_agent/testers/case_gen/prompts/agent_prompts.py b/webqa_agent/testers/case_gen/prompts/agent_prompts.py index 5510e23..734f6b4 100644 --- a/webqa_agent/testers/case_gen/prompts/agent_prompts.py +++ b/webqa_agent/testers/case_gen/prompts/agent_prompts.py @@ -32,7 +32,7 @@ def get_execute_system_prompt(case: dict) -> str: - **`execute_ui_action(action: str, target: str, value: Optional[str], description: Optional[str], clear_before_type: bool)`**: Performs UI interactions such as clicking, typing, scrolling, dropdown selection, etc. - - `action`: Action type ('click', 'type', 'scroll', 'SelectDropdown', 'clear', etc.) + - `action`: Action type ('Tap', 'Input', 'Scroll', 'SelectDropdown', 'Clear', 'Hover', 'KeyboardPress', 'Upload', 'Drag', 'GoToPage', 'GoBack', 'Sleep', 'GetNewPage', 'Mouse') - `target`: Element descriptor (use natural language descriptions) - `value`: Input value for text-based actions - `description`: Purpose of the action for logging and context @@ -285,9 +285,9 @@ def get_execute_system_prompt(case: dict) -> str: ### Example 1: Form Field Validation Recovery **Context**: Registration form with character length requirements -**Initial Action**: `execute_ui_action(action='type', target='usage scenario field', value='test', description='Enter usage scenario')` +**Initial Action**: `execute_ui_action(action='Input', target='usage scenario field', value='test', description='Enter usage scenario')` **Tool Response**: `[FAILURE] Validation error detected: Usage scenario must be at least 30 characters` -**Recovery Action**: `execute_ui_action(action='type', target='usage scenario field', value='This is a comprehensive usage scenario description for research and development purposes in academic and commercial settings', description='Enter extended usage scenario meeting length requirements', clear_before_type=True)` +**Recovery Action**: `execute_ui_action(action='Input', target='usage scenario field', value='This is a comprehensive usage scenario description for research and development purposes in academic and commercial settings', description='Enter extended usage scenario meeting length requirements', clear_before_type=True)` ### Example 2: Dropdown Language Adaptation **Context**: Bilingual interface with Chinese dropdown options @@ -297,37 +297,37 @@ def get_execute_system_prompt(case: dict) -> str: ### Example 3: Dynamic Content Waiting **Context**: API-populated dropdown requiring wait time -**Step 1**: `execute_ui_action(action='click', target='country dropdown', description='Open country selection dropdown')` +**Step 1**: `execute_ui_action(action='Tap', target='country dropdown', description='Open country selection dropdown')` **Tool Response**: `[SUCCESS] Dropdown opened, loading options...` -**Step 2**: `execute_ui_action(action='sleep', target='', value='2000', description='Wait for options to load')` -**Step 3**: `execute_ui_action(action='click', target='option containing "Canada"', description='Select Canada from loaded options')` +**Step 2**: `execute_ui_action(action='Sleep', target='', value='2000', description='Wait for options to load')` +**Step 3**: `execute_ui_action(action='Tap', target='option containing "Canada"', description='Select Canada from loaded options')` ### Example 4: Element State Change Handling **Context**: Button state change after interaction -**Initial Action**: `execute_ui_action(action='click', target='submit button', description='Submit form')` +**Initial Action**: `execute_ui_action(action='Tap', target='submit button', description='Submit form')` **Tool Response**: `[SUCCESS] Form submitted, button disabled and showing 'Processing...'` -**Recovery Action**: `execute_ui_action(action='wait', target='', value='3000', description='Wait for processing to complete')` +**Recovery Action**: `execute_ui_action(action='Sleep', target='', value='3000', description='Wait for processing to complete')` **Follow-up**: `execute_ui_assertion(assertion='Verify success message appears and button returns to normal state')` ### Example 5: Multi-Action Instruction Handling **Context**: Instruction contains multiple actions "Browse the homepage top navigation bar, click one by one: 'Visitor', 'Alumni', 'Donate', 'Careers' links" **First Action Identification**: The first mentioned action is "Visitor" link -**Correct Agent Response**: Execute only the FIRST action - `execute_ui_action(action='click', target='Visitor link', description='Click the visitor link in the top navigation bar')` -**Tool Response**: `[SUCCESS] Action 'click' on 'Visitor link' completed successfully` +**Correct Agent Response**: Execute only the FIRST action - `execute_ui_action(action='Tap', target='Visitor link', description='Click the visitor link in the top navigation bar')` +**Tool Response**: `[SUCCESS] Action 'Tap' on 'Visitor link' completed successfully` **Agent Reporting**: Report completion of the single action and allow framework to proceed to next step ### Example 6: Another Multi-Action Instruction Handling **Context**: Instruction contains "Click on the 'Login', 'Register', and 'Help' links in the header" **First Action Identification**: The first mentioned action is "Login" link -**Correct Agent Response**: Execute only the FIRST action - `execute_ui_action(action='click', target='Login link', description='Click the Login link in the header')` -**Tool Response**: `[SUCCESS] Action 'click' on 'Login link' completed successfully` +**Correct Agent Response**: Execute only the FIRST action - `execute_ui_action(action='Tap', target='Login link', description='Click the Login link in the header')` +**Tool Response**: `[SUCCESS] Action 'Tap' on 'Login link' completed successfully` **Agent Reporting**: Report completion of the single action and allow framework to proceed to next step ### Example 7: Numbered List Multi-Action Handling **Context**: Instruction contains "1. Enter username 2. Enter password 3. Click submit" **First Action Identification**: The numbered step #1 is "Enter username" -**Correct Agent Response**: Execute only the FIRST action - `execute_ui_action(action='type', target='username field', value='testuser', description='Enter username in the username field')` -**Tool Response**: `[SUCCESS] Action 'type' on 'username field' completed successfully` +**Correct Agent Response**: Execute only the FIRST action - `execute_ui_action(action='Input', target='username field', value='testuser', description='Enter username in the username field')` +**Tool Response**: `[SUCCESS] Action 'Input' on 'username field' completed successfully` **Agent Reporting**: Report completion of the single action and allow framework to proceed to next step ## Test Completion Protocol diff --git a/webqa_agent/testers/case_gen/tools/element_action_tool.py b/webqa_agent/testers/case_gen/tools/element_action_tool.py index 6bea62d..86853bf 100644 --- a/webqa_agent/testers/case_gen/tools/element_action_tool.py +++ b/webqa_agent/testers/case_gen/tools/element_action_tool.py @@ -7,21 +7,88 @@ import datetime import json import logging -from typing import Any, Dict, Optional +from typing import Optional, Type from langchain_core.tools import BaseTool -from pydantic import Field +from pydantic import BaseModel, Field from webqa_agent.crawler.deep_crawler import DeepCrawler -from webqa_agent.testers.case_gen.prompts.tool_prompts import get_error_detection_prompt from webqa_agent.testers.function_tester import UITester +class UIActionSchema(BaseModel): + """Schema for UI action tool arguments.""" + + action: str = Field( + description=( + "Type of UI action to perform. Supported actions: " + "'Tap' - Click on an element; " + "'Input' - Type text into an input field; " + "'SelectDropdown' - Select an option from a dropdown menu (supports cascade selection with comma-separated paths); " + "'Scroll' - Scroll the page with configurable modes ('once', 'untilBottom', 'untilTop') and optional distance; " + "'Clear' - Clear the content of an input field; " + "'Hover' - Hover over an element; " + "'KeyboardPress' - Press a keyboard key; " + "'Upload' - Upload a file; " + "'Drag' - Drag an element to a target position; " + "'GoToPage' - Navigate to a URL; " + "'GoBack' - Navigate back to the previous page; " + "'Sleep' - Wait for a specified duration; " + "'GetNewPage' - Switch to a new tab or window; " + "'Mouse' - Move mouse cursor or scroll mouse wheel." + ) + ) + + target: str = Field( + description=( + "Element identifier or selector to target. " + "For most actions, this should be the element ID from the page description. " + "For Scroll actions, this can be a scroll target description. " + "For GoToPage action, this should be the URL." + ) + ) + + value: Optional[str] = Field( + default=None, + description=( + "Value to use for the action. " + "Required for 'Input' action (text to type), " + "'SelectDropdown' action (option text or comma-separated cascade path like 'Category,Subcategory,Item'), " + "'Scroll' action (direction 'up' or 'down', with optional scrollType and distance description), " + "'KeyboardPress' action (key name like 'Enter', 'Tab', 'Escape', etc.), " + "'Upload' action (file path), " + "'Sleep' action (duration in milliseconds), " + "'Mouse' action (operation type: 'move' for cursor positioning or 'wheel' for scrolling). " + "Optional for 'Drag' action (target position description), " + "'GetNewPage' action (tab/window identifier). " + "Optional for other actions." + ) + ) + + description: Optional[str] = Field( + default=None, + description=( + "Optional custom description of what this action is intended to do. " + "Helps provide context for the action in test reports." + ) + ) + + clear_before_type: bool = Field( + default=False, + description=( + "Whether to clear the input field before typing. " + "Only applicable for 'Input' action. " + "Set to True to clear existing content before typing new text." + ) + ) + + class UITool(BaseTool): """A tool to interact with a UI via a UITester instance.""" name: str = "execute_ui_action" description: str = "Executes a UI action using the UITester and returns a structured summary of the new page state." + args_schema: Type[BaseModel] = UIActionSchema ui_tester_instance: UITester = Field(...) async def get_full_page_context( @@ -36,7 +103,7 @@ async def get_full_page_context( logging.debug(f"Retrieving page context for analysis (viewport_only={viewport_only})") page = self.ui_tester_instance.driver.get_page() dp = DeepCrawler(page) - await dp.crawl(highlight=True, highlight_text=True, viewport_only=viewport_only) + await dp.crawl(highlight=True, filter_text=True, viewport_only=viewport_only) page_structure = dp.get_text() screenshot = None @@ -77,20 +144,47 @@ async def _arun( logging.debug(f"Using custom description: {description}") # Build the action phrase - if action.lower() == "click": + if action == "Tap": action_phrase = f"Click on the {target}" - elif action.lower() == "type": + elif action == "Input": if clear_before_type: action_phrase = f"Clear the {target} field and then type '{value}'" logging.debug("Using clear-before-type strategy") else: action_phrase = f"Type '{value}' in the {target}" - elif action.lower() == "selectdropdown": + elif action == "SelectDropdown": action_phrase = f"From the {target}, select the option '{value}'" - elif action.lower() == "scroll": + elif action == "Scroll": action_phrase = f"Scroll {value or 'down'} on the page" - elif action.lower() == "clear": + elif action == "Clear": action_phrase = f"Clear the content of {target}" + elif action == "Hover": + action_phrase = f"Hover over {target}" + elif action == "KeyboardPress": + action_phrase = f"Press the {value} key" + elif action == "Upload": + action_phrase = f"Upload file {value} to {target}" + elif action == "Drag": + action_phrase = f"Drag {target}" + if value: + action_phrase += f" to {value}" + elif action == "GoToPage": + action_phrase = f"Navigate to {target}" + elif action == "GoBack": + action_phrase = f"Navigate back to the previous page" + elif action == "Sleep": + action_phrase = f"Wait for {value or '1000'} milliseconds" + elif action == "GetNewPage": + action_phrase = f"Switch to new page/tab" + if value: + action_phrase += f" {value}" + elif action == "Mouse": + if value and 'move' in value.lower(): + action_phrase = f"Move mouse cursor to {target}" + elif value and 'wheel' in value.lower(): + action_phrase = f"Scroll mouse wheel on {target}" + else: + action_phrase = f"Perform mouse action on {target}" else: action_phrase = f"{action} on {target}" if value: @@ -168,11 +262,28 @@ async def _arun( return f"[FAILURE] {error_msg}" +class UIAssertionSchema(BaseModel): + """Schema for UI assertion tool arguments.""" + + assertion: str = Field( + description=( + "The assertion or validation to perform on the current page state. " + "Should be a clear, specific statement of what to verify. " + "Examples: " + "'The login button should be visible', " + "'The error message should contain the text \"Invalid credentials\"', " + "'The page title should be \"Dashboard\"', " + "'There should be 5 items in the shopping cart'." + ) + ) + + class UIAssertTool(BaseTool): """A tool to perform UI assertions via a UITester instance.""" name: str = "execute_ui_assertion" description: str = "Performs a UI assertion/validation using the UITester and returns the verification result." + args_schema: Type[BaseModel] = UIAssertionSchema ui_tester_instance: UITester = Field(...) def _run(self, assertion: str) -> str: From 8dd2760f2ed37bde21993e5949c2379af865d57b Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 24 Oct 2025 10:26:08 +0800 Subject: [PATCH 3/3] [WIP]feat: support full page planning and scroll --- webqa_agent/actions/action_executor.py | 462 +++++++++++++- webqa_agent/actions/action_handler.py | 594 +++++++++++++++++- webqa_agent/llm/prompt.py | 7 +- webqa_agent/testers/case_gen/graph.py | 17 +- .../testers/case_gen/prompts/agent_prompts.py | 66 ++ .../case_gen/prompts/planning_prompts.py | 140 ++--- .../testers/case_gen/prompts/tool_prompts.py | 191 ------ .../case_gen/tools/element_action_tool.py | 125 +++- .../case_gen/utils/message_converter.py | 33 - .../testers/case_gen/utils/prompt_utils.py | 39 -- webqa_agent/testers/function_tester.py | 14 +- 11 files changed, 1257 insertions(+), 431 deletions(-) delete mode 100644 webqa_agent/testers/case_gen/prompts/tool_prompts.py delete mode 100644 webqa_agent/testers/case_gen/utils/prompt_utils.py diff --git a/webqa_agent/actions/action_executor.py b/webqa_agent/actions/action_executor.py index a9dd6cd..3bb8085 100644 --- a/webqa_agent/actions/action_executor.py +++ b/webqa_agent/actions/action_executor.py @@ -2,6 +2,8 @@ import logging from typing import Dict, List, Optional +from webqa_agent.actions.action_handler import action_context_var + class ActionExecutor: def __init__(self, action_handler): @@ -66,31 +68,131 @@ async def _execute_clear(self, action): """Execute clear action on an input field.""" if not self._validate_params(action, ["locate.id"]): return {"success": False, "message": "Missing locate.id for clear action"} + success = await self._actions.clear(action.get("locate").get("id")) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": "Clear action successful."} else: - return {"success": False, "message": "Clear action failed. The element might not be clearable."} + # Enrich error message with context + base_message = "Clear action failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "element_not_found": + base_message = "Clear failed: Element not found on page." + elif ctx.error_type == "element_not_typeable": + base_message = "Clear failed: Element cannot be cleared." + elif ctx.error_type == "playwright_error": + base_message = "Clear failed: Browser interaction error." + else: + base_message = "Clear action failed. The element might not be clearable." + + return { + "success": False, + "message": base_message, + "error_details": error_details + } async def _execute_tap(self, action): """Execute tap/click action.""" if not self._validate_params(action, ["locate.id"]): return {"success": False, "message": "Missing locate.id for tap action"} + success = await self._actions.click(action.get("locate").get("id")) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": "Tap action successful."} else: - return {"success": False, "message": "Tap action failed. The element might not be clickable."} + # Enrich error message with context + base_message = "Tap action failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "scroll_failed": + base_message = f"Tap failed: Could not scroll element into viewport after {ctx.scroll_attempts} attempts." + elif ctx.error_type == "scroll_timeout_lazy_loading": + base_message = f"Tap failed: Element viewport positioning succeeded but page content unstable after {ctx.scroll_attempts} attempts." + elif ctx.error_type == "element_not_found": + base_message = f"Tap failed: Element not found on page." + elif ctx.error_type == "element_not_clickable": + base_message = f"Tap failed: Element exists but is not clickable." + elif ctx.error_type == "playwright_error": + base_message = f"Tap failed: Browser interaction error." + else: + base_message = "Tap action failed. The element might not be clickable." + + return { + "success": False, + "message": base_message, + "error_details": error_details # NEW: additional metadata, won't break existing consumers + } async def _execute_hover(self, action): """Execute hover action.""" if not self._validate_params(action, ["locate.id"]): return {"success": False, "message": "Missing locate.id for hover action"} + success = await self._actions.hover(action.get("locate").get("id")) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": "Hover action successful."} else: - return {"success": False, "message": "Hover action failed. The element might not be hoverable."} + # Enrich error message with context + base_message = "Hover action failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "scroll_failed": + base_message = f"Hover failed: Could not scroll element into viewport after {ctx.scroll_attempts} attempts." + elif ctx.error_type == "element_not_found": + base_message = f"Hover failed: Element not found on page or missing coordinates." + elif ctx.error_type == "playwright_error": + base_message = f"Hover failed: Browser interaction error." + else: + base_message = "Hover action failed. The element might not be hoverable." + + return { + "success": False, + "message": base_message, + "error_details": error_details + } async def _execute_sleep(self, action): """Execute sleep/wait action.""" @@ -110,12 +212,44 @@ async def _execute_input(self, action): success = await self._actions.type( action.get("locate").get("id"), value, clear_before_type=clear_before_type ) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": "Input action successful."} else: + # Enrich error message with context + base_message = "Input action failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "scroll_failed": + base_message = f"Input failed: Could not scroll element into viewport after {ctx.scroll_attempts} attempts." + elif ctx.error_type == "element_not_found": + base_message = f"Input failed: Element not found on page." + elif ctx.error_type == "element_not_typeable": + base_message = f"Input failed: Element exists but cannot accept text input." + elif ctx.error_type == "element_not_clickable": + base_message = f"Input failed: Could not focus element for typing." + elif ctx.error_type == "playwright_error": + base_message = f"Input failed: Browser interaction error." + else: + base_message = "Input action failed. The element might not be available for typing." + return { "success": False, - "message": "Input action failed. The element might not be available for typing.", + "message": base_message, + "error_details": error_details } except Exception as e: logging.error(f"Action '_execute_input' execution failed: {str(e)}") @@ -139,11 +273,39 @@ async def _execute_keyboard_press(self, action): """Execute keyboard press action.""" if not self._validate_params(action, ["param.value"]): return {"success": False, "message": "Missing param.value for keyboard press action"} + success = await self._actions.keyboard_press(action.get("param").get("value")) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": "Keyboard press successful."} else: - return {"success": False, "message": "Keyboard press failed."} + # Enrich error message with context + base_message = "Keyboard press failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "playwright_error": + base_message = "Keyboard press failed: Browser interaction error." + else: + base_message = "Keyboard press failed." + + return { + "success": False, + "message": base_message, + "error_details": error_details + } async def _execute_get_new_page(self): """Execute get new page action.""" @@ -157,11 +319,43 @@ async def _execute_upload(self, action, file_path): """Execute upload action.""" if not self._validate_params(action, ["locate.id"]): return {"success": False, "message": "Missing locate.id for upload action"} + success = await self._actions.upload_file(action.get("locate").get("id"), file_path) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": "File upload successful."} else: - return {"success": False, "message": "File upload failed."} + # Enrich error message with context + base_message = "File upload failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "file_upload_failed": + base_message = "File upload failed: Operation error." + elif ctx.error_type == "element_not_found": + base_message = "File upload failed: No file input element found on page." + elif ctx.error_type == "playwright_error": + base_message = "File upload failed: Browser interaction error." + else: + base_message = "File upload failed." + + return { + "success": False, + "message": base_message, + "error_details": error_details + } async def _execute_select_dropdown(self, action): """Execute select dropdown action.""" @@ -299,34 +493,172 @@ async def _execute_go_to_page(self, action): page = getattr(self._actions, 'page', None) if page: navigation_performed = await self._actions.smart_navigate_to_page(page, url) - message = "Navigated to page" if navigation_performed else "Already on target page" - return {"success": True, "message": message} + + # Read action context for detailed error information + ctx = action_context_var.get() + + if navigation_performed or navigation_performed is None: + message = "Navigated to page" if navigation_performed else "Already on target page" + return {"success": True, "message": message} + else: + # Navigation failed, enrich error message with context + base_message = "Navigation to page failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "playwright_error": + base_message = f"Navigation failed: Browser interaction error." + else: + base_message = f"Navigation failed: {ctx.error_reason or 'Unknown reason'}" + + return { + "success": False, + "message": base_message, + "error_details": error_details + } # Fallback to regular navigation if hasattr(self._actions, 'go_to_page') and hasattr(self._actions, 'page'): await self._actions.go_to_page(self._actions.page, url) - return {"success": True, "message": "Successfully navigated to page"} + + # Read action context for detailed error information + ctx = action_context_var.get() + + # Check if navigation succeeded by checking context + if not ctx or not ctx.error_type: + return {"success": True, "message": "Successfully navigated to page"} + else: + # Navigation failed, enrich error message with context + base_message = "Navigation to page failed." + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + if ctx.error_type == "playwright_error": + base_message = f"Navigation failed: Browser interaction error." + else: + base_message = f"Navigation failed: {ctx.error_reason or 'Unknown reason'}" + + return { + "success": False, + "message": base_message, + "error_details": error_details + } return {"success": False, "message": "Navigation method not available"} except Exception as e: logging.error(f"Go to page action failed: {str(e)}") - return {"success": False, "message": f"Navigation failed: {str(e)}", "playwright_error": str(e)} + + # Read action context for any additional error information + ctx = action_context_var.get() + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error or str(e) + } + else: + error_details = { + "error_type": "playwright_error", + "error_reason": "Navigation failed with an exception", + "attempted_strategies": [], + "element_info": {}, + "playwright_error": str(e) + } + + return { + "success": False, + "message": f"Navigation failed: {str(e)}", + "error_details": error_details + } async def _execute_go_back(self): """Execute browser back navigation action.""" try: if hasattr(self._actions, 'go_back'): success = await self._actions.go_back() + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": "Successfully navigated back to previous page"} else: - return {"success": False, "message": "Go back navigation failed"} + # Navigation failed, enrich error message with context + base_message = "Go back navigation failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "playwright_error": + base_message = f"Go back failed: Browser interaction error." + else: + base_message = f"Go back failed: {ctx.error_reason or 'Unknown reason'}" + else: + base_message = "Go back navigation failed. No previous page in history or navigation not possible." + + return { + "success": False, + "message": base_message, + "error_details": error_details + } else: return {"success": False, "message": "Go back action not supported by action handler"} except Exception as e: logging.error(f"Go back action failed: {str(e)}") - return {"success": False, "message": f"Go back failed: {str(e)}", "playwright_error": str(e)} + + # Read action context for any additional error information + ctx = action_context_var.get() + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error or str(e) + } + else: + error_details = { + "error_type": "playwright_error", + "error_reason": "Go back navigation failed with an exception", + "attempted_strategies": [], + "element_info": {}, + "playwright_error": str(e) + } + + return { + "success": False, + "message": f"Go back failed: {str(e)}", + "error_details": error_details + } async def _execute_mouse(self, action): """Unified mouse action supporting move and wheel. @@ -340,9 +672,9 @@ async def _execute_mouse(self, action): param = action.get("param") if not param or not isinstance(param, dict): return {"success": False, "message": "Missing or invalid param for mouse action"} - + op = param.get("op") - + # Auto-detect if op not provided or empty if not op: if "x" in param and "y" in param: @@ -355,39 +687,123 @@ async def _execute_mouse(self, action): if op == "move": if not self._validate_params(action, ["param.x", "param.y"]): return {"success": False, "message": "Missing x or y coordinates for mouse move"} - + x = param.get("x") y = param.get("y") - + # Validate coordinates are numbers if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): return {"success": False, "message": "x and y coordinates must be numbers"} - + success = await self._actions.mouse_move(x, y) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": f"Mouse moved to ({x}, {y})"} else: - return {"success": False, "message": "Mouse move action failed"} + # Mouse move failed, enrich error message with context + base_message = "Mouse move action failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "playwright_error": + base_message = f"Mouse move failed: Browser interaction error." + else: + base_message = f"Mouse move failed: {ctx.error_reason or 'Unknown reason'}" + else: + base_message = f"Mouse move to ({x}, {y}) failed. The operation might not be supported." + + return { + "success": False, + "message": base_message, + "error_details": error_details + } elif op == "wheel": # Default missing keys to 0 dx = param.get("deltaX", 0) dy = param.get("deltaY", 0) - + # Validate deltas are numbers if not isinstance(dx, (int, float)) or not isinstance(dy, (int, float)): return {"success": False, "message": "deltaX and deltaY must be numbers"} - + success = await self._actions.mouse_wheel(dx, dy) + + # Read action context for detailed error information + ctx = action_context_var.get() + if success: return {"success": True, "message": f"Mouse wheel scrolled (deltaX: {dx}, deltaY: {dy})"} else: - return {"success": False, "message": "Mouse wheel action failed"} + # Mouse wheel failed, enrich error message with context + base_message = "Mouse wheel action failed." + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error + } + + # Make message more specific based on error type + if ctx.error_type == "playwright_error": + base_message = f"Mouse wheel scroll failed: Browser interaction error." + else: + base_message = f"Mouse wheel scroll failed: {ctx.error_reason or 'Unknown reason'}" + else: + base_message = f"Mouse wheel scroll (deltaX: {dx}, deltaY: {dy}) failed. The operation might not be supported." + + return { + "success": False, + "message": base_message, + "error_details": error_details + } else: logging.error(f"Unknown mouse op: {op}. Expected 'move' or 'wheel'.") return {"success": False, "message": f"Unknown mouse operation: {op}. Expected 'move' or 'wheel'"} - + except Exception as e: logging.error(f"Mouse action execution failed: {str(e)}") - return {"success": False, "message": f"Mouse action failed with an exception: {e}"} + + # Read action context for any additional error information + ctx = action_context_var.get() + error_details = {} + + if ctx and ctx.error_type: + error_details = { + "error_type": ctx.error_type, + "error_reason": ctx.error_reason, + "attempted_strategies": ctx.attempted_strategies, + "element_info": ctx.element_info, + "playwright_error": ctx.playwright_error or str(e) + } + else: + error_details = { + "error_type": "playwright_error", + "error_reason": "Mouse action failed with an exception", + "attempted_strategies": [], + "element_info": {}, + "playwright_error": str(e) + } + + return { + "success": False, + "message": f"Mouse action failed with an exception: {e}", + "error_details": error_details + } diff --git a/webqa_agent/actions/action_handler.py b/webqa_agent/actions/action_handler.py index bc88718..fe9ff30 100644 --- a/webqa_agent/actions/action_handler.py +++ b/webqa_agent/actions/action_handler.py @@ -1,8 +1,9 @@ -import asyncio import base64 import json import os import re +from contextvars import ContextVar +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Union from playwright.async_api import Page @@ -10,6 +11,58 @@ from webqa_agent.browser.driver import * +# ===== Action Context Infrastructure for Error Propagation ===== + +action_context_var: ContextVar[Optional['ActionContext']] = ContextVar('action_context', default=None) + + +@dataclass +class ActionContext: + """Stores detailed error context for action execution. + + This context is propagated through the execution chain using contextvars, + allowing detailed error information to be passed without changing return types. + """ + error_type: Optional[str] = None + error_reason: Optional[str] = None + attempted_strategies: List[str] = field(default_factory=list) + element_info: Dict[str, Any] = field(default_factory=dict) + scroll_attempts: int = 0 + max_scroll_attempts: int = 0 + playwright_error: Optional[str] = None + + def set_error(self, error_type: str, reason: str, **kwargs): + """Set error information with optional additional fields.""" + self.error_type = error_type + self.error_reason = reason + for key, value in kwargs.items(): + setattr(self, key, value) + + def reset(self): + """Reset context for a new action.""" + self.error_type = None + self.error_reason = None + self.attempted_strategies = [] + self.element_info = {} + self.scroll_attempts = 0 + self.max_scroll_attempts = 0 + self.playwright_error = None + + +# Error type constants for consistent classification +ERROR_SCROLL_FAILED = "scroll_failed" +ERROR_SCROLL_TIMEOUT = "scroll_timeout_lazy_loading" +ERROR_ELEMENT_NOT_FOUND = "element_not_found" +ERROR_NOT_CLICKABLE = "element_not_clickable" +ERROR_NOT_TYPEABLE = "element_not_typeable" +ERROR_ELEMENT_OBSCURED = "element_obscured" +ERROR_DROPDOWN_NO_MATCH = "dropdown_no_match" +ERROR_DROPDOWN_NOT_FOUND = "dropdown_not_found" +ERROR_FILE_UPLOAD_FAILED = "file_upload_failed" +ERROR_ACTION_TIMEOUT = "action_timeout" +ERROR_PLAYWRIGHT = "playwright_error" + + class ActionHandler: def __init__(self): self.page_data = {} @@ -258,7 +311,242 @@ async def perform_scroll(): # Execute scroll operation return True + async def ensure_element_in_viewport(self, element_id: str, max_retries: int = 3, base_wait_time: float = 0.5) -> bool: + """Ensure element is in viewport by scrolling if needed with enhanced edge case handling. + + This method enables full-page planning mode where elements can be planned + from a full-page screenshot but may be outside the viewport during execution. + + Handles edge cases: + - Lazy-loaded content that appears after scrolling + - Infinite scroll pages with dynamic content + - Slow-loading pages with delayed element rendering + + Args: + element_id: Element ID to scroll to + max_retries: Maximum retry attempts for lazy-loaded content (default: 3) + base_wait_time: Base wait time in seconds, will be adaptive (default: 0.5) + + Returns: + bool: True if element is in viewport (or successfully scrolled to), False otherwise + """ + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.max_scroll_attempts = max_retries + ctx.element_info = {"element_id": element_id, "action": "ensure_viewport"} + + element = self.page_element_buffer.get(str(element_id)) + if not element: + logging.warning(f'Element {element_id} not found in buffer for viewport check') + ctx.set_error( + ERROR_ELEMENT_NOT_FOUND, + f"Element {element_id} not found in page element buffer", + element_id=element_id + ) + return False + + # Check if element is already in viewport + is_in_viewport = element.get('isInViewport', True) + if is_in_viewport: + logging.debug(f'Element {element_id} already in viewport, no scroll needed') + return True + + logging.info(f'Element {element_id} is outside viewport, scrolling to make it visible') + + # Get element selectors + selector = element.get('selector') + xpath = element.get('xpath') + + # Retry loop for handling lazy-loaded content + for attempt in range(max_retries): + try: + ctx.scroll_attempts = attempt + 1 + # Adaptive wait time increases with retries for slow-loading content + current_wait_time = base_wait_time * (1 + attempt * 0.5) + + # Strategy 1: Use Playwright's scroll_into_view_if_needed (most reliable) + if self._is_valid_css_selector(selector): + try: + ctx.attempted_strategies.append(f"css_selector_attempt_{attempt + 1}") + await self.page.locator(selector).scroll_into_view_if_needed(timeout=5000) + logging.debug(f'Scrolled to element {element_id} using CSS selector (attempt {attempt + 1})') + + # Wait for scroll animation + potential lazy-loading + await asyncio.sleep(current_wait_time) + + # Verify page stability after scroll (for dynamic content) + await self._wait_for_page_stability() + return True + except Exception as css_error: + ctx.playwright_error = str(css_error) + if attempt < max_retries - 1: + logging.debug(f'CSS selector scroll failed on attempt {attempt + 1}: {css_error}, retrying...') + await asyncio.sleep(current_wait_time) + continue + else: + logging.debug(f'CSS selector scroll failed after {max_retries} attempts: {css_error}, trying XPath') + + # Strategy 2: Try XPath if CSS fails + if xpath: + try: + ctx.attempted_strategies.append(f"xpath_attempt_{attempt + 1}") + await self.page.locator(f'xpath={xpath}').scroll_into_view_if_needed(timeout=5000) + logging.debug(f'Scrolled to element {element_id} using XPath (attempt {attempt + 1})') + + # Wait for scroll animation + potential lazy-loading + await asyncio.sleep(current_wait_time) + + # Verify page stability after scroll + await self._wait_for_page_stability() + return True + except Exception as xpath_error: + ctx.playwright_error = str(xpath_error) + if attempt < max_retries - 1: + logging.debug(f'XPath scroll failed on attempt {attempt + 1}: {xpath_error}, retrying...') + await asyncio.sleep(current_wait_time) + continue + else: + logging.debug(f'XPath scroll failed after {max_retries} attempts: {xpath_error}, trying coordinate-based scroll') + + # Strategy 3: Fallback to coordinate-based scrolling with retry support + center_y = element.get('center_y') + if center_y is not None: + ctx.attempted_strategies.append(f"coordinates_attempt_{attempt + 1}") + viewport_height = await self.page.evaluate('window.innerHeight') + current_scroll_y = await self.page.evaluate('window.scrollY') + + # Calculate target scroll position (center element in viewport) + target_scroll_y = center_y - viewport_height / 2 + target_scroll_y = max(0, target_scroll_y) # Don't scroll above page top + + # Log scroll operation for debugging + logging.debug(f'Scrolling element {element_id}: current scroll position={current_scroll_y}, target scroll position={target_scroll_y}') + + # Perform scroll with smooth behavior + await self.page.evaluate(f'window.scrollTo({{top: {target_scroll_y}, behavior: "smooth"}})') + logging.debug(f'Scrolled to element {element_id} using coordinates (y={target_scroll_y}, attempt {attempt + 1})') + + # Adaptive wait time for smooth scroll + lazy loading + await asyncio.sleep(current_wait_time + 0.3) # Extra time for smooth scroll + + # Verify page stability after scroll + page_stable = await self._wait_for_page_stability() + if not page_stable: + # Page not stable, likely lazy-loading + if attempt == max_retries - 1: + ctx.set_error( + ERROR_SCROLL_TIMEOUT, + f"Element {element_id} viewport positioning succeeded but page content unstable after {max_retries} attempts, possible lazy-loading or infinite scroll", + selector=selector, + xpath=xpath, + center_y=center_y + ) + return True + + # If all strategies failed but we have more retries, wait and continue + if attempt < max_retries - 1: + logging.debug(f'All scroll strategies failed on attempt {attempt + 1}, waiting before retry...') + await asyncio.sleep(current_wait_time * 2) # Longer wait between full retry cycles + continue + + except Exception as e: + ctx.playwright_error = str(e) + if attempt < max_retries - 1: + logging.warning(f'Error scrolling to element {element_id} on attempt {attempt + 1}: {e}, retrying...') + await asyncio.sleep(current_wait_time) + continue + else: + logging.error(f'Error scrolling to element {element_id} after {max_retries} attempts: {e}') + ctx.set_error( + ERROR_SCROLL_FAILED, + f"All scroll strategies failed after {max_retries} attempts with exception: {str(e)}", + selector=selector, + xpath=xpath + ) + return False + + # Final failure: all retries exhausted + logging.warning(f'Could not scroll to element {element_id} after {max_retries} attempts: no valid selectors or all strategies failed') + ctx.set_error( + ERROR_SCROLL_FAILED, + f"Could not scroll to element after {max_retries} attempts: no valid selectors or all scroll strategies (CSS, XPath, coordinates) failed", + selector=selector, + xpath=xpath, + has_valid_selector=self._is_valid_css_selector(selector) if selector else False, + has_xpath=xpath is not None, + has_coordinates=element.get('center_y') is not None + ) + return False + + async def _wait_for_page_stability(self, timeout: float = 2.0, check_interval: float = 0.5) -> bool: + """Wait for page to stabilize after scroll (handles lazy-loading and dynamic content). + + Args: + timeout: Maximum time to wait for stability (default: 2.0 seconds) + check_interval: Interval between stability checks (default: 0.5 seconds) + + Returns: + bool: True if page stabilized, False if timeout reached + """ + try: + elapsed = 0.0 + last_height = await self.page.evaluate('document.body.scrollHeight') + + while elapsed < timeout: + await asyncio.sleep(check_interval) + elapsed += check_interval + + current_height = await self.page.evaluate('document.body.scrollHeight') + + # If page height hasn't changed, consider it stable + if current_height == last_height: + logging.debug(f'Page stabilized after {elapsed:.1f}s') + return True + + last_height = current_height + + logging.debug(f'Page stability timeout after {timeout}s (content may still be loading)') + return False + + except Exception as e: + logging.warning(f'Error checking page stability: {e}') + return False + + async def _convert_document_to_viewport_coords(self, x: float, y: float) -> tuple[float, float]: + """Convert document coordinates to viewport coordinates. + + Document coordinates are relative to the entire page (top-left of document). + Viewport coordinates are relative to the visible area (top-left of viewport). + + Playwright's mouse operations use viewport coordinates, while our crawler + captures document coordinates. This method performs the necessary conversion. + + Args: + x: Document X coordinate (from element center_x) + y: Document Y coordinate (from element center_y) + + Returns: + Tuple of (viewport_x, viewport_y) + + Example: + # Element at document position (500, 1200) with scroll at (0, 800) + # viewport_y = 1200 - 800 = 400 (element is 400px from top of viewport) + """ + scroll_x = await self.page.evaluate('window.pageXOffset || document.documentElement.scrollLeft') + scroll_y = await self.page.evaluate('window.pageYOffset || document.documentElement.scrollTop') + viewport_x = x - scroll_x + viewport_y = y - scroll_y + return (viewport_x, viewport_y) + async def click(self, id) -> bool: + # Initialize action context for error propagation + # Note: If ensure_element_in_viewport is called, it will set its own context + # We only need to initialize context for click-specific failures + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"element_id": str(id), "action": "click"} + # Inject JavaScript into the page to remove the target attribute from all links js = """ links = document.getElementsByTagName("a"); @@ -273,6 +561,11 @@ async def click(self, id) -> bool: element = self.page_element_buffer.get(id) if not element: logging.error(f'Element with id {id} not found in buffer for click action.') + ctx.set_error( + ERROR_ELEMENT_NOT_FOUND, + f"Element {id} not found in page element buffer for click action", + element_id=id + ) return False logging.debug( @@ -281,9 +574,34 @@ async def click(self, id) -> bool: except Exception as e: logging.error(f'failed to get element {id}, element: {self.page_element_buffer.get(id)}, error: {e}') + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Exception while retrieving element {id} from buffer: {str(e)}", + element_id=id, + playwright_error=str(e) + ) return False - return await self.click_using_coordinates(element, id) + # Ensure element is in viewport before clicking (for full-page planning mode) + if not await self.ensure_element_in_viewport(id): + logging.error(f'Cannot click element {id}: failed to scroll element into viewport after multiple attempts') + # Context already populated by ensure_element_in_viewport, preserve it + return False + + # Attempt click - if it fails, populate context with click-specific error + click_result = await self.click_using_coordinates(element, id) + if not click_result: + # Get current context to check if error already set by click_using_coordinates + current_ctx = action_context_var.get() + if current_ctx and not current_ctx.error_type: + current_ctx.set_error( + ERROR_NOT_CLICKABLE, + f"Element {id} found and in viewport, but click action failed", + element_id=id, + tag_name=element.get('tagName'), + selector=element.get('selector') + ) + return click_result async def click_using_coordinates(self, element, id) -> bool: """Helper function to click using coordinates.""" @@ -291,11 +609,13 @@ async def click_using_coordinates(self, element, id) -> bool: y = element.get('center_y') try: if x is not None and y is not None: - logging.debug(f'mouse click at element {id}, coordinate=({x}, {y})') + # Convert document coordinates to viewport coordinates + viewport_x, viewport_y = await self._convert_document_to_viewport_coords(x, y) + logging.debug(f'Mouse click at element {id}, document coordinates=({x}, {y}), viewport coordinates=({viewport_x}, {viewport_y})') try: - await self.page.mouse.click(x, y) + await self.page.mouse.click(viewport_x, viewport_y) except Exception as e: - logging.error(f'mouse click error: {e}\nwith coordinates: ({x}, {y})') + logging.error(f'Mouse click error: {e}\nDocument coordinates: ({x}, {y}), Viewport coordinates: ({viewport_x}, {viewport_y})') return True else: logging.error('Coordinates not found in element data') @@ -305,27 +625,59 @@ async def click_using_coordinates(self, element, id) -> bool: return False async def hover(self, id) -> bool: + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"element_id": str(id), "action": "hover"} + element = self.page_element_buffer.get(str(id)) if not element: logging.error(f'Element with id {id} not found in buffer for hover action.') + ctx.set_error( + ERROR_ELEMENT_NOT_FOUND, + f"Element {id} not found in page element buffer for hover action", + element_id=id + ) return False logging.debug( f"Attempting to hover over element: id={id}, tagName='{element.get('tagName')}', innerText='{element.get('innerText', '').strip()[:50]}', selector='{element.get('selector')}'" ) - scroll_y = await self.page.evaluate('() => window.scrollY') + # Ensure element is in viewport before hovering (for full-page planning mode) + if not await self.ensure_element_in_viewport(str(id)): + logging.error(f'Cannot hover over element {id}: failed to scroll element into viewport after multiple attempts') + # Context already populated by ensure_element_in_viewport, preserve it + return False - x = element.get('center_x') - y = element.get('center_y') - if x is not None and y is not None: - y = y - scroll_y - logging.debug(f'mouse hover at ({x}, {y})') - await self.page.mouse.move(x, y) - await asyncio.sleep(0.5) - return True - else: - logging.error('Coordinates not found in element data') + try: + x = element.get('center_x') + y = element.get('center_y') + if x is not None and y is not None: + # Convert document coordinates to viewport coordinates + viewport_x, viewport_y = await self._convert_document_to_viewport_coords(x, y) + logging.debug(f'Mouse hover at element {id}, document coordinates=({x}, {y}), viewport coordinates=({viewport_x}, {viewport_y})') + await self.page.mouse.move(viewport_x, viewport_y) + await asyncio.sleep(0.5) + return True + else: + logging.error('Coordinates not found in element data') + ctx.set_error( + ERROR_ELEMENT_NOT_FOUND, + f"Element {id} missing coordinate information (center_x or center_y)", + element_id=id, + has_center_x=x is not None, + has_center_y=element.get('center_y') is not None + ) + return False + except Exception as e: + logging.error(f'Hover action failed for element {id}: {e}') + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Hover action failed with exception: {str(e)}", + element_id=id, + playwright_error=str(e) + ) return False async def wait(self, timeMs) -> bool: @@ -345,16 +697,32 @@ async def wait(self, timeMs) -> bool: async def type(self, id, text, clear_before_type: bool = False) -> bool: """Types text into the specified element, optionally clearing it first.""" + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"element_id": str(id), "action": "type", "text_length": len(text), "clear_before_type": clear_before_type} + try: element = self.page_element_buffer.get(str(id)) if not element: logging.error(f'Element with id {id} not found in buffer for type action.') + ctx.set_error( + ERROR_ELEMENT_NOT_FOUND, + f"Element {id} not found in page element buffer for type action", + element_id=id + ) return False logging.debug( f"Attempting to type into element: id={id}, tagName='{element.get('tagName')}', innerText='{element.get('innerText', '').strip()[:50]}', selector='{element.get('selector')}', clear_before_type={clear_before_type}" ) + # Ensure element is in viewport before typing (for full-page planning mode) + if not await self.ensure_element_in_viewport(str(id)): + logging.error(f'Cannot type into element {id}: failed to scroll element into viewport after multiple attempts') + # Context already populated by ensure_element_in_viewport, preserve it + return False + if clear_before_type: if not await self.clear(id): logging.warning(f'Failed to clear element {id} before typing, but will attempt to type anyway.') @@ -362,10 +730,24 @@ async def type(self, id, text, clear_before_type: bool = False) -> bool: # click element to get focus try: if not await self.click(str(id)): + # Context already populated by click(), check and enhance if needed + current_ctx = action_context_var.get() + if current_ctx and not current_ctx.error_type: + current_ctx.set_error( + ERROR_NOT_CLICKABLE, + f"Cannot type into element {id}: failed to click element for focus", + element_id=id + ) return False except Exception as e: logging.error(f"Error 'type' clicking using coordinates: {e}") logging.error(f'id type {type(id)}, id: {id}') + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Exception while clicking element {id} to focus for typing: {str(e)}", + element_id=id, + playwright_error=str(e) + ) return False await asyncio.sleep(1) @@ -380,6 +762,7 @@ async def type(self, id, text, clear_before_type: bool = False) -> bool: logging.debug(f"Typed '{text}' into element {id} using CSS selector: {selector}") except Exception as css_error: logging.warning(f'CSS selector type failed for element {id}: {css_error}') + ctx.playwright_error = str(css_error) # CSS selector failed, try XPath xpath = element.get('xpath') if xpath: @@ -390,9 +773,26 @@ async def type(self, id, text, clear_before_type: bool = False) -> bool: logging.error( f'Both CSS and XPath type failed for element {id}. CSS error: {css_error}, XPath error: {xpath_error}' ) + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"Both CSS selector and XPath strategies failed to type into element {id}", + element_id=id, + selector=selector, + xpath=xpath, + css_error=str(css_error), + xpath_error=str(xpath_error) + ) return False else: logging.error(f'CSS selector type failed and no XPath available for element {id}') + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"CSS selector failed to type into element {id} and no XPath fallback available", + element_id=id, + selector=selector, + has_xpath=False, + playwright_error=str(css_error) + ) return False else: logging.warning(f'Invalid CSS selector format for element {id}: {selector}') @@ -404,15 +804,36 @@ async def type(self, id, text, clear_before_type: bool = False) -> bool: logging.debug(f"Typed '{text}' into element {id} using XPath: {xpath}") except Exception as xpath_error: logging.error(f'XPath type failed for element {id}: {xpath_error}') + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"XPath strategy failed to type into element {id} (invalid CSS selector)", + element_id=id, + selector=selector, + xpath=xpath, + playwright_error=str(xpath_error) + ) return False else: logging.error(f'Invalid CSS selector and no XPath available for element {id}') + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"Invalid CSS selector format and no XPath available for element {id}", + element_id=id, + selector=selector, + has_xpath=False + ) return False await asyncio.sleep(1) return True except Exception as e: logging.error(f'Failed to type into element {id}: {e}') + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Unexpected exception during type action: {str(e)}", + element_id=id, + playwright_error=str(e) + ) return False @staticmethod @@ -460,10 +881,20 @@ def _is_valid_css_selector(selector: str) -> bool: async def clear(self, id) -> bool: """Clears the text in the specified input element.""" + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"element_id": str(id), "action": "clear"} + try: element_to_clear = self.page_element_buffer.get(str(id)) if not element_to_clear: logging.error(f'Element with id {id} not found in buffer for clear action.') + ctx.set_error( + ERROR_ELEMENT_NOT_FOUND, + f"Element {id} not found in page element buffer for clear action", + element_id=id + ) return False logging.debug( @@ -490,6 +921,7 @@ async def clear(self, id) -> bool: logging.debug(f'Cleared input for element {id} using CSS selector: {selector}') except Exception as css_error: logging.warning(f'CSS selector clear failed for element {id}: {css_error}') + ctx.playwright_error = str(css_error) # CSS selector failed, try XPath xpath = element_to_clear.get('xpath') if xpath: @@ -500,9 +932,26 @@ async def clear(self, id) -> bool: logging.error( f'Both CSS and XPath clear failed for element {id}. CSS error: {css_error}, XPath error: {xpath_error}' ) + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"Both CSS selector and XPath strategies failed to clear element {id}", + element_id=id, + selector=selector, + xpath=xpath, + css_error=str(css_error), + xpath_error=str(xpath_error) + ) return False else: logging.error(f'CSS selector clear failed and no XPath available for element {id}') + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"CSS selector failed to clear element {id} and no XPath fallback available", + element_id=id, + selector=selector, + has_xpath=False, + playwright_error=str(css_error) + ) return False else: logging.warning(f'Invalid CSS selector format for element {id}: {selector}') @@ -514,15 +963,36 @@ async def clear(self, id) -> bool: logging.debug(f'Cleared input for element {id} using XPath: {xpath}') except Exception as xpath_error: logging.error(f'XPath clear failed for element {id}: {xpath_error}') + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"XPath strategy failed to clear element {id} (invalid CSS selector)", + element_id=id, + selector=selector, + xpath=xpath, + playwright_error=str(xpath_error) + ) return False else: logging.error(f'Invalid CSS selector and no XPath available for element {id}') + ctx.set_error( + ERROR_NOT_TYPEABLE, + f"Invalid CSS selector format and no XPath available for element {id}", + element_id=id, + selector=selector, + has_xpath=False + ) return False await asyncio.sleep(0.5) return True except Exception as e: logging.error(f'Failed to clear element {id}: {e}') + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Unexpected exception during clear action: {str(e)}", + element_id=id, + playwright_error=str(e) + ) return False async def keyboard_press(self, key) -> bool: @@ -534,9 +1004,24 @@ async def keyboard_press(self, key) -> bool: Returns: bool: True if success, False if failed """ - await self.page.keyboard.press(key) - await asyncio.sleep(1) - return True + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"action": "keyboard_press", "key": key} + + try: + await self.page.keyboard.press(key) + await asyncio.sleep(1) + return True + except Exception as e: + logging.error(f"Keyboard press failed for key '{key}': {e}") + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Keyboard press action failed for key '{key}'", + key=key, + playwright_error=str(e) + ) + return False async def b64_page_screenshot(self, full_page=False, file_path=None, file_name=None, save_to_log=True): """Get page screenshot (Base64 encoded) @@ -636,6 +1121,11 @@ async def upload_file(self, id, file_path: Union[str, List[str]]) -> bool: Returns: bool: True if success, False if failed """ + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"element_id": str(id), "action": "upload", "file_path": str(file_path)} + try: # Support single file and multiple files if isinstance(file_path, str): @@ -644,19 +1134,32 @@ async def upload_file(self, id, file_path: Union[str, List[str]]) -> bool: file_paths = file_path else: logging.error(f'file_path must be str or list, got {type(file_path)}') + ctx.set_error( + ERROR_FILE_UPLOAD_FAILED, + f"Invalid file_path type: expected str or list, got {type(file_path)}", + file_path_type=str(type(file_path)) + ) return False valid_file_paths = [] + missing_files = [] for fp in file_paths: if not fp or not isinstance(fp, str): continue if not os.path.exists(fp): logging.error(f'File not found: {fp}') + missing_files.append(fp) continue valid_file_paths.append(fp) if not valid_file_paths: logging.error('No valid files to upload.') + ctx.set_error( + ERROR_FILE_UPLOAD_FAILED, + f"No valid files to upload. Missing files: {', '.join(missing_files) if missing_files else 'None'}", + missing_files=missing_files, + provided_paths=file_paths + ) return False # Get file extension for accept check @@ -690,6 +1193,11 @@ async def upload_file(self, id, file_path: Union[str, List[str]]) -> bool: if not file_inputs: logging.error('No file input elements found') + ctx.set_error( + ERROR_ELEMENT_NOT_FOUND, + "No file input elements found on page for upload action", + element_id=id + ) return False # Find compatible input elements @@ -711,6 +1219,11 @@ async def upload_file(self, id, file_path: Union[str, List[str]]) -> bool: except Exception as e: logging.error(f'Upload failed: {str(e)}') + ctx.set_error( + ERROR_FILE_UPLOAD_FAILED, + f"File upload failed with exception: {str(e)}", + playwright_error=str(e) + ) return False async def get_dropdown_options(self, id) -> Dict[str, Any]: @@ -1406,9 +1919,14 @@ async def drag(self, source_coords, target_coords): target_y = target_coords.get('y') try: + # Convert document coordinates to viewport coordinates + viewport_source_x, viewport_source_y = await self._convert_document_to_viewport_coords(source_x, source_y) + viewport_target_x, viewport_target_y = await self._convert_document_to_viewport_coords(target_x, target_y) + + logging.debug(f'Drag action: source document=({source_x}, {source_y}) -> viewport=({viewport_source_x}, {viewport_source_y}), target document=({target_x}, {target_y}) -> viewport=({viewport_target_x}, {viewport_target_y})') # move to start position - await self.page.mouse.move(source_x, source_y) + await self.page.mouse.move(viewport_source_x, viewport_source_y) await asyncio.sleep(0.1) # press mouse @@ -1416,14 +1934,14 @@ async def drag(self, source_coords, target_coords): await asyncio.sleep(0.1) # drag to target position - await self.page.mouse.move(target_x, target_y) + await self.page.mouse.move(viewport_target_x, viewport_target_y) await asyncio.sleep(0.1) # release mouse await self.page.mouse.up() await asyncio.sleep(0.2) - logging.debug(f'Drag completed from ({source_x}, {source_y}) to ({target_x}, {target_y})') + logging.debug(f'Drag completed from viewport ({viewport_source_x}, {viewport_source_y}) to ({viewport_target_x}, {viewport_target_y})') return True except Exception as e: @@ -1432,20 +1950,41 @@ async def drag(self, source_coords, target_coords): async def mouse_move(self, x: int | float, y: int | float) -> bool: """Move mouse to absolute coordinates (x, y).""" + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"action": "mouse_move", "x": x, "y": y} + try: # Coerce to numbers in case strings are provided target_x = float(x) target_y = float(y) - await self.page.mouse.move(target_x, target_y) - logging.info(f"mouse move to ({target_x}, {target_y})") + + # Convert document coordinates to viewport coordinates + viewport_x, viewport_y = await self._convert_document_to_viewport_coords(target_x, target_y) + + logging.info(f"Mouse move: document=({target_x}, {target_y}) -> viewport=({viewport_x}, {viewport_y})") + await self.page.mouse.move(viewport_x, viewport_y) await asyncio.sleep(0.1) return True except Exception as e: logging.error(f"Mouse move failed: {str(e)}") + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Mouse move action failed to position ({x}, {y})", + target_x=target_x if 'target_x' in locals() else x, + target_y=target_y if 'target_y' in locals() else y, + playwright_error=str(e) + ) return False async def mouse_wheel(self, delta_x: int | float = 0, delta_y: int | float = 0) -> bool: """Scroll the mouse wheel by delta values.""" + # Initialize action context for error propagation + ctx = ActionContext() + action_context_var.set(ctx) + ctx.element_info = {"action": "mouse_wheel", "deltaX": delta_x, "deltaY": delta_y} + try: dx = float(delta_x) if delta_x is not None else 0.0 dy = float(delta_y) if delta_y is not None else 0.0 @@ -1455,4 +1994,11 @@ async def mouse_wheel(self, delta_x: int | float = 0, delta_y: int | float = 0) return True except Exception as e: logging.error(f"Mouse wheel failed: {str(e)}") + ctx.set_error( + ERROR_PLAYWRIGHT, + f"Mouse wheel action failed with delta ({delta_x}, {delta_y})", + deltaX=dx if 'dx' in locals() else delta_x, + deltaY=dy if 'dy' in locals() else delta_y, + playwright_error=str(e) + ) return False \ No newline at end of file diff --git a/webqa_agent/llm/prompt.py b/webqa_agent/llm/prompt.py index 0048a6e..11b79f9 100644 --- a/webqa_agent/llm/prompt.py +++ b/webqa_agent/llm/prompt.py @@ -14,12 +14,11 @@ class LLMPrompt: ## Context Provided - **`pageDescription (interactive elements)`**: A map of all interactive elements on the page, each with a unique ID. Use these IDs for actions. - - **`page_structure (full text content)`**: The complete text content of the page, including non-interactive elements. - **`Screenshot`**: A visual capture of the current page state. ## Objective - Decompose the user's instruction into a **series of actionable steps**, each representing a single UI interaction. - - **Unified Context Analysis**: You MUST analyze BOTH `pageDescription` and `page_structure` together. Use `page_structure` to understand the meaning and context of the interactive elements in `pageDescription` (e.g., matching a label to a nearby input field). This unified view is critical for making correct decisions. + - **Unified Context Analysis**: Analyze the `pageDescription` together with the visual `Screenshot`. Use the screenshot to understand the spatial layout and context of the interactive elements (e.g., matching a label to a nearby input field based on their visual positions). This unified view is critical for making correct decisions. - Identify and locate the target element if applicable. - Validate if the planned target matches the user's intent, especially in cases of **duplicate or ambiguous elements**. - Avoid redundant operations such as repeated scrolling or re-executing completed steps. @@ -187,8 +186,8 @@ class LLMPrompt: - Example: if you see element '1' with internal id 917, use "id": "1" in your action ### Contextual Decision Making: - - **Crucially, use the `page_structure` (full text content) to understand the context of the interactive elements from `pageDescription`**. For example, if `page_structure` shows "Username:" next to an input field, you know that input field is for the username. - - If you see error text like "Invalid email format" in `page_structure`, use this information to correct your next action. + - **Crucially, use the `Screenshot` to understand the context of the interactive elements from `pageDescription`**. For example, if the screenshot shows "Username:" next to an input field, you know that input field is for the username. + - If you see error text like "Invalid email format" in the screenshot, use this information to correct your next action. ### Supported Actions: - Tap: Click on a specified page element (such as a button or link). Typically used to trigger a click event. diff --git a/webqa_agent/testers/case_gen/graph.py b/webqa_agent/testers/case_gen/graph.py index 7250628..7a3c99a 100644 --- a/webqa_agent/testers/case_gen/graph.py +++ b/webqa_agent/testers/case_gen/graph.py @@ -85,30 +85,25 @@ async def plan_test_cases(state: MainGraphState) -> Dict[str, List[Dict[str, Any logging.info(f"Deep crawling page structure and elements for initial test plan...") page = await ui_tester.get_current_page() dp = DeepCrawler(page) - await dp.crawl(highlight=True, viewport_only=True) + await dp.crawl(highlight=True, viewport_only=False) screenshot = await ui_tester._actions.b64_page_screenshot( - file_name="plan_or_replan", save_to_log=False, full_page=False + file_name="plan_or_replan", save_to_log=False, full_page=True ) await dp.remove_marker() - await dp.crawl(highlight=False, filter_text=True, viewport_only=True) + await dp.crawl(highlight=False, filter_text=True, viewport_only=False) page_structure = dp.get_text() logging.debug(f"----- plan cases ---- Page structure: {page_structure}") business_objectives = state.get("business_objectives", "No specific business objectives provided.") - completed_cases = state.get("completed_cases") language = state.get('language', 'zh-CN') system_prompt = get_test_case_planning_system_prompt( business_objectives=business_objectives, - completed_cases=completed_cases, language=language, ) user_prompt = get_test_case_planning_user_prompt( state_url=state["url"], - completed_cases=completed_cases, - reflection_history=state.get("reflection_history"), - remaining_objectives=state.get("remaining_objectives"), ) logging.info("Generating initial test plan - Sending request to LLM...") @@ -283,7 +278,7 @@ async def reflect_and_replan(state: MainGraphState) -> dict: # Use DeepCrawler to get interactive elements mapping and highlighted screenshot logging.info(f"Deep crawling page structure and elements for reflection and replanning analysis...") dp = DeepCrawler(page) - curr = await dp.crawl(highlight=True, viewport_only=True) + curr = await dp.crawl(highlight=True, viewport_only=False) # Include position information for better replanning decisions reflect_template = [ str(ElementKey.TAG_NAME), @@ -294,9 +289,9 @@ async def reflect_and_replan(state: MainGraphState) -> dict: ] page_content_summary = curr.clean_dict(reflect_template) logging.debug(f"current page crawled result: {page_content_summary}") - screenshot = await ui_tester._actions.b64_page_screenshot(file_name="reflection", save_to_log=False, full_page=False) + screenshot = await ui_tester._actions.b64_page_screenshot(file_name="reflection", save_to_log=False, full_page=True) await dp.remove_marker() - await dp.crawl(highlight=False, filter_text=True, viewport_only=True) + await dp.crawl(highlight=False, filter_text=True, viewport_only=False) page_structure = dp.get_text() logging.debug(f"----- reflection ---- Page structure: {page_structure}") diff --git a/webqa_agent/testers/case_gen/prompts/agent_prompts.py b/webqa_agent/testers/case_gen/prompts/agent_prompts.py index 734f6b4..8a3015c 100644 --- a/webqa_agent/testers/case_gen/prompts/agent_prompts.py +++ b/webqa_agent/testers/case_gen/prompts/agent_prompts.py @@ -27,6 +27,12 @@ def get_execute_system_prompt(case: dict) -> str: - **Layout Comprehension**: Analyze the layout to understand the spatial relationship between elements, which is crucial for complex interactions. - **Anomaly Detection**: Identify unexpected visual states like error pop-ups, unloaded content, or graphical glitches that may not be present in the text structure. +**IMPORTANT - Automatic Viewport Management**: +The system automatically handles element visibility through intelligent scrolling. When you interact with elements (click, hover, type), the system will automatically scroll to ensure the element is in the viewport before performing the action. You do NOT need to manually scroll to elements or worry about elements being outside the visible area. Simply reference elements by their identifiers, and the system will handle viewport positioning automatically. + +**IMPORTANT - Screenshot Context**: +The screenshots you receive during test execution show ONLY the current viewport (visible portion of the page), not the entire webpage. While test planning may reference elements from full-page screenshots, your execution screenshots are viewport-limited. This is intentional - the automatic viewport management system ensures that any element you need to interact with will be scrolled into the viewport before your action executes. If you cannot see an element in the current screenshot but it was referenced in the test plan, trust that the system will handle the scrolling automatically. + ## Available Tools You have access to two specialized testing tools: @@ -281,6 +287,43 @@ def get_execute_system_prompt(case: dict) -> str: 2. Check for dynamic content appearance 3. Retry interaction after content stabilization +### Pattern 5: Automatic Scroll Management Failures +**Scenario**: Element interaction fails due to scroll or viewport positioning issues +**Recognition Signals**: +- Error messages containing "element not in viewport", "not visible", "not clickable", or "scroll failed" +- Element was referenced in test plan from full-page screenshot but not visible in current viewport +- Interaction timeout errors for elements that should exist + +**Understanding the Issue**: +The system uses automatic viewport management with intelligent scrolling. When you interact with elements (click, hover, type), the system automatically scrolls to ensure the element is in viewport BEFORE executing your action. This process: +1. Detects if the target element is outside viewport +2. Attempts scroll using CSS selector → XPath → coordinate-based fallback +3. Implements retry logic for lazy-loaded content (up to 3 attempts) +4. Waits for page stability after scroll (handles infinite scroll and dynamic loading) + +**Recovery Solution**: +If automatic scroll fails, the error will indicate the specific issue: +1. **Element Not Found**: Element may not exist yet due to lazy loading + - Use `execute_ui_action(action='Sleep', value='2000')` to wait for content to load + - Verify element identifier is correct by checking page structure + - Consider that element may appear conditionally based on previous actions + +2. **Scroll Timeout**: Page is loading slowly or has infinite scroll + - Increase wait time: `execute_ui_action(action='Sleep', value='3000')` + - Manually trigger scroll if needed: `execute_ui_action(action='Scroll', value='down')` + - Check for loading spinners or progress indicators + +3. **Element Obscured**: Element exists but is covered by another element (modal, overlay, popup) + - Close the obscuring element first (dismiss modal, close popup) + - Use `execute_ui_action(action='KeyboardPress', value='Escape')` to dismiss overlays + - Verify no sticky headers or floating elements are blocking the target + +**Important Notes**: +- You do NOT need to manually scroll in normal circumstances - the system handles this automatically +- Only use manual scroll actions when automatic scroll explicitly fails with error messages +- If you see an error about scroll failure, report it as-is - these are rare and indicate system issues +- Trust the automatic viewport management for elements referenced from full-page planning screenshots + ## Test Execution Examples ### Example 1: Form Field Validation Recovery @@ -330,6 +373,29 @@ def get_execute_system_prompt(case: dict) -> str: **Tool Response**: `[SUCCESS] Action 'Input' on 'username field' completed successfully` **Agent Reporting**: Report completion of the single action and allow framework to proceed to next step +### Example 8: Mouse Action - Cursor Positioning +**Context**: Drawing canvas requiring precise cursor positioning +**Action**: `execute_ui_action(action='Mouse', target='canvas drawing area', value='move:250,150', description='Position cursor at specific canvas coordinates for drawing')` +**Tool Response**: `[SUCCESS] Action 'Mouse' on 'canvas drawing area' completed successfully. Mouse moved to (250, 150)` +**Use Case**: When standard click/hover actions are insufficient and precise coordinate-based cursor control is needed (e.g., drawing tools, custom interactive visualizations, coordinate-based maps) + +### Example 9: Mouse Action - Wheel Scrolling +**Context**: Custom scrollable container with horizontal scroll +**Action**: `execute_ui_action(action='Mouse', target='horizontal gallery container', value='wheel:100,0', description='Scroll gallery horizontally to the right')` +**Tool Response**: `[SUCCESS] Action 'Mouse' on 'horizontal gallery container' completed successfully. Mouse wheel scrolled (deltaX: 100, deltaY: 0)` +**Use Case**: When standard Scroll action doesn't support custom scroll directions or precise delta control needed (e.g., horizontal scrolling, custom scroll containers) + +### Example 10: Page Navigation Actions +**Context 1 - Direct Navigation**: Navigate to specific URL for cross-site testing +**Action**: `execute_ui_action(action='GoToPage', target='https://example.com/test-page', description='Navigate to external test page for integration testing')` +**Tool Response**: `[SUCCESS] Action 'GoToPage' on 'https://example.com/test-page' completed successfully. Navigated to page` +**Use Case**: Direct URL navigation for multi-site workflows, external authentication redirects, or testing cross-domain functionality + +**Context 2 - Browser Back**: Return to previous page after completing action +**Action**: `execute_ui_action(action='GoBack', target='', description='Navigate back to main product listing page')` +**Tool Response**: `[SUCCESS] Action 'GoBack' completed successfully. Successfully navigated back to previous page` +**Use Case**: Test browser back button functionality, validate state preservation after navigation, or reset to previous page state + ## Test Completion Protocol When all test steps are completed or an unrecoverable error occurs: diff --git a/webqa_agent/testers/case_gen/prompts/planning_prompts.py b/webqa_agent/testers/case_gen/prompts/planning_prompts.py index 4502aac..38bc8f0 100644 --- a/webqa_agent/testers/case_gen/prompts/planning_prompts.py +++ b/webqa_agent/testers/case_gen/prompts/planning_prompts.py @@ -30,7 +30,7 @@ def get_shared_test_design_standards(language: str = 'zh-CN') -> str: - **`domain_specific_rules`**: Industry-specific validation requirements or compliance rules - **`test_data_requirements`**: Specification of domain-appropriate test data and setup conditions - **`steps`**: Detailed test execution steps with clear action/verification pairs that simulate real user behavior and scenarios - - `action`: User-scenario action instructions describing what a real user would do in natural language, DON'T IMAGE. **Only use these action types: "Tap", "Scroll", "Input", "Sleep", "KeyboardPress", "Drag", "SelectDropdown". Do NOT invent or output any other action types or non-existent data.** + - `action`: User-scenario action instructions describing what a real user would do in natural language, DON'T IMAGE. **Only use these action types: "Tap", "Input", "Scroll", "SelectDropdown", "Clear", "Hover", "KeyboardPress", "Upload", "Drag", "GoToPage", "GoBack", "Sleep", "GetNewPage", "Mouse". Do NOT invent or output any other action types or non-existent data.** - `verify`: User-expectation validation instructions describing what result a real user would expect to see - **`preamble_actions`**: Optional setup steps to establish required test preconditions - **`reset_session`**: Session management flag for test isolation strategy @@ -38,7 +38,7 @@ def get_shared_test_design_standards(language: str = 'zh-CN') -> str: - **`cleanup_requirements`**: Post-test cleanup actions if needed #### Step Decomposition Rules: -1. **One Action Per Step**: Each step in the `steps` array must contain ONLY ONE atomic action, and the action type must be one of: "Tap", "Scroll", "Input", "Sleep", "KeyboardPress", "Drag", "SelectDropdown". +1. **One Action Per Step**: Each step in the `steps` array must contain ONLY ONE atomic action, and the action type must be one of: "Tap", "Input", "Scroll", "SelectDropdown", "Clear", "Hover", "KeyboardPress", "Upload", "Drag", "GoToPage", "GoBack", "Sleep", "GetNewPage", "Mouse". 2. **Strict Element Correspondence**: Each action must strictly correspond to a real element or option on the page. 3. **No Compound Instructions**: Never combine multiple UI interactions in a single step 4. **Sequential Operations**: Multiple operations on the same or different elements must be separated into distinct steps @@ -86,6 +86,35 @@ def get_shared_test_design_standards(language: str = 'zh-CN') -> str: - **Healthcare**: Use realistic patient data, medical codes, and HIPAA-compliant test scenarios - **Social Media**: Use realistic user profiles, content types, and interaction patterns +### Mouse Action Usage Guidelines +**IMPORTANT**: The Mouse action allows precise cursor positioning and mouse wheel scrolling. + +#### Mouse Action Format +- **Mouse Move**: Use format `"Mouse"` action with value `"move:x,y"` where x,y are pixel coordinates + - Example: `{{"action": "Move mouse cursor to position (100, 200)"}}` with value `"move:100,200"` + - Use for: Precise cursor positioning, custom drawing areas, coordinate-based interactions + +- **Mouse Wheel**: Use format `"Mouse"` action with value `"wheel:deltaX,deltaY"` + - Example: `{{"action": "Scroll mouse wheel down"}}` with value `"wheel:0,100"` + - Use for: Custom scroll behavior, horizontal scrolling, precise scroll control + +#### When to Use Mouse Action +- **Coordinate-based interactions**: Canvas drawing, image mapping, coordinate systems +- **Custom scroll needs**: Horizontal scrolling, specific scroll distances +- **Specialized UIs**: Games, design tools, interactive visualizations + +#### Mouse Action Examples +```json +[ + {{"action": "Move mouse to drawing area coordinates (150, 300)"}}, + {{"verify": "Verify cursor position indicator updates"}}, + {{"action": "Scroll horizontally in the canvas"}}, + {{"verify": "Verify canvas content shifts horizontally"}} +] +``` + +**Note**: For standard element interactions (clicking buttons, hovering over links), prefer using `Tap` and `Hover` actions which automatically locate elements. + ### User-Scenario Step Design Standards **CRITICAL**: All test steps must be designed from the user's perspective to ensure realistic and actionable test scenarios: @@ -175,34 +204,30 @@ def get_shared_test_design_standards(language: str = 'zh-CN') -> str: def get_test_case_planning_system_prompt( business_objectives: str, - completed_cases: list = None, language: str = 'zh-CN', ) -> str: """Generate system prompt for test case planning. Args: business_objectives: Business objectives - completed_cases: Completed test cases (for replanning) language: Language for test case naming (zh-CN or en-US) Returns: Formatted system prompt string """ - # Determine if initial planning or replanning - if not completed_cases: - # Decide mode based on whether business_objectives is empty - # Handle case where business_objectives might be a list - business_objectives_str = business_objectives if isinstance(business_objectives, str) else str(business_objectives) if business_objectives else "" - if business_objectives_str and business_objectives_str.strip(): - role_and_objective = """ + # Decide mode based on whether business_objectives is empty + # Handle case where business_objectives might be a list + business_objectives_str = business_objectives if isinstance(business_objectives, str) else str(business_objectives) if business_objectives else "" + if business_objectives_str and business_objectives_str.strip(): + role_and_objective = """ ## Role You are a Senior QA Testing Professional with expertise in business domain analysis, requirement engineering, and context-aware test design. Your responsibility is to deeply understand the application's business context, domain-specific patterns, and user needs to generate highly relevant and effective test cases. ## Primary Objective Conduct comprehensive business domain analysis and contextual understanding before generating test cases. Analyze the application's purpose, industry patterns, user workflows, and business logic to create test cases that are not only technically sound but also business-relevant and domain-appropriate. """ - mode_section = f""" + mode_section = f""" ## Test Planning Mode: Context-Aware Intent-Driven Testing **Business Objectives Provided**: {business_objectives_str} @@ -259,15 +284,15 @@ def get_test_case_planning_system_prompt( - **Success criteria**: Clear verification conditions - **Test data**: If data input is required, provide specific test data """ - else: - role_and_objective = """ + else: + role_and_objective = """ ## Role You are a Senior QA Testing Professional with expertise in comprehensive web application analysis and domain-aware testing. Your responsibility is to conduct deep application analysis, understand business context, and design complete test suites that ensure software quality through systematic validation of all functional, business, and domain-specific requirements. ## Primary Objective Perform comprehensive application analysis including business domain understanding, user workflow identification, and contextual awareness before generating test cases. Apply established QA methodologies including domain-specific testing patterns, business process validation, and risk-based testing prioritization. """ - mode_section = """ + mode_section = """ ## Test Planning Mode: Comprehensive Context-Aware Testing **Business Objectives**: Not provided - Performing comprehensive testing with domain analysis @@ -323,63 +348,6 @@ def get_test_case_planning_system_prompt( * Verification points - **Success criteria**: Clear verification conditions - **Test data**: If data input is required, provide specific test data -""" - else: - # Replanning mode - role_and_objective = """ -## Role -You are a Senior QA Testing Professional performing adaptive test plan revision based on execution results, enhanced business understanding, and evolving domain context. - -## Primary Objective -Leverage deeper business domain insights and execution learnings to generate refined test plans that address remaining coverage gaps while building upon successful outcomes. Ensure enhanced business relevance and domain appropriateness in all test cases. -""" - # Also decide mode based on business_objectives during replanning - # Handle case where business_objectives might be a list - business_objectives_str = business_objectives if isinstance(business_objectives, str) else str(business_objectives) if business_objectives else "" - if business_objectives_str and business_objectives_str.strip(): - mode_section = f""" -## Replanning Mode: Enhanced Context-Aware Revision -**Original Business Objectives**: {business_objectives_str} - -### Enhanced Replanning Requirements -- Apply deeper domain understanding gained from execution results -- Generate additional test cases with enhanced business relevance -- Maintain focus on original business objectives while improving domain appropriateness -- Incorporate lessons learned from executed test cases -- Ensure new test cases complement completed ones with superior business alignment -""" - else: - mode_section = """ -## Replanning Mode: Enhanced Comprehensive Testing Revision -**Original Objectives**: Comprehensive testing with enhanced domain awareness - - CRITICAL ANALYSIS REQUIREMENTS - BEFORE making ANY decision, you MUST: - - 1. **CHECK REPETITION WARNINGS FIRST**: If there are ANY repetition warnings above, those warnings are MANDATORY and NON-NEGOTIABLE. You MUST NOT perform any action that is mentioned in the warnings. - - 2. **FORBIDDEN ACTIONS**: If any element or action is marked as FORBIDDEN, FAILED, or CRITICAL in the warnings above, you are ABSOLUTELY PROHIBITED from using that element or action again. - - 3. **ALTERNATIVE STRATEGY REQUIRED**: When repetition warnings exist, you MUST: - - Choose a completely different type of element (if button failed, try link or input) - - Navigate to different page areas (scroll, click navigation menu) - - Try completely different approaches to achieve the objective - - Consider marking the test as completed if the objective might already be achieved - - 4. **ERROR HANDLING PRIORITY**: Check page content and screenshots for errors, warnings, login requirements, etc. Handle these BEFORE continuing the original process. - - 5. **NO EXCUSES**: There are NO exceptions to repetition warnings. Even if the element seems important for the objective, if it's marked as forbidden, you MUST find an alternative approach. - - Analysis Priority Order: - 1. Compliance with repetition warnings (HIGHEST PRIORITY) - 2. Error/exception handling in page content - 3. Progress toward test objective - 4. Coverage of untested functionalities - - Please analyze the current state and decide: - 1. Whether the current test case is completed - 2. Whether to shift the test focus - 3. The most valuable next action """ shared_standards = get_shared_test_design_standards(language) @@ -425,40 +393,23 @@ def get_test_case_planning_system_prompt( def get_test_case_planning_user_prompt( state_url: str, - completed_cases: list = None, - reflection_history: list = None, - remaining_objectives: str = None, ) -> str: """Generate user prompt for test case planning. Args: state_url: Target URL - completed_cases: Completed test cases (for replanning) - reflection_history: Reflection history (for replanning) - remaining_objectives: Remaining objectives (for replanning) Returns: Formatted user prompt string """ - context_section = "" - if completed_cases: - # Replanning mode - last_reflection = reflection_history[-1] if reflection_history else {} - context_section = f""" -## Revision Context with Enhanced Business Understanding -- **Completed Test Execution Summary**: {json.dumps(completed_cases, indent=2)} -- **Previous Reflection Analysis**: {json.dumps(last_reflection, indent=2)} -- **Remaining Coverage Objectives**: {remaining_objectives} -- **Enhanced Domain Insights**: Apply deeper business context learned from execution results -""" - user_prompt = f""" ## Application Under Test (AUT) - **Target URL**: {state_url} -- **Visual Element Reference (Referenced via attached screenshot) **: The attached screenshot contains numbered markers corresponding to interactive elements. +- **Visual Element Reference (Referenced via attached screenshot)**: The attached screenshot contains numbered markers corresponding to interactive elements. -{context_section} +**IMPORTANT - Full-Page Context**: +The screenshot shows the ENTIRE webpage from top to bottom, not just the visible viewport. All elements on the page are captured and numbered, including those that may be below the fold. When planning test cases, you can reference ANY element visible in this full-page screenshot. During execution, the system will automatically scroll to elements outside the viewport as needed. Please help me plan test cases based on the above information. Please conduct in-depth analysis according to the requirements in the system prompt and generate test cases that meet the specifications. Example 1: @@ -713,7 +664,10 @@ def get_reflection_user_prompt( interactive_elements_section = f""" - **Interactive Elements Map**: {interactive_elements_json} -- **Visual Element Reference**: The attached screenshot contains numbered markers corresponding to interactive elements. Each number in the image maps to an element ID in the Interactive Elements Map above, providing precise visual-textual correlation for comprehensive UI analysis.""" +- **Visual Element Reference**: The attached screenshot contains numbered markers corresponding to interactive elements. Each number in the image maps to an element ID in the Interactive Elements Map above, providing precise visual-textual correlation for comprehensive UI analysis. + +**IMPORTANT - Full-Page Context**: +The screenshot shows the ENTIRE webpage from top to bottom, not just the visible viewport. All elements on the page are captured and numbered, including those below the fold. When replanning test cases, you can reference ANY element visible in this full-page screenshot. The execution system automatically scrolls to elements outside the viewport as needed.""" # Determine test mode for reflection decision # Handle case where business_objectives might be a list diff --git a/webqa_agent/testers/case_gen/prompts/tool_prompts.py b/webqa_agent/testers/case_gen/prompts/tool_prompts.py deleted file mode 100644 index a87de12..0000000 --- a/webqa_agent/testers/case_gen/prompts/tool_prompts.py +++ /dev/null @@ -1,191 +0,0 @@ -"""工具相关的提示词模板.""" - - -def get_error_detection_prompt() -> str: - """返回UI错误检测LLM的系统提示词.""" - prompt = """ -You are a Senior QA Test Validation Specialist with expertise in automated UI testing and validation error detection. Your responsibility is to analyze post-action UI states and determine whether specific user actions have resulted in validation errors or system failures that require immediate remediation. - -## Core Mission -Provide precise, actionable validation analysis for UI test execution agents by detecting errors that directly prevent the intended user action from achieving its stated objective. Your analysis must distinguish between actionable errors requiring immediate correction and informational messages that do not block test progression. - -## Input Context Analysis -You will receive the following test execution context: - -1. **Action Intent**: The specific user goal or business objective the action was intended to achieve -2. **Executed Action Details**: - - `action`: The type of UI interaction performed - - `target`: The UI element that was targeted - - `value`: The data input provided (for text-based actions) -3. **Post-Action Screenshot**: Base64-encoded visual capture of the UI state after action execution -4. **Post-Action Page Structure**: Complete textual representation of the page content and elements - -## Error Classification Framework - -### Category 1: CRITICAL ERRORS (Require Immediate Action) -**Definition**: Errors that directly prevent the intended action objective from being achieved and require immediate remediation. - -**Error Types**: -- **Input Validation Failures**: Form field validation errors directly related to the submitted data -- **Authentication/Authorization Errors**: Access denied, session expired, insufficient permissions -- **System Errors**: Application crashes, server errors, network timeouts -- **Business Logic Violations**: Data conflicts, constraint violations, workflow rule violations -- **UI State Errors**: Unexpected modal dialogs, navigation failures, broken functionality - -**Detection Criteria**: -- Error message explicitly references the submitted data or performed action -- System prevents progression of the intended user workflow -- UI state has changed in a way that blocks the objective achievement - -### Category 2: NON-CRITICAL CONDITIONS (No Immediate Action Required) -**Definition**: UI states or messages that do not prevent the current action's objective from being achieved. - -**Condition Types**: -- **Stale Validation Messages**: Error messages from previous actions that don't apply to current input -- **Informational Messages**: Help text, tooltips, status updates that don't indicate failure -- **Progressive Disclosure**: New form fields or options appearing as part of normal workflow -- **Secondary Validation Warnings**: Non-blocking suggestions or recommendations -- **Future State Preparations**: Empty required fields that will be addressed in subsequent test steps - -## Advanced Error Detection Logic - -### Stale Error Recognition Protocol -**Scenario**: Previous validation errors may persist visually even after corrective action -**Analysis Method**: -1. **Data Correlation Check**: Does the visible error message specifically reference the current input value? -2. **Temporal Analysis**: Is the error message consistent with the action just performed? -3. **Context Relevance**: Does the error logically apply to the current UI interaction? -4. **Resolution Path**: Would the error be resolved by the action that was just taken? - -**Example Analysis**: -- Current Action: `type` with `value='john.doe@email.com'` -- Visible Error: "Email format is invalid" -- Analysis: The current value is properly formatted; error message is stale from previous attempt -- **Conclusion**: NO ERROR DETECTED (stale condition) - -### Intent-Based Validation Protocol -**Methodology**: Evaluate success not just by the absence of errors, but by positively confirming that the UI has transitioned to the expected state implied by the action's intent. This is the most critical part of your analysis. -**Process**: -1. **Deconstruct Intent**: What is the explicit goal of the action? (e.g., "Navigate to the login page," "Open the user profile dialog," "Apply a filter to the search results.") -2. **Identify Success Indicators**: Based on the intent, what specific UI elements or state changes MUST be present on the new page? (e.g., For a login page, success indicators are the presence of 'username'/'password' input fields and a 'submit' button. For a search filter, it's the updated results list.) -3. **Scan for Indicators**: Actively scan the provided `Page Structure` and `Screenshot` for these specific success indicators. -4. **Compare and Conclude**: - - If the key success indicators ARE PRESENT, the action was successful, even if other, unrelated warnings or elements are also on the page. Conclude **NO ERROR**. - - If the key success indicators ARE MISSING, the action has failed to achieve its intent, even if no explicit error message is visible. This is a critical failure. Conclude **ERROR DETECTED**. - -**Example Analysis**: -- Action Intent: "Navigate to the login page by clicking the '登录' button" -- Post-Action State: The page remains unchanged. The `Page Structure` does not contain any `` fields or a login form. -- Analysis: The primary success indicators for navigating to a login page (username/password fields) are absent. The UI has not transitioned to the expected state. -- **Conclusion**: ERROR DETECTED. The click action had no effect. -- **Remediation Suggestion**: "The '登录' button was clicked, but the login page/modal did not appear. The UI did not change as expected. Verify the button's functionality or if another action is required first." - -## Quality Assurance Testing Scenarios - -### Scenario A: Form Input Validation -**Context**: User submitting data to a web form with validation rules -**Critical Errors**: Field-specific validation messages related to the submitted data -**Non-Critical**: Generic form instructions, placeholder text, unrelated field warnings - -### Scenario B: Dropdown Selection -**Context**: User selecting an option from a dropdown menu -**Critical Errors**: "Option not found" errors, dropdown functionality failures -**Non-Critical**: Dropdown opening successfully but showing different options than expected - -### Scenario C: Navigation Actions -**Context**: User attempting to navigate to a different page or section -**Critical Errors**: Access denied messages, broken links, page load failures -**Non-Critical**: Page loading successfully but containing unrelated content warnings - -### Scenario D: Dynamic Content Loading -**Context**: User triggering content updates or asynchronous operations -**Critical Errors**: Load failures, timeout errors, data retrieval problems -**Non-Critical**: Loading states, progress indicators, partial content updates - -## Decision-Making Examples - -### Example 1: Input Validation Error Detection -**Input Analysis**: -- Action Intent: "Enter organization name for account setup" -- Action: `type` on `Organization Name field` with `value='Test@Org#123'` -- Page State: Error message "Organization name can only contain letters, numbers, spaces and symbols _-" -- Analysis: The submitted value contains '@' and '#' characters which violate the stated validation rule -**Decision**: ERROR DETECTED - Direct validation failure requiring data correction - -### Example 2: Stale Error Identification -**Input Analysis**: -- Action Intent: "Enter valid email address" -- Action: `type` on `Email field` with `value='user@company.com'` -- Page State: Error message "Please enter a valid email address" still visible -- Analysis: The current input follows proper email format; error message doesn't apply to this value -**Decision**: NO ERROR DETECTED - Stale validation message from previous attempt - -### Example 3: Intent-Based Success Recognition -**Input Analysis**: -- Action Intent: "Open registration form for new account creation" -- Action: `click` on `Sign Up button` -- Page State: Registration form displayed with "Required field" indicators on empty inputs -- Analysis: The form opening objective was achieved; empty field indicators are normal initial state -**Decision**: NO ERROR DETECTED - Intent successfully fulfilled - -### Example 4: System Error Detection -**Input Analysis**: -- Action Intent: "Submit completed application form" -- Action: `click` on `Submit button` -- Page State: "Server error: Unable to process request. Please try again later." -- Analysis: The submission failed due to system-level error preventing objective completion -**Decision**: ERROR DETECTED - System failure requiring retry or escalation - -## Output Format Specification - -You must return a strictly formatted JSON object with complete analysis: - -```json -{ - "error_detected": , - "error_message": "", - "reasoning": "", - "error_category": "", - "remediation_suggestion": "" -} -``` - -### Field Specifications: -- **error_detected**: `true` if a critical error requiring immediate action is identified, `false` otherwise -- **error_message**: Concise, actionable description of the detected error (null if no error) -- **reasoning**: Detailed analysis explaining the decision-making process and evidence considered -- **error_category**: Classification of error type (e.g., "Input_Validation", "System_Error", "Authentication") or null -- **remediation_suggestion**: Specific guidance for error resolution (null if no error detected) - -## Response Examples - -### Critical Error Response: -```json -{ - "error_detected": true, - "error_message": "Password must be at least 8 characters long with uppercase, lowercase, and numeric characters", - "reasoning": "The submitted password 'test123' does not meet the complexity requirements displayed in the validation message. The error directly corresponds to the current input value and prevents successful form submission.", - "error_category": "Input_Validation", - "remediation_suggestion": "Modify password to include uppercase letters and ensure minimum 8 character length" -} -``` - -### No Error Response: -```json -{ - "error_detected": false, - "error_message": null, - "reasoning": "The action successfully achieved its intended objective. The login form opened as expected and no validation errors were triggered by the current action. Visible placeholder text and field labels are standard UI elements, not error conditions.", - "error_category": null, - "remediation_suggestion": null -} -``` - -## Quality Standards -- **Precision**: Only identify errors that directly impact the current action's objective -- **Actionability**: All detected errors must provide clear remediation guidance -- **Context Awareness**: Consider the full testing context and user intent -- **Consistency**: Apply uniform analysis criteria across all evaluations -- **Completeness**: Provide thorough reasoning for all decisions made -""" - return prompt diff --git a/webqa_agent/testers/case_gen/tools/element_action_tool.py b/webqa_agent/testers/case_gen/tools/element_action_tool.py index 86853bf..4cf7428 100644 --- a/webqa_agent/testers/case_gen/tools/element_action_tool.py +++ b/webqa_agent/testers/case_gen/tools/element_action_tool.py @@ -58,7 +58,7 @@ class UIActionSchema(BaseModel): "'KeyboardPress' action (key name like 'Enter', 'Tab', 'Escape', etc.), " "'Upload' action (file path), " "'Sleep' action (duration in milliseconds), " - "'Mouse' action (operation type: 'move' for cursor positioning or 'wheel' for scrolling). " + "'Mouse' action (operation specification in format 'move:x,y' for cursor positioning to coordinates (x,y) or 'wheel:deltaX,deltaY' for scrolling by delta values. Examples: 'move:100,200' moves cursor to (100,200), 'wheel:0,100' scrolls down by 100 pixels). " "Optional for 'Drag' action (target position description), " "'GetNewPage' action (tab/window identifier). " "Optional for other actions." @@ -179,12 +179,14 @@ async def _arun( if value: action_phrase += f" {value}" elif action == "Mouse": - if value and 'move' in value.lower(): - action_phrase = f"Move mouse cursor to {target}" - elif value and 'wheel' in value.lower(): - action_phrase = f"Scroll mouse wheel on {target}" + if value and 'move:' in value.lower(): + # Extract coordinates from 'move:x,y' format + action_phrase = f"Move mouse cursor to coordinates {value.split(':', 1)[1]} (specified as {target})" + elif value and 'wheel:' in value.lower(): + # Extract delta values from 'wheel:deltaX,deltaY' format + action_phrase = f"Scroll mouse wheel by {value.split(':', 1)[1]} (on {target})" else: - action_phrase = f"Perform mouse action on {target}" + action_phrase = f"Perform mouse action on {target} with value '{value}'" else: action_phrase = f"{action} on {target}" if value: @@ -212,6 +214,117 @@ async def _arun( # First, check for a hard failure from the action executor if not result.get("success"): + # Check for enriched error details + error_details = result.get("error_details", {}) + + if error_details and error_details.get("error_type"): + # Format structured error message based on error type + error_type = error_details.get("error_type") + error_reason = error_details.get("error_reason", "Unknown reason") + + if error_type == "scroll_failed": + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Root Cause**: Element viewport positioning failed +**Details**: {error_reason} +**Strategies Attempted**: {', '.join(error_details.get('attempted_strategies', []))} + +**Recovery Actions**: +1. Use Sleep action (2-3 seconds) to allow lazy-loaded content to appear +2. Try manual Scroll action to navigate the page closer to the element +3. Verify element ID is correct from current page state +4. Check if element is in a collapsed section that needs to be opened first""" + + elif error_type == "scroll_timeout_lazy_loading": + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Root Cause**: Page content unstable after scrolling (likely lazy-loading or infinite scroll) +**Details**: {error_reason} + +**Recovery Actions**: +1. Use Sleep action with longer duration (3-5 seconds) to allow content to stabilize +2. Try the action again - content may have loaded by now +3. Use manual Scroll action to trigger additional content loading +4. Verify the element ID from the current page state in case it changed""" + + elif error_type == "element_not_found": + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Root Cause**: Element does not exist on current page +**Element ID**: {error_details.get('element_info', {}).get('element_id', target)} + +**Recovery Actions**: +1. Review current page structure - element may have a different ID now +2. Check if navigation to the correct page is needed +3. Verify element is not hidden behind authentication or modal dialog +4. Use Sleep action if element loads dynamically after page interaction""" + + elif error_type == "element_not_clickable": + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Root Cause**: Element exists but cannot be clicked +**Details**: {error_reason} + +**Recovery Actions**: +1. Check if element is obscured by modal/overlay - close it first using Tap action +2. Try Hover action over the element before clicking +3. Check if element is disabled - may need to enable it through other actions +4. Verify correct element ID - similar but different elements may exist""" + + elif error_type == "element_not_typeable": + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Root Cause**: Element cannot accept text input +**Details**: {error_reason} + +**Recovery Actions**: +1. Verify the element is actually an input field or contenteditable element +2. Try Clear action first, then Input action +3. Check if element is disabled or read-only +4. Use Tap action to focus the element before typing""" + + elif error_type == "file_upload_failed": + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Root Cause**: File upload operation failed +**Details**: {error_reason} + +**Recovery Actions**: +1. Verify the file path exists and is accessible +2. Check if the file format is accepted by the input element +3. Ensure the file size is within acceptable limits +4. Verify file input element is present and enabled on the page +5. Check file permissions and ensure the file is readable""" + + elif error_type == "playwright_error": + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Root Cause**: Browser interaction error +**Technical Details**: {error_details.get('playwright_error', 'Unknown error')} + +**Recovery Actions**: +1. Retry the action after a short Sleep (1-2 seconds) +2. Check if page has navigated unexpectedly +3. Verify element still exists on current page +4. Check browser console for JavaScript errors that might interfere""" + + else: + # Unknown error type, use generic format + error_message = f"""[FAILURE] Action '{action}' on '{target}' failed. + +**Error Type**: {error_type} +**Details**: {error_reason} + +**Recovery Actions**: +1. Review the error details carefully +2. Check current page state +3. Try alternative action strategies +4. Use Sleep action to allow page to stabilize""" + + logging.warning(f"Action failed with structured error: {error_type}") + return error_message + + # Fallback: Use existing error handling for errors without enriched details error_message = ( f"Action '{action}' on '{target}' failed. Reason: {result.get('message', 'No details provided.')}" ) diff --git a/webqa_agent/testers/case_gen/utils/message_converter.py b/webqa_agent/testers/case_gen/utils/message_converter.py index 247901d..3a873c2 100644 --- a/webqa_agent/testers/case_gen/utils/message_converter.py +++ b/webqa_agent/testers/case_gen/utils/message_converter.py @@ -91,36 +91,3 @@ def convert_intermediate_steps_to_messages( continue return messages - - -def merge_messages_with_intermediate_steps( - existing_messages: List[BaseMessage], - intermediate_steps: List[Tuple[Any, str]], - include_intermediate: bool = True -) -> List[BaseMessage]: - """Merge existing messages with converted intermediate steps. - - Args: - existing_messages: Current message history - intermediate_steps: New intermediate steps to add - include_intermediate: Whether to include intermediate steps in messages - - Returns: - Merged list of messages - """ - if not include_intermediate or not intermediate_steps: - return existing_messages - - # Convert intermediate steps to messages - intermediate_messages = convert_intermediate_steps_to_messages(intermediate_steps) - - # Merge with existing messages - # Note: We append intermediate messages to maintain chronological order - merged_messages = existing_messages + intermediate_messages - - logging.debug( - f"Merged {len(existing_messages)} existing messages with " - f"{len(intermediate_messages)} intermediate messages" - ) - - return merged_messages \ No newline at end of file diff --git a/webqa_agent/testers/case_gen/utils/prompt_utils.py b/webqa_agent/testers/case_gen/utils/prompt_utils.py deleted file mode 100644 index 5e09748..0000000 --- a/webqa_agent/testers/case_gen/utils/prompt_utils.py +++ /dev/null @@ -1,39 +0,0 @@ -"""This module provides utility functions for generating parts of agent -prompts.""" - - -def check_repetition(case: dict) -> str: - """Checks for repeated actions in the test case history and returns a - warning string.""" - if not case.get("test_context"): - return "" - - warnings = [] - test_context = case["test_context"] - - # Check for repeated element interactions - for element, data in test_context.get("tested_elements", {}).items(): - if data.get("test_count", 0) >= 2: - recent_failures = [r for r in data.get("results", [])[-2:] if not r.get("success")] - if len(recent_failures) >= 2: - warnings.append( - f"⚠️ REPETITION WARNING: Element '{element}' has failed multiple times recently. AVOID interacting with it again." - ) - elif data.get("test_count", 0) >= 3: - warnings.append( - f"⚠️ REPETITION WARNING: Element '{element}' has been tested multiple times. Consider a different element or action." - ) - - # Check for repeated action paths - test_path = test_context.get("test_path", []) - if len(test_path) >= 3: - recent_path = test_path[-3:] - if len(set(recent_path)) == 1: - warnings.append( - f"⚠️ REPETITION WARNING: You are repeating the exact same action '{recent_path[0]}' three times in a row. You MUST choose a different action." - ) - - if warnings: - return "=== REPETITION WARNINGS ===\n" + "\n".join(warnings) + "\n" - - return "No repetition detected. Proceed with the next logical step." diff --git a/webqa_agent/testers/function_tester.py b/webqa_agent/testers/function_tester.py index cb031fe..7f72ed1 100644 --- a/webqa_agent/testers/function_tester.py +++ b/webqa_agent/testers/function_tester.py @@ -92,12 +92,12 @@ async def action(self, test_step: str, file_path: str = None) -> Tuple[Dict[str, # Crawl current page state dp = DeepCrawler(self.page) - prev = await dp.crawl(highlight=True, viewport_only=True, cache_dom=True) + prev = await dp.crawl(highlight=True, viewport_only=False, cache_dom=True) await self._actions.update_element_buffer(prev.raw_dict()) logging.debug(f"previous dom before action : {prev.to_llm_json()}") # Take screenshot - marker_screenshot = await self._actions.b64_page_screenshot(file_name="marker") + marker_screenshot = await self._actions.b64_page_screenshot(file_name="marker", full_page=True) # Remove marker await dp.remove_marker() @@ -123,7 +123,7 @@ async def action(self, test_step: str, file_path: str = None) -> Tuple[Dict[str, end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - curr = await dp.crawl(highlight=True, viewport_only=True, cache_dom=True) + curr = await dp.crawl(highlight=True, viewport_only=False, cache_dom=True) diff_elems = curr.diff_dict([str(ElementKey.TAG_NAME), str(ElementKey.INNER_TEXT), str(ElementKey.ATTRIBUTES), str(ElementKey.CENTER_X), str(ElementKey.CENTER_Y)]) if diff_elems: logging.debug(f"Diff element map after action: {diff_elems}") @@ -203,15 +203,15 @@ async def verify(self, assertion: str) -> tuple[Dict[str, Any], Dict[str, Any]]: # Crawl current page dp = DeepCrawler(self.page) - await dp.crawl(highlight=True, filter_text=True, viewport_only=True) + await dp.crawl(highlight=True, filter_text=True, viewport_only=False) - marker_screenshot = await self._actions.b64_page_screenshot(file_name="marker") + marker_screenshot = await self._actions.b64_page_screenshot(file_name="marker", full_page=True) await dp.remove_marker() - screenshot = await self._actions.b64_page_screenshot(file_name="assert") + screenshot = await self._actions.b64_page_screenshot(file_name="assert", full_page=True) # Get page structure - await dp.crawl(highlight=False, filter_text=True, viewport_only=True) + await dp.crawl(highlight=False, filter_text=True, viewport_only=False) page_structure = dp.get_text() # Prepare LLM input