Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,12 @@ browser_config:
headless: False # Docker environment will automatically override to True
language: zh-CN
cookies: []
save_screenshots: False # Whether to save screenshots to local disk (default: False)

report:
language: en-US # zh-CN, en-US
save_screenshots: False # Whether to enable screenshots. If True, screenshots are saved as local files. If False, screenshots are stored as base64 in the test data.
report_dir: null # null=use default 'reports/{timestamp}/', or custom path like './my_reports/'


log:
level: info
2 changes: 2 additions & 0 deletions config/config_run.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@ log:

report:
language: en-US # zh-CN, en-US
save_screenshots: False # Whether to enable screenshots. If True, screenshots are saved as local files. If False, screenshots are stored as base64 in the test data.
report_dir: null # null=use default 'reports/{timestamp}/', or custom path like './my_reports/'
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "webqa-agent",
"version": "0.2.2.post1",
"version": "0.2.3",
"description": "WebQA Agent is an autonomous web browser agent that audits performance, functionality & UX for engineers and vibe-coding creators.",
"dependencies": {
"chrome-launcher": "^1.2.0",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "webqa-agent"
version = "0.2.2.post1"
version = "0.2.3"
description = "WebQA Agent is an autonomous web browser agent that audits performance, functionality & UX for engineers and vibe-coding creators."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion webqa_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__all__ = [
]

__version__ = '0.2.2.post1'
__version__ = '0.2.3'
137 changes: 91 additions & 46 deletions webqa_agent/actions/action_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import logging
import os
import random
import re
from contextvars import ContextVar
from dataclasses import dataclass, field
Expand All @@ -15,6 +16,7 @@
# ===== Action Context Infrastructure for Error Propagation =====

action_context_var: ContextVar[Optional['ActionContext']] = ContextVar('action_context', default=None)
screenshot_prefix_var: ContextVar[str] = ContextVar('screenshot_prefix', default='')


@dataclass
Expand Down Expand Up @@ -69,35 +71,68 @@ class ActionHandler:
# Session management for screenshot organization
_screenshot_session_dir: Optional[Path] = None
_screenshot_session_timestamp: Optional[str] = None
_save_screenshots: bool = False # Default: not save screenshots to disk
_save_screenshots_locally: bool = False # Whether to save screenshots as files

@classmethod
def set_screenshot_config(cls, save_screenshots: bool = False):
"""Set global screenshot saving behavior.
def clear_screenshot_session(cls):
"""Clear the current screenshot session state.

This should be called at the start of a new test session to ensure
isolation from previous runs in the same process.
"""
cls._screenshot_session_dir = None
cls._screenshot_session_timestamp = None
logging.debug('Screenshot session state cleared')

@classmethod
def set_screenshot_config(cls, save_screenshots: bool):
"""Set whether to save screenshots locally.

Args:
save_screenshots: Whether to save screenshots to local disk (default: False)
save_screenshots: If True, screenshots are saved as files.
If False, only base64 data is kept.
"""
cls._save_screenshots = save_screenshots
logging.debug(f'Screenshot saving config set to: {save_screenshots}')
cls._save_screenshots_locally = save_screenshots
logging.info(f'Screenshot configuration updated: save_locally={save_screenshots}')

@classmethod
def init_screenshot_session(cls) -> Path:
def init_screenshot_session(cls, custom_report_dir: Optional[str] = None) -> Path:
"""Initialize screenshot session directory for this test run.

Creates a timestamped directory under webqa_agent/crawler/screenshots/
for organizing all screenshots from a single test session.
Creates a screenshot directory under the report directory. All screenshots
from the test session will be organized in this directory.

Args:
custom_report_dir: Custom report directory from config.
If None, uses default 'reports/{timestamp}/'

Returns:
Path: The session directory path
Path: The screenshot directory path

Examples:
>>> # Default directory
>>> ActionHandler.init_screenshot_session()
PosixPath('reports/20260105_155547/screenshots')

>>> # Custom directory
>>> ActionHandler.init_screenshot_session('./my_reports')
PosixPath('my_reports/screenshots')
"""
if cls._screenshot_session_dir is None:
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
base_dir = Path(__file__).parent.parent / 'crawler' / 'screenshots'
cls._screenshot_session_dir = base_dir / timestamp
if cls._save_screenshots_locally and cls._screenshot_session_dir is None:
timestamp = os.getenv('WEBQA_REPORT_TIMESTAMP') or datetime.now().strftime('%Y-%m-%d_%H-%M-%S_%f')
cls._screenshot_session_timestamp = timestamp

if custom_report_dir:
# User-defined directory: {custom_report_dir}/screenshots/
report_base = Path(custom_report_dir)
else:
# Default directory: reports/test_{timestamp}/
report_base = Path('reports') / f'test_{timestamp}'

cls._screenshot_session_dir = report_base / 'screenshots'
cls._screenshot_session_dir.mkdir(parents=True, exist_ok=True)
logging.info(f'Initialized screenshot session directory: {cls._screenshot_session_dir}')
logging.info(f'Initialized screenshot directory: {cls._screenshot_session_dir}')

return cls._screenshot_session_dir

def __init__(self):
Expand Down Expand Up @@ -1651,66 +1686,79 @@ async def b64_page_screenshot(
full_page: bool = False,
file_name: Optional[str] = None,
context: str = 'default'
) -> Optional[str]:
"""Get page screenshot (Base64 encoded) and optionally save to local
file.
) -> tuple[Optional[str], Optional[str]]:
"""Get page screenshot as base64 and save to local file.

Args:
full_page: whether to capture the whole page
file_name: descriptive screenshot name (e.g., "marker", "action_click_button")
context: test context category (e.g., 'test', 'agent', 'scroll', 'error')

Returns:
str: screenshot base64 encoded, or None if screenshot fails
tuple[Optional[str], Optional[str]]: (base64_data, file_path)
- base64_data: For LLM requests (data:image/png;base64,...)
- file_path: Relative path to saved screenshot file, or None if not saved

Note:
The screenshot is always returned as base64 for HTML reports and LLM analysis.
Local file saving is controlled by the _save_screenshots class variable.
Screenshots are saved to local disk if _save_screenshots_locally is True.
The base64 data is always returned for LLM analysis and fallback rendering.
"""
try:
# Get current active page (dynamically resolves to latest page)
current_page = self._get_current_page()
timeout = 90000 if full_page else 60000 # 90s for full page, 60s for viewport

