Files
agent-desktop/e2e/fixtures/__init__.py
T

416 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""
QwenPaw E2E Test Framework - Pytest Fixtures
Provides browser, page, API client and other fixtures required by tests.
"""
from __future__ import annotations
import os
import logging
import pytest
from pathlib import Path
from typing import Generator, Optional
from playwright.sync_api import sync_playwright, Page, Browser, BrowserContext, APIRequestContext
from datetime import datetime
from config.settings import config, get_config
from pages.chat_page import ChatPage
# Configure logging
logging.basicConfig(
level=getattr(logging, config.test.log_level),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler(config.paths.logs_dir / f"test_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
# ============================================================================
# Session-scoped Fixtures
# ============================================================================
@pytest.fixture(scope="session")
def playwright_context():
"""
Create a Playwright session (session scope)
Yields:
Playwright instance
"""
logger.info("Starting Playwright session")
with sync_playwright() as p:
yield p
logger.info("Playwright session ended")
@pytest.fixture(scope="session")
def browser(playwright_context):
"""
Create a browser instance (reused across the session)
Yields:
Browser instance
"""
cfg = config.browser
logger.info(f"Launching browser: {cfg.browser_type}, headless={cfg.headless}")
browser_kwargs = {
"headless": cfg.headless,
"slow_mo": cfg.slow_mo,
"args": cfg.args,
}
# Launch based on browser type
if cfg.browser_type == "chromium":
browser = playwright_context.chromium.launch(**browser_kwargs)
elif cfg.browser_type == "firefox":
browser = playwright_context.firefox.launch(**browser_kwargs)
elif cfg.browser_type == "webkit":
browser = playwright_context.webkit.launch(**browser_kwargs)
else:
raise ValueError(f"Unsupported browser type: {cfg.browser_type}")
logger.info("Browser launched successfully")
yield browser
logger.info("Closing browser")
browser.close()
@pytest.fixture(scope="session")
def api_context(playwright_context) -> Generator[APIRequestContext, None, None]:
"""
Create an API request context.
Note: this depends on our custom `playwright_context` (Sync API), not
on the `playwright` fixture from the pytest-playwright plugin. Reason:
pytest-playwright is built on the Async API; once its fixture fires,
it installs an asyncio event loop on the main thread, which makes our
Sync API raise "using Sync API inside the asyncio loop".
Yields:
APIRequestContext instance
"""
logger.info("Creating API request context")
api_request_context = playwright_context.request.new_context(
base_url=config.server.base_url,
extra_http_headers={
"Content-Type": "application/json",
"X-Agent-Id": "default",
}
)
yield api_request_context
api_request_context.dispose()
# ============================================================================
# Function-scoped Fixtures
# ============================================================================
@pytest.fixture(scope="function")
def browser_context(browser: Browser, request: pytest.FixtureRequest) -> Generator[BrowserContext, None, None]:
"""
Create a browser context (one per test function)
Supports:
- Isolated Cookies/Storage
- Video recording on failure
- Custom viewport
Yields:
BrowserContext instance
"""
test_name = request.node.name
logger.info(f"Creating browser context for test: {test_name}")
# Video recording configuration (saved on failure)
video_dir = config.paths.videos_dir if config.test.video_on_fail else None
context = browser.new_context(
viewport={
"width": config.browser.viewport_width,
"height": config.browser.viewport_height,
},
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
# Use en-US locale to match the system under test (English-only UI),
# preventing Chrome from showing a translation popup that would
# obscure page elements on a locale mismatch.
locale="en-US",
timezone_id="Asia/Shanghai",
record_video_dir=video_dir,
record_video_size={
"width": config.browser.viewport_width,
"height": config.browser.viewport_height,
} if video_dir else None,
)
yield context
logger.info(f"Closing browser context for test: {test_name}")
context.close()
@pytest.fixture(scope="function")
def page(browser_context: BrowserContext, request: pytest.FixtureRequest) -> Generator[Page, None, None]:
"""
Create a page instance (one per test function)
Supports:
- Automatic screenshot on failure
- Timeout configuration
- Console log capture
Yields:
Page instance
"""
test_name = request.node.name
logger.info(f"Creating page for test: {test_name}")
page = browser_context.new_page()
page.set_default_timeout(config.browser.timeout)
# Inject test name + step counter, used by BasePage.step_shot for auto-archiving
try:
page._qwenpaw_test_name = test_name
page._qwenpaw_step_seq = 0
except Exception:
pass
# Capture console logs
page.on("console", lambda msg: logger.debug(f"Browser console: {msg.type} - {msg.text}"))
page.on("pageerror", lambda err: logger.error(f"Page error: {err}"))
yield page
# Take a screenshot on test failure (use getattr as a fallback: API-only tests
# may not trigger the pytest_runtest_makereport hook, so the node may lack the
# rep_call attribute. getattr avoids AttributeError polluting teardown.)
rep_call = getattr(request.node, "rep_call", None)
if config.test.screenshot_on_fail and rep_call is not None and rep_call.failed:
try:
screenshot_path = config.paths.screenshots_dir / f"{test_name}_failure.png"
page.screenshot(path=str(screenshot_path), full_page=True)
logger.info(f"Screenshot saved: {screenshot_path}")
# Save video
if config.test.video_on_fail and page.video:
video_path = config.paths.videos_dir / f"{test_name}_failure.webm"
page.video.save_as(str(video_path))
logger.info(f"Video saved: {video_path}")
except Exception as e:
logger.warning(f"Failed to capture screenshot/video: {e}")
page.close()
logger.info(f"Page closed for test: {test_name}")
# Hook to track test call state
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo) -> None:
"""Track test execution state, used for screenshot-on-failure"""
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
@pytest.fixture(scope="function")
def chat_page(page: Page) -> ChatPage:
"""
Create a Chat page object
Yields:
ChatPage instance
"""
logger.info("Creating ChatPage instance")
chat = ChatPage(page)
yield chat
@pytest.fixture(scope="function")
def clean_chat_page(page: Page) -> Generator[ChatPage, None, None]:
"""
Create a ChatPage instance and clean up all session data after the test.
Use for tests that create new conversations, to ensure no residual data
is left behind.
Yields:
ChatPage instance
"""
logger.info("Creating ChatPage instance (with cleanup)")
chat = ChatPage(page)
yield chat
logger.info("Cleaning up test sessions")
try:
chat.delete_all_sessions()
except Exception as cleanup_error:
logger.warning(f"Session cleanup failed: {cleanup_error}")
@pytest.fixture(scope="function")
def authenticated_page(page: Page) -> Page:
"""
Authenticated page (if login is required)
QwenPaw currently uses an auth-free mode; extend this fixture if login
is needed.
Yields:
Page instance
"""
# If login is required, add the login logic here.
# For example:
# page.goto(f"{config.base_url}/login")
# page.fill('[name="username"]', "test_user")
# page.fill('[name="password"]', "test_password")
# page.click('button[type="submit"]')
# page.wait_for_load_state("networkidle")
logger.info("Authenticated page ready (using auth-free mode)")
yield page
# ============================================================================
# Data Fixtures
# ============================================================================
@pytest.fixture(scope="function")
def test_file(tmp_path: Path) -> Path:
"""
Create a test file
Yields:
Test file path
"""
test_file = tmp_path / "test_upload.txt"
test_content = """QwenPaw Test File
This is a file used to verify the E2E file upload functionality.
QwenPaw is an intelligent assistant platform supporting the following features:
1. Chat conversations
2. File processing
3. Skill invocation
4. Automated tasks
5. Multi-channel support
Version: v1.0.0
Created: 2026-04-13
Purpose: E2E testing
"""
test_file.write_text(test_content, encoding="utf-8")
logger.info(f"Test file created: {test_file}")
yield test_file
@pytest.fixture(scope="function")
def large_test_file(tmp_path: Path) -> Path:
"""
Create a large test file (used to test file size limits)
Yields:
Large file path
"""
large_file = tmp_path / "large_file.txt"
# Create an 11MB file (exceeds the 10MB limit)
chunk = "A" * (1024 * 1024) # 1MB
with open(large_file, 'w', encoding='utf-8') as f:
for _ in range(11):
f.write(chunk)
logger.info(f"Large test file created: {large_file} (11MB)")
yield large_file
@pytest.fixture(scope="function")
def test_messages() -> list:
"""
Test message data
Returns:
List of messages
"""
return [
"Hello, please introduce yourself",
"I want to learn Python programming",
"Where should I start?",
"Can you recommend some learning resources?",
]
@pytest.fixture(scope="function")
def test_user_data() -> dict:
"""
Test user data
Returns:
User data dictionary
"""
return {
"user_id": config.test.user_id,
"channel": config.test.channel,
"session_id": f"{config.test.channel}:{config.test.user_id}",
}
# ============================================================================
# Utility Fixtures
# ============================================================================
@pytest.fixture(scope="function")
def retry_on_failure(request: pytest.FixtureRequest):
"""
Retry-on-failure decorator
Usage:
@pytest.mark.parametrize("retry_on_failure", [3], indirect=True)
def test_something(retry_on_failure):
...
"""
max_retries = getattr(request, "param", 3)
def runner(test_func, *args, **kwargs):
last_exception = None
for attempt in range(max_retries):
try:
return test_func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f"Attempt {attempt + 1}/{max_retries} failed: {e}")
if attempt < max_retries - 1:
import time
time.sleep(config.server.retry_delay)
raise last_exception
return runner
@pytest.fixture(scope="session")
def base_url() -> str:
"""Get the base URL"""
return config.server.base_url
@pytest.fixture(scope="session")
def api_url() -> str:
"""Get the API URL"""
return config.server.api_base_url