|
1 | | -"""Configuration and shared fixtures for pytest automation test suite.""" |
2 | | - |
3 | | -import atexit |
| 1 | +""" |
| 2 | +Pytest configuration and fixtures for KM Generic Golden Path tests |
| 3 | +""" |
| 4 | +import os |
4 | 5 | import io |
5 | 6 | import logging |
6 | | -import os |
| 7 | +import atexit |
| 8 | +from datetime import datetime |
7 | 9 |
|
8 | 10 | import pytest |
9 | | -from bs4 import BeautifulSoup |
10 | 11 | from playwright.sync_api import sync_playwright |
| 12 | +from bs4 import BeautifulSoup |
11 | 13 |
|
12 | | -from config.constants import URL # Explicit import instead of wildcard |
13 | | - |
14 | | -# Uncomment if login is to be used |
15 | | -# from pages.loginPage import LoginPage |
16 | | - |
| 14 | +from config.constants import URL |
| 15 | + |
| 16 | +# Create screenshots directory if it doesn't exist |
| 17 | +SCREENSHOTS_DIR = os.path.join(os.path.dirname(__file__), "screenshots") |
| 18 | +os.makedirs(SCREENSHOTS_DIR, exist_ok=True) |
| 19 | + |
| 20 | +@pytest.fixture |
| 21 | +def subtests(request): |
| 22 | + """Fixture to enable subtests for step-by-step reporting in HTML""" |
| 23 | + class SubTests: |
| 24 | + """SubTests class for managing subtest contexts""" |
| 25 | + def __init__(self, request): |
| 26 | + self.request = request |
| 27 | + self._current_subtest = None |
| 28 | + |
| 29 | + def test(self, msg=None): |
| 30 | + """Create a new subtest context""" |
| 31 | + return SubTestContext(self, msg) |
| 32 | + |
| 33 | + class SubTestContext: |
| 34 | + """Context manager for individual subtests""" |
| 35 | + def __init__(self, parent, msg): |
| 36 | + self.parent = parent |
| 37 | + self.msg = msg |
| 38 | + self.logger = logging.getLogger() |
| 39 | + self.stream = None |
| 40 | + self.handler = None |
| 41 | + |
| 42 | + def __enter__(self): |
| 43 | + # Create a dedicated log stream for this subtest |
| 44 | + self.stream = io.StringIO() |
| 45 | + self.handler = logging.StreamHandler(self.stream) |
| 46 | + self.handler.setLevel(logging.INFO) |
| 47 | + self.logger.addHandler(self.handler) |
| 48 | + return self |
| 49 | + |
| 50 | + def __exit__(self, exc_type, exc_val, exc_tb): |
| 51 | + # Flush logs |
| 52 | + if self.handler: |
| 53 | + self.handler.flush() |
| 54 | + log_output = self.stream.getvalue() |
| 55 | + self.logger.removeHandler(self.handler) |
| 56 | + |
| 57 | + # Create a report entry for this subtest |
| 58 | + if hasattr(self.parent.request.node, 'user_properties'): |
| 59 | + self.parent.request.node.user_properties.append( |
| 60 | + ("subtest", { |
| 61 | + "msg": self.msg, |
| 62 | + "logs": log_output, |
| 63 | + "passed": exc_type is None |
| 64 | + }) |
| 65 | + ) |
| 66 | + |
| 67 | + # Don't suppress exceptions - let them propagate |
| 68 | + return False |
| 69 | + |
| 70 | + return SubTests(request) |
17 | 71 |
|
18 | 72 | @pytest.fixture(scope="session") |
19 | 73 | def login_logout(): |
20 | | - """Perform login once per session and yield a Playwright page instance.""" |
21 | | - with sync_playwright() as p: |
22 | | - browser = p.chromium.launch(headless=False, args=["--start-maximized"]) |
| 74 | + """Perform login and browser close once in a session""" |
| 75 | + with sync_playwright() as playwright_instance: |
| 76 | + browser = playwright_instance.chromium.launch( |
| 77 | + headless=False, |
| 78 | + args=["--start-maximized"] |
| 79 | + ) |
23 | 80 | context = browser.new_context(no_viewport=True) |
24 | | - context.set_default_timeout(120000) |
| 81 | + context.set_default_timeout(150000) |
25 | 82 | page = context.new_page() |
26 | | - page.goto(URL) |
27 | | - page.wait_for_load_state("networkidle") |
28 | | - |
29 | | - # Uncomment below to perform actual login |
30 | | - # login_page = LoginPage(page) |
31 | | - # load_dotenv() |
32 | | - # login_page.authenticate(os.getenv('user_name'), os.getenv('pass_word')) |
| 83 | + # Navigate to the login URL |
| 84 | + page.goto(URL, wait_until="domcontentloaded") |
| 85 | + # Wait for the login form to appear |
| 86 | + page.wait_for_timeout(6000) |
33 | 87 |
|
34 | 88 | yield page |
| 89 | + # Perform close the browser |
35 | 90 | browser.close() |
36 | 91 |
|
37 | 92 |
|
38 | | -@pytest.hookimpl(tryfirst=True) |
39 | | -def pytest_html_report_title(report): |
40 | | - """Customize HTML report title.""" |
41 | | - report.title = "Test Automation MACAE-v3 GP" |
42 | | - |
43 | | - |
44 | 93 | log_streams = {} |
45 | 94 |
|
46 | 95 |
|
47 | 96 | @pytest.hookimpl(tryfirst=True) |
48 | 97 | def pytest_runtest_setup(item): |
49 | | - """Attach a log stream to each test for capturing stdout/stderr.""" |
| 98 | + """Prepare StringIO for capturing logs""" |
50 | 99 | stream = io.StringIO() |
51 | 100 | handler = logging.StreamHandler(stream) |
52 | 101 | handler.setLevel(logging.INFO) |
53 | 102 |
|
54 | 103 | logger = logging.getLogger() |
55 | 104 | logger.addHandler(handler) |
56 | 105 |
|
| 106 | + # Save handler and stream |
57 | 107 | log_streams[item.nodeid] = (handler, stream) |
58 | 108 |
|
59 | 109 |
|
| 110 | + |
| 111 | +@pytest.hookimpl(tryfirst=True) |
| 112 | +def pytest_html_report_title(report): |
| 113 | + """Set custom HTML report title""" |
| 114 | + report.title = "MACAE-v3_test_Automation_Report" |
| 115 | + |
| 116 | + |
60 | 117 | @pytest.hookimpl(hookwrapper=True) |
61 | 118 | def pytest_runtest_makereport(item, call): |
62 | | - """Inject captured logs into HTML report for each test.""" |
| 119 | + """Generate test report with logs, subtest details, and screenshots on failure""" |
63 | 120 | outcome = yield |
64 | 121 | report = outcome.get_result() |
65 | 122 |
|
| 123 | + # Capture screenshot on failure |
| 124 | + if report.when == "call" and report.failed: |
| 125 | + # Get the page fixture if it exists |
| 126 | + if "login_logout" in item.fixturenames: |
| 127 | + page = item.funcargs.get("login_logout") |
| 128 | + if page: |
| 129 | + try: |
| 130 | + # Generate screenshot filename with timestamp |
| 131 | + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| 132 | + test_name = item.name.replace(" ", "_").replace("/", "_") |
| 133 | + screenshot_name = f"screenshot_{test_name}_{timestamp}.png" |
| 134 | + screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_name) |
| 135 | + |
| 136 | + # Take screenshot |
| 137 | + page.screenshot(path=screenshot_path) |
| 138 | + |
| 139 | + # Add screenshot link to report |
| 140 | + if not hasattr(report, 'extra'): |
| 141 | + report.extra = [] |
| 142 | + |
| 143 | + # Add screenshot as a link in the Links column |
| 144 | + # Use relative path from report.html location |
| 145 | + relative_path = os.path.relpath( |
| 146 | + screenshot_path, |
| 147 | + os.path.dirname(os.path.abspath("report.html")) |
| 148 | + ) |
| 149 | + |
| 150 | + # pytest-html expects this format for extras |
| 151 | + from pytest_html import extras |
| 152 | + report.extra.append(extras.url(relative_path, name='Screenshot')) |
| 153 | + |
| 154 | + logging.info("Screenshot saved: %s", screenshot_path) |
| 155 | + except Exception as exc: # pylint: disable=broad-exception-caught |
| 156 | + logging.error("Failed to capture screenshot: %s", str(exc)) |
| 157 | + |
66 | 158 | handler, stream = log_streams.get(item.nodeid, (None, None)) |
67 | 159 |
|
68 | 160 | if handler and stream: |
| 161 | + # Make sure logs are flushed |
69 | 162 | handler.flush() |
70 | 163 | log_output = stream.getvalue() |
| 164 | + |
| 165 | + # Only remove the handler, don't close the stream yet |
71 | 166 | logger = logging.getLogger() |
72 | 167 | logger.removeHandler(handler) |
73 | 168 |
|
74 | | - report.description = f"<pre>{log_output.strip()}</pre>" |
| 169 | + # Check if there are subtests |
| 170 | + subtests_html = "" |
| 171 | + if hasattr(item, 'user_properties'): |
| 172 | + item_subtests = [ |
| 173 | + prop[1] for prop in item.user_properties if prop[0] == "subtest" |
| 174 | + ] |
| 175 | + if item_subtests: |
| 176 | + subtests_html = ( |
| 177 | + "<div style='margin-top: 10px;'>" |
| 178 | + "<strong>Step-by-Step Details:</strong>" |
| 179 | + "<ul style='list-style: none; padding-left: 0;'>" |
| 180 | + ) |
| 181 | + for idx, subtest in enumerate(item_subtests, 1): |
| 182 | + status = "✅ PASSED" if subtest.get('passed') else "❌ FAILED" |
| 183 | + status_color = "green" if subtest.get('passed') else "red" |
| 184 | + subtests_html += ( |
| 185 | + f"<li style='margin: 10px 0; padding: 10px; " |
| 186 | + f"border-left: 3px solid {status_color}; " |
| 187 | + f"background-color: #f9f9f9;'>" |
| 188 | + ) |
| 189 | + subtests_html += ( |
| 190 | + f"<div style='font-weight: bold; color: {status_color};'>" |
| 191 | + f"{status} - {subtest.get('msg', f'Step {idx}')}</div>" |
| 192 | + ) |
| 193 | + if subtest.get('logs'): |
| 194 | + subtests_html += ( |
| 195 | + f"<pre style='margin: 5px 0; padding: 5px; " |
| 196 | + f"background-color: #fff; border: 1px solid #ddd; " |
| 197 | + f"font-size: 11px;'>{subtest.get('logs').strip()}</pre>" |
| 198 | + ) |
| 199 | + subtests_html += "</li>" |
| 200 | + subtests_html += "</ul></div>" |
| 201 | + |
| 202 | + # Combine main log output with subtests |
| 203 | + if subtests_html: |
| 204 | + report.description = f"<pre>{log_output.strip()}</pre>{subtests_html}" |
| 205 | + else: |
| 206 | + report.description = f"<pre>{log_output.strip()}</pre>" |
| 207 | + |
| 208 | + # Clean up references |
75 | 209 | log_streams.pop(item.nodeid, None) |
76 | 210 | else: |
77 | 211 | report.description = "" |
78 | 212 |
|
79 | | - |
80 | 213 | def pytest_collection_modifyitems(items): |
81 | | - """Rename test node IDs in HTML report based on parametrized prompts.""" |
| 214 | + """Modify test items to use custom node IDs""" |
82 | 215 | for item in items: |
83 | | - if hasattr(item, "callspec"): |
84 | | - prompt = item.callspec.params.get("prompt") |
85 | | - if prompt: |
86 | | - item._nodeid = prompt |
| 216 | + if hasattr(item, 'callspec'): |
| 217 | + # Check for 'description' parameter first (for Golden Path tests) |
| 218 | + description = item.callspec.params.get("description") |
| 219 | + if description: |
| 220 | + # pylint: disable=protected-access |
| 221 | + item._nodeid = f"Golden Path - KM Generic - {description}" |
| 222 | + # Fallback to 'prompt' parameter for other tests |
| 223 | + else: |
| 224 | + prompt = item.callspec.params.get("prompt") |
| 225 | + if prompt: |
| 226 | + # This controls how the test name appears in the report |
| 227 | + # pylint: disable=protected-access |
| 228 | + item._nodeid = prompt |
87 | 229 |
|
88 | 230 |
|
89 | 231 | def rename_duration_column(): |
90 | | - """Post-process HTML report to rename 'Duration' column to 'Execution Time'.""" |
| 232 | + """Rename Duration column to Execution Time in HTML report""" |
91 | 233 | report_path = os.path.abspath("report.html") |
92 | 234 | if not os.path.exists(report_path): |
93 | 235 | print("Report file not found, skipping column rename.") |
94 | 236 | return |
95 | 237 |
|
96 | | - with open(report_path, "r", encoding="utf-8") as f: |
97 | | - soup = BeautifulSoup(f, "html.parser") |
| 238 | + with open(report_path, 'r', encoding='utf-8') as report_file: |
| 239 | + soup = BeautifulSoup(report_file, 'html.parser') |
98 | 240 |
|
99 | | - headers = soup.select("table#results-table thead th") |
100 | | - for th in headers: |
101 | | - if th.text.strip() == "Duration": |
102 | | - th.string = "Execution Time" |
| 241 | + # Find and rename the header |
| 242 | + headers = soup.select('table#results-table thead th') |
| 243 | + for header_th in headers: |
| 244 | + if header_th.text.strip() == 'Duration': |
| 245 | + header_th.string = 'Execution Time' |
103 | 246 | break |
104 | 247 | else: |
105 | 248 | print("'Duration' column not found in report.") |
106 | 249 |
|
107 | | - with open(report_path, "w", encoding="utf-8") as f: |
108 | | - f.write(str(soup)) |
109 | | - |
| 250 | + with open(report_path, 'w', encoding='utf-8') as report_file: |
| 251 | + report_file.write(str(soup)) |
110 | 252 |
|
111 | | -# Register the report modification function to run after tests |
112 | 253 |
|
| 254 | +# Register this function to run after everything is done |
113 | 255 | atexit.register(rename_duration_column) |
114 | | - |
|
0 commit comments