# Prepare file path only if saving is enabled
file_path_str = None
if self._save_screenshots:
# Initialize session directory if needed
relative_path = None

# Only prepare file path and session if local saving is enabled
if self._save_screenshots_locally:
session_dir = self.init_screenshot_session()

# Generate timestamp and filename
timestamp = datetime.datetime.now().strftime('%H%M%S')
# Use high-precision timestamp and random suffix to avoid collisions in parallel execution
now = datetime.datetime.now()
timestamp = now.strftime('%H%M%S')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current timestamp format for screenshot filenames has only second-level precision. In scenarios with high-frequency screenshot captures, especially during parallel test execution, this could lead to filenames that don't reflect the true chronological order if multiple screenshots are taken within the same second. Using a more precise timestamp would improve debugging and log analysis.

I suggest including microseconds in the timestamp to ensure unique and correctly ordered filenames.

Suggested change
timestamp = now.strftime('%H%M%S')
timestamp = now.strftime('%H%M%S_%f')

random_suffix = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=4))

# Get prefix from context variable (set by workers to distinguish cases/tests)
prefix = screenshot_prefix_var.get()
prefix_part = f'{prefix}_' if prefix else ''

# Build filename: {timestamp}_{context}_{file_name}.png
if file_name:
filename = f'{timestamp}_{context}_{file_name}.png'
filename = f'{prefix_part}{timestamp}_{random_suffix}_{context}_{file_name}.png'
else:
filename = f'{timestamp}_{context}_screenshot.png'
filename = f'{prefix_part}{timestamp}_{random_suffix}_{context}_screenshot.png'

file_path_str = str(session_dir / filename)
file_path = session_dir / filename
file_path_str = str(file_path)

# Capture screenshot (with or without file saving based on config)
# Return path relative to the report root for HTML rendering
# Screenshots are stored in report_dir/screenshots/ and report is in report_dir/run_report.html
relative_path = os.path.join(session_dir.name, filename)

# Capture screenshot (always returns bytes)
screenshot_bytes = await self.take_screenshot(
current_page,
full_page=full_page,
file_path=file_path_str,
timeout=timeout
)

# Convert to Base64 for HTML reports
# Convert to Base64 for LLM requests
screenshot_base64 = base64.b64encode(screenshot_bytes).decode('utf-8')
base64_data = f'data:image/png;base64,{screenshot_base64}'

if self._save_screenshots and file_path_str:
logging.debug(f'Screenshot saved to {file_path_str}')
if self._save_screenshots_locally:
logging.debug(f'Screenshot saved: {file_path_str}, relative_path: {relative_path}')
else:
logging.debug('Screenshot captured (not saved to disk)')
logging.debug('Screenshot captured as base64 only (local saving disabled)')

return base64_data
return base64_data, relative_path

except Exception as e:
logging.warning(f'Failed to capture screenshot: {e}')
return None
return None, None

async def take_screenshot(
self,
Expand All @@ -1719,21 +1767,20 @@ async def take_screenshot(
file_path: str | None = None,
timeout: float = 120000,
) -> bytes:
"""Get page screenshot (binary)
"""Get page screenshot (binary) and save to disk.

Args:
page: page object
full_page: whether to capture the whole page
file_path: screenshot save path (only used when save_screenshots=True)
file_path: screenshot save path (required)
timeout: timeout (milliseconds)

Returns:
bytes: screenshot binary data

Note:
If save_screenshots is False, the screenshot will not be saved to disk
regardless of the file_path parameter. The method always returns the
screenshot bytes for in-memory use (e.g., Base64 encoding).
Screenshots are always saved to the file_path if provided.
The method always returns the screenshot bytes for base64 encoding.
"""
try:
# Shortened and more lenient load state check
Expand All @@ -1743,7 +1790,7 @@ async def take_screenshot(
except Exception as e:
logging.debug(f'Load state check: {e}; proceeding with screenshot')

logging.debug(f'Taking screenshot (full_page={full_page}, save={self._save_screenshots}, timeout={timeout}ms)')
logging.debug(f'Taking screenshot (full_page={full_page}, timeout={timeout}ms)')

# Prepare screenshot options with Playwright best practices
screenshot_options = {
Expand All @@ -1753,12 +1800,10 @@ async def take_screenshot(
'caret': 'hide', # Hide text input cursor for cleaner screenshots
}

# Only save to disk if _save_screenshots is True and file_path is provided
if self._save_screenshots and file_path:
# Save to disk if file_path is provided
if file_path:
screenshot_options['path'] = file_path
logging.debug(f'Screenshot will be saved to: {file_path}')
elif not self._save_screenshots:
logging.debug('Screenshot saving disabled, returning bytes only')

# Capture screenshot with optimized options
screenshot: bytes = await page.screenshot(**screenshot_options)
Expand Down
8 changes: 6 additions & 2 deletions webqa_agent/actions/click_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ async def click_and_screenshot(
'response_errors': [],
'screenshot_before': None,
'screenshot_after': None,
'screenshot_after_path': None,
'new_page_screenshot': None,
'new_page_screenshot_path': None,
'click_method': None,
'click_coordinates': None,
'has_new_page': False,
Expand Down Expand Up @@ -122,11 +124,12 @@ def handle_new_page(page_obj):

new_page_action_handler = ActionHandler()
new_page_action_handler.page = new_page
screenshot_b64 = await new_page_action_handler.b64_page_screenshot(
screenshot_b64, screenshot_path = await new_page_action_handler.b64_page_screenshot(
file_name=f'element_{element_index}_new_page',
context='test'
)
click_result['new_page_screenshot'] = screenshot_b64
click_result['new_page_screenshot_path'] = screenshot_path
logging.debug('New page screenshot saved')

except Exception as e:
Expand All @@ -135,11 +138,12 @@ def handle_new_page(page_obj):

await page.wait_for_load_state('networkidle', timeout=30000)
else:
screenshot_b64 = await action_handler.b64_page_screenshot(
screenshot_b64, screenshot_path = await action_handler.b64_page_screenshot(
file_name=f'element_{element_index}_after_click',
context='test'
)
click_result['screenshot_after'] = screenshot_b64
click_result['screenshot_after_path'] = screenshot_path
logging.debug('After click screenshot saved')

else:
Expand Down
Loading