robo-automation-test-kit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- robo_automation_test_kit/__init__.py +8 -0
- robo_automation_test_kit/hookspec.py +36 -0
- robo_automation_test_kit/plugin.py +867 -0
- robo_automation_test_kit/templates/email_report/email_template.html +0 -0
- robo_automation_test_kit/templates/html_report/components/category-chart.html +148 -0
- robo_automation_test_kit/templates/html_report/components/center-chart.html +97 -0
- robo_automation_test_kit/templates/html_report/components/phase-chart.html +148 -0
- robo_automation_test_kit/templates/html_report/components/results-table.html +240 -0
- robo_automation_test_kit/templates/html_report/components/status-center-chart.html +148 -0
- robo_automation_test_kit/templates/html_report/components/summary-chart.html +94 -0
- robo_automation_test_kit/templates/html_report/html_template.html +62 -0
- robo_automation_test_kit/templates/html_report/scripts/css/material-icons.css +20 -0
- robo_automation_test_kit/templates/html_report/scripts/css/report.css +714 -0
- robo_automation_test_kit/templates/html_report/scripts/css/robo-fonts.css +24 -0
- robo_automation_test_kit/templates/html_report/scripts/js/chart.js +14 -0
- robo_automation_test_kit/templates/html_report/scripts/js/report.js +319 -0
- robo_automation_test_kit/utils/RoboHelper.py +420 -0
- robo_automation_test_kit/utils/__init__.py +19 -0
- robo_automation_test_kit/utils/reports/EmailReportUtils.py +0 -0
- robo_automation_test_kit/utils/reports/HtmlReportUtils.py +154 -0
- robo_automation_test_kit/utils/reports/__init__.py +3 -0
- robo_automation_test_kit-1.0.0.dist-info/METADATA +132 -0
- robo_automation_test_kit-1.0.0.dist-info/RECORD +26 -0
- robo_automation_test_kit-1.0.0.dist-info/WHEEL +4 -0
- robo_automation_test_kit-1.0.0.dist-info/entry_points.txt +3 -0
- robo_automation_test_kit-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Robo Reporter - Pytest Plugin
|
|
3
|
+
Collects test results and generates HTML reports with chart visualizations.
|
|
4
|
+
|
|
5
|
+
PYTEST HOOK EXECUTION ORDER (Session Lifecycle):
|
|
6
|
+
=====================================================
|
|
7
|
+
|
|
8
|
+
PHASE 1: SESSION INITIALIZATION
|
|
9
|
+
1. pytest_addoption - Register command-line options (called once per session)
|
|
10
|
+
2. pytest_plugin_registered - Plugin registration/lifecycle management
|
|
11
|
+
3. pytest_configure - Initialize plugin state and global config
|
|
12
|
+
4. pytest_sessionstart - Session initialization complete
|
|
13
|
+
|
|
14
|
+
PHASE 2: TEST COLLECTION
|
|
15
|
+
5. pytest_collection - Parse test selections and optimize collection
|
|
16
|
+
6. pytest_collection_modifyitems - Modify collected test items
|
|
17
|
+
7. pytest_generate_tests - Parametrize tests with CSV/Excel data (per test function)
|
|
18
|
+
8. pytest_collection_finish - Collection phase complete
|
|
19
|
+
|
|
20
|
+
PHASE 3: TEST EXECUTION (per test)
|
|
21
|
+
9. pytest_runtest_protocol - Protocol for running individual tests
|
|
22
|
+
├─ pytest_runtest_setup - Setup phase before test
|
|
23
|
+
├─ pytest_runtest_call - Test execution phase
|
|
24
|
+
├─ pytest_runtest_teardown - Teardown phase after test
|
|
25
|
+
└─ pytest_runtest_makereport - Capture result after each phase
|
|
26
|
+
|
|
27
|
+
PHASE 4: XDIST WORKER COORDINATION (parallel execution only)
|
|
28
|
+
10. pytest_configure_node - Configure xdist worker nodes (workers only)
|
|
29
|
+
11. pytest_testnodedown - Aggregate worker results to master (master only)
|
|
30
|
+
|
|
31
|
+
PHASE 5: SESSION FINALIZATION
|
|
32
|
+
12. pytest_sessionfinish - Session finalization before report generation
|
|
33
|
+
13. pytest_unconfigure - Generate final HTML report (master only)
|
|
34
|
+
|
|
35
|
+
CUSTOM HOOKS (Robo Reporter Extensions):
|
|
36
|
+
========================================
|
|
37
|
+
- robo_modify_report_row - Allow projects to provide custom test attributes
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
import os
|
|
42
|
+
import shutil
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
from dotenv import load_dotenv
|
|
49
|
+
import pytest
|
|
50
|
+
from selenium import webdriver
|
|
51
|
+
from selenium.webdriver.chrome.options import Options
|
|
52
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
53
|
+
|
|
54
|
+
from .utils.RoboHelper import (
|
|
55
|
+
print_results_summary,
|
|
56
|
+
build_test_data,
|
|
57
|
+
create_report_summary,
|
|
58
|
+
generate_report,
|
|
59
|
+
flatten_results,
|
|
60
|
+
aggregate_test_results,
|
|
61
|
+
)
|
|
62
|
+
from .utils import get_env, load_test_data
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger(__name__)
|
|
66
|
+
logger.propagate = True
|
|
67
|
+
|
|
68
|
+
# Load environment variables from .env file
|
|
69
|
+
load_dotenv()
|
|
70
|
+
|
|
71
|
+
# ============================================================================
|
|
72
|
+
# Global Variables for pytest-xdist result aggregation
|
|
73
|
+
# ============================================================================
|
|
74
|
+
|
|
75
|
+
_MASTER_CONFIG = None # Global reference to master config for xdist aggregation
|
|
76
|
+
_CONFTEST_HOOK_MODULE = None # Cached conftest module with robo_modify_report_row
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ============================================================================
|
|
80
|
+
# Pytest Hooks (ordered by execution sequence)
|
|
81
|
+
# ============================================================================
|
|
82
|
+
|
|
83
|
+
# ============================================================================
|
|
84
|
+
# HOOK 1: pytest_addoption
|
|
85
|
+
# Execution: Very first - before plugins are loaded
|
|
86
|
+
# Purpose: Register custom command-line options for the pytest command
|
|
87
|
+
# ============================================================================
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pytest_addoption(parser):
|
|
91
|
+
"""
|
|
92
|
+
Register command-line options for the robo-reporter plugin.
|
|
93
|
+
|
|
94
|
+
Options:
|
|
95
|
+
- --robo-report: Custom path for HTML report output
|
|
96
|
+
- --robo-report-title: Custom title for the HTML report
|
|
97
|
+
"""
|
|
98
|
+
group = parser.getgroup("robo-reporter", "Robo Reporter Options")
|
|
99
|
+
group.addoption(
|
|
100
|
+
"--robo-report",
|
|
101
|
+
action="store",
|
|
102
|
+
dest="robo_report_path",
|
|
103
|
+
default=None,
|
|
104
|
+
help="Path to save the HTML report (default: reports/test_report_<timestamp>.html)",
|
|
105
|
+
)
|
|
106
|
+
group.addoption(
|
|
107
|
+
"--robo-report-title",
|
|
108
|
+
action="store",
|
|
109
|
+
dest="robo_report_title",
|
|
110
|
+
default="Test Execution Report",
|
|
111
|
+
help="Title for the HTML report",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ============================================================================
|
|
116
|
+
# HOOK 2: pytest_plugin_registered
|
|
117
|
+
# Execution: When each plugin is registered (after addoption)
|
|
118
|
+
# Purpose: Manage plugin lifecycle - can unregister plugins if conditions met
|
|
119
|
+
# ============================================================================
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def pytest_plugin_registered(plugin, manager):
|
|
123
|
+
"""
|
|
124
|
+
Called when a plugin is registered.
|
|
125
|
+
|
|
126
|
+
Functionality:
|
|
127
|
+
- Checks PARALLEL_EXECUTION environment variable
|
|
128
|
+
- Unregisters pytest-xdist if PARALLEL_EXECUTION is disabled
|
|
129
|
+
- Allows disabling parallel execution via environment configuration
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
# Check if the registered plugin is the xdist dsession plugin
|
|
133
|
+
if str(plugin).find("xdist.dsession.DSession") != -1:
|
|
134
|
+
# Check PARALLEL_EXECUTION environment variable
|
|
135
|
+
parallel_execution = get_env("PARALLEL_EXECUTION", "N").strip()
|
|
136
|
+
if parallel_execution == "N":
|
|
137
|
+
logger.warning("Parallel execution disabled, unregistering pytest-xdist")
|
|
138
|
+
manager.unregister(plugin)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ============================================================================
|
|
142
|
+
# HOOK 3: pytest_configure
|
|
143
|
+
# Execution: After command-line parsing and all plugins loaded
|
|
144
|
+
# Purpose: Initialize plugin state, register hookspecs, store global config
|
|
145
|
+
# Runs on: Both master and worker processes
|
|
146
|
+
# ============================================================================
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def pytest_configure(config):
|
|
150
|
+
"""
|
|
151
|
+
Initialize robo-reporter plugin configuration.
|
|
152
|
+
|
|
153
|
+
Responsibilities:
|
|
154
|
+
1. Store session start time for report duration calculation
|
|
155
|
+
2. Initialize test_results_summary list on config object
|
|
156
|
+
3. Store master config reference in global variable for xdist workers
|
|
157
|
+
|
|
158
|
+
Config attributes created:
|
|
159
|
+
- config.test_results_summary: List to collect test result dicts
|
|
160
|
+
- config._sessionstart_time: Session start datetime
|
|
161
|
+
- _MASTER_CONFIG: Global ref to master config for worker aggregation
|
|
162
|
+
|
|
163
|
+
Note on hooks:
|
|
164
|
+
- Project-specific hook implementations (robo_modify_report_row) in conftest.py
|
|
165
|
+
are discovered via direct module lookup in pytest_runtest_makereport
|
|
166
|
+
- pytest's hook discovery can't find hookimpls in modules loaded before hookspec
|
|
167
|
+
registration, so we use direct sys.modules lookup instead (more reliable)
|
|
168
|
+
"""
|
|
169
|
+
# Discover and cache conftest module with hook implementation (optimization)
|
|
170
|
+
global _CONFTEST_HOOK_MODULE
|
|
171
|
+
if _CONFTEST_HOOK_MODULE is None:
|
|
172
|
+
for module_name, module in list(sys.modules.items()):
|
|
173
|
+
if "conftest" in module_name and hasattr(module, "robo_modify_report_row"):
|
|
174
|
+
_CONFTEST_HOOK_MODULE = module
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
# Store session start time for HTML report duration calculation (master only)
|
|
178
|
+
if not hasattr(config, "workerinput") and not hasattr(config, "_sessionstart_time"):
|
|
179
|
+
config._sessionstart_time = datetime.now()
|
|
180
|
+
|
|
181
|
+
# Initialize test_results_summary on config (runs on both master and workers)
|
|
182
|
+
config.test_results_summary = []
|
|
183
|
+
|
|
184
|
+
# Store master config in global for xdist aggregation (master only)
|
|
185
|
+
global _MASTER_CONFIG
|
|
186
|
+
if not hasattr(config, "workerinput"):
|
|
187
|
+
_MASTER_CONFIG = config
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ============================================================================
|
|
191
|
+
# HOOK 4: pytest_sessionstart
|
|
192
|
+
# Execution: After session object has been created and before collection starts
|
|
193
|
+
# Purpose: Setup session-specific state before test collection
|
|
194
|
+
# Runs on: Both master and worker processes
|
|
195
|
+
# ============================================================================
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def pytest_sessionstart(session):
|
|
199
|
+
"""
|
|
200
|
+
Called at session initialization after test collection configuration.
|
|
201
|
+
|
|
202
|
+
Responsibilities:
|
|
203
|
+
- Session state is now available
|
|
204
|
+
- Plugin setup is complete
|
|
205
|
+
- Ready to start test collection
|
|
206
|
+
|
|
207
|
+
Called after:
|
|
208
|
+
- pytest_configure (plugin setup)
|
|
209
|
+
- Command-line options registered
|
|
210
|
+
|
|
211
|
+
Called before:
|
|
212
|
+
- pytest_collection (test collection starts)
|
|
213
|
+
"""
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ============================================================================
|
|
218
|
+
# HOOK 5: pytest_collection
|
|
219
|
+
# Execution: At start of collection phase (after sessionstart)
|
|
220
|
+
# Purpose: Optimize test collection by parsing command-line test selectors
|
|
221
|
+
# Runs on: Both master and worker processes
|
|
222
|
+
# ============================================================================
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def pytest_collection(session):
|
|
226
|
+
"""
|
|
227
|
+
Parse and store test selections for optimization.
|
|
228
|
+
|
|
229
|
+
Parses command-line arguments to identify specific test selections
|
|
230
|
+
(e.g., tests/test_file.py::test_name) and stores them in config.
|
|
231
|
+
|
|
232
|
+
Purpose:
|
|
233
|
+
- Helps pytest_generate_tests skip parametrization for unselected tests
|
|
234
|
+
- Reduces overhead when running specific tests instead of full suite
|
|
235
|
+
|
|
236
|
+
Config attributes created:
|
|
237
|
+
- config._specified_test_functions: Set of selected test node IDs
|
|
238
|
+
"""
|
|
239
|
+
config = session.config
|
|
240
|
+
specified_tests = set()
|
|
241
|
+
|
|
242
|
+
# Parse command line arguments to find test selectors (e.g., tests/test_file.py::test_name)
|
|
243
|
+
for arg in config.invocation_params.args:
|
|
244
|
+
# Skip pytest options (start with -)
|
|
245
|
+
if arg.startswith("-"):
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Only process test selectors containing ::
|
|
249
|
+
if "::" not in arg:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
# Normalize path separators for cross-platform compatibility
|
|
253
|
+
normalized = arg.replace("\\", "/")
|
|
254
|
+
|
|
255
|
+
# Handle Windows absolute paths (e.g., C:/path/to/tests/test_file.py::test_name)
|
|
256
|
+
# Extract relative path from test directory onwards
|
|
257
|
+
if ":" in normalized.split("::")[0]: # Has drive letter (Windows absolute path)
|
|
258
|
+
parts = normalized.split("/")
|
|
259
|
+
# Find test directory start (tests/, test/, or test_*.py)
|
|
260
|
+
for i, part in enumerate(parts):
|
|
261
|
+
if part in ("tests", "test") or part.startswith("test_"):
|
|
262
|
+
normalized = "/".join(parts[i:])
|
|
263
|
+
break
|
|
264
|
+
|
|
265
|
+
# Remove parametrization indices like [0], [row_name], etc.
|
|
266
|
+
normalized = normalized.split("[")[0]
|
|
267
|
+
|
|
268
|
+
specified_tests.add(normalized)
|
|
269
|
+
|
|
270
|
+
# Store for use in pytest_generate_tests
|
|
271
|
+
config._specified_test_functions = specified_tests
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ============================================================================
|
|
275
|
+
# HOOK 6: pytest_collection_modifyitems
|
|
276
|
+
# Execution: After test collection, before parametrization of individual tests
|
|
277
|
+
# Purpose: Modify collected test items (reorder, filter, mark, etc.)
|
|
278
|
+
# Runs on: Both master and worker processes
|
|
279
|
+
# ============================================================================
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def pytest_collection_modifyitems(session, config, items):
|
|
283
|
+
"""
|
|
284
|
+
Modify collected test items before test execution.
|
|
285
|
+
|
|
286
|
+
Called after pytest_collection and before parametrization.
|
|
287
|
+
Allows plugins to:
|
|
288
|
+
- Filter or reorder tests
|
|
289
|
+
- Add marks to tests
|
|
290
|
+
- Modify test parameters
|
|
291
|
+
- Skip tests programmatically
|
|
292
|
+
|
|
293
|
+
Purpose:
|
|
294
|
+
- Reserved for future enhancements (filtering, reordering, etc.)
|
|
295
|
+
- Can add custom marks or modify test execution order
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
session: Test session object
|
|
299
|
+
config: Pytest config object
|
|
300
|
+
items: List of collected test items
|
|
301
|
+
"""
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ============================================================================
|
|
306
|
+
# HOOK 7: pytest_generate_tests
|
|
307
|
+
# Execution: For each test function during collection (after collection_modifyitems)
|
|
308
|
+
# Purpose: Parametrize tests with CSV/Excel data rows
|
|
309
|
+
# Runs on: Both master and worker processes
|
|
310
|
+
# ============================================================================
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def pytest_generate_tests(metafunc):
|
|
314
|
+
"""
|
|
315
|
+
Parametrize tests with data from CSV/Excel files.
|
|
316
|
+
|
|
317
|
+
Triggered when:
|
|
318
|
+
- Test has @pytest.mark.datafile("filename.csv") marker
|
|
319
|
+
- Test declares 'row' as a fixture/parameter
|
|
320
|
+
|
|
321
|
+
Process:
|
|
322
|
+
1. Check for @pytest.mark.datafile marker
|
|
323
|
+
2. Validate 'row' fixture is used by test
|
|
324
|
+
3. Check if test is in selected tests (skip if not)
|
|
325
|
+
4. Load CSV/Excel data from data/ directory
|
|
326
|
+
5. Parametrize test with loaded rows
|
|
327
|
+
|
|
328
|
+
Optimization:
|
|
329
|
+
- Skips tests not explicitly requested in command line
|
|
330
|
+
- Reduces overhead for targeted test runs
|
|
331
|
+
"""
|
|
332
|
+
# Check if test has @pytest.mark.datafile marker
|
|
333
|
+
marker = metafunc.definition.get_closest_marker("datafile")
|
|
334
|
+
if not marker or not marker.args:
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
# Check if test actually uses the 'row' fixture
|
|
338
|
+
if "row" not in metafunc.fixturenames:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
csv_file = marker.args[0]
|
|
342
|
+
test_nodeid = metafunc.definition.nodeid
|
|
343
|
+
config = metafunc.config
|
|
344
|
+
|
|
345
|
+
# Optimization: If specific tests were requested, skip tests not in the selection
|
|
346
|
+
if (
|
|
347
|
+
hasattr(config, "_specified_test_functions")
|
|
348
|
+
and config._specified_test_functions
|
|
349
|
+
):
|
|
350
|
+
is_requested = any(
|
|
351
|
+
test_nodeid.split("[")[0] == spec
|
|
352
|
+
or test_nodeid.split("[")[0].startswith(spec + "::")
|
|
353
|
+
for spec in config._specified_test_functions
|
|
354
|
+
)
|
|
355
|
+
if not is_requested:
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
# Validate test file path exists
|
|
359
|
+
test_file_path = metafunc.definition.path
|
|
360
|
+
if not test_file_path:
|
|
361
|
+
logger.error(f"Cannot determine file path for test {test_nodeid}")
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
# Load CSV data from data/ directory (sibling to test directory)
|
|
365
|
+
test_dir = Path(test_file_path).parent
|
|
366
|
+
data_path = test_dir.parent / "data" / csv_file
|
|
367
|
+
|
|
368
|
+
# Load test data from CSV/Excel file
|
|
369
|
+
rows = load_test_data(data_path)
|
|
370
|
+
|
|
371
|
+
if not rows:
|
|
372
|
+
logger.error(
|
|
373
|
+
f"Failed to load data file '{csv_file}' at {data_path}; "
|
|
374
|
+
f"file may not exist, be empty, or have encoding issues"
|
|
375
|
+
)
|
|
376
|
+
pytest.fail(f"Data file '{csv_file}' could not be loaded from {data_path}")
|
|
377
|
+
|
|
378
|
+
metafunc.parametrize("row", rows)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ============================================================================
|
|
382
|
+
# HOOK 8: pytest_collection_finish
|
|
383
|
+
# Execution: After all tests have been collected
|
|
384
|
+
# Purpose: Final opportunity to modify or inspect collected tests
|
|
385
|
+
# Runs on: Both master and worker processes
|
|
386
|
+
# ============================================================================
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def pytest_collection_finish(session):
|
|
390
|
+
"""
|
|
391
|
+
Called after collection of all test items is complete.
|
|
392
|
+
|
|
393
|
+
Process:
|
|
394
|
+
- All tests have been discovered and collected
|
|
395
|
+
- pytest_generate_tests has been called for all test functions
|
|
396
|
+
- Ready to begin test execution phase
|
|
397
|
+
|
|
398
|
+
Purpose:
|
|
399
|
+
- Log collection summary
|
|
400
|
+
- Reserved for future enhancements (reporting, validation, etc.)
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
session: Test session object containing all collected items
|
|
404
|
+
"""
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ============================================================================
|
|
409
|
+
# HOOK 9: pytest_runtest_makereport
|
|
410
|
+
# Execution: For each test phase (setup, call, teardown) after phase completes
|
|
411
|
+
# Purpose: Capture test results and metadata
|
|
412
|
+
# Runs on: Both master and worker processes
|
|
413
|
+
# ============================================================================
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def pytest_runtest_makereport(item, call):
|
|
417
|
+
"""
|
|
418
|
+
Capture individual test result data.
|
|
419
|
+
|
|
420
|
+
Called for each test phase:
|
|
421
|
+
- setup: Before test execution
|
|
422
|
+
- call: During test execution (captured by this hook)
|
|
423
|
+
- teardown: After test execution
|
|
424
|
+
|
|
425
|
+
Result data collected:
|
|
426
|
+
- test_status: PASSED, FAILED, or SKIPPED (RERUN status is converted to FAILED)
|
|
427
|
+
- test_name: Full pytest node ID
|
|
428
|
+
- title: Test title from @pytest.mark.datafile row or docstring
|
|
429
|
+
- Phase, Request Category, Request Sub Category, Center: From CSV data
|
|
430
|
+
- duration: Execution time in seconds (sum of setup + call + teardown)
|
|
431
|
+
- error_log: Exception message if test failed
|
|
432
|
+
|
|
433
|
+
Data storage:
|
|
434
|
+
- Appended to config.test_results_summary (master and workers)
|
|
435
|
+
- Synced to workeroutput for xdist workers
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
# Store durations for each phase on the item
|
|
439
|
+
if not hasattr(item, "_phase_durations"):
|
|
440
|
+
item._phase_durations = {}
|
|
441
|
+
|
|
442
|
+
# Capture duration for this phase
|
|
443
|
+
item._phase_durations[call.when] = getattr(call, "duration", 0)
|
|
444
|
+
|
|
445
|
+
# Store call phase info for later use
|
|
446
|
+
if call.when == "call":
|
|
447
|
+
item._call_excinfo = call.excinfo
|
|
448
|
+
item._call_when = call.when
|
|
449
|
+
|
|
450
|
+
# Only create final result after teardown completes
|
|
451
|
+
if call.when != "teardown":
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
# Build test result data dictionary
|
|
455
|
+
report_row = build_test_data(item)
|
|
456
|
+
test_data = item.funcargs.get("row", {}) if "row" in item.fixturenames else {}
|
|
457
|
+
|
|
458
|
+
# Allow source projects to modify/enrich the report row via conftest hook
|
|
459
|
+
final_report_row = report_row
|
|
460
|
+
|
|
461
|
+
# Use cached conftest module for better performance
|
|
462
|
+
if _CONFTEST_HOOK_MODULE is not None:
|
|
463
|
+
try:
|
|
464
|
+
result = _CONFTEST_HOOK_MODULE.robo_modify_report_row(
|
|
465
|
+
report_row=report_row, test_data=test_data
|
|
466
|
+
)
|
|
467
|
+
if result and isinstance(result, dict):
|
|
468
|
+
final_report_row = result
|
|
469
|
+
elif result is not None:
|
|
470
|
+
logger.warning(
|
|
471
|
+
f"robo_modify_report_row returned {type(result).__name__} instead of dict, "
|
|
472
|
+
f"ignoring result for test {item.nodeid}"
|
|
473
|
+
)
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.error(
|
|
476
|
+
f"Error calling robo_modify_report_row for test {item.nodeid}: {e}",
|
|
477
|
+
exc_info=True
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Store result in config (initialized for both main and worker processes)
|
|
481
|
+
item.config.test_results_summary.append(final_report_row)
|
|
482
|
+
|
|
483
|
+
# For xdist workers: sync to workeroutput for master aggregation
|
|
484
|
+
if hasattr(item.config, "workeroutput"):
|
|
485
|
+
item.config.workeroutput["test_results_summary"] = list(
|
|
486
|
+
item.config.test_results_summary
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ============================================================================
|
|
491
|
+
# HOOK 10: pytest_configure_node (xdist only)
|
|
492
|
+
# Execution: When xdist worker node is being configured
|
|
493
|
+
# Purpose: Initialize worker-specific configuration
|
|
494
|
+
# Runs on: Worker processes only (not on master)
|
|
495
|
+
# ============================================================================
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def pytest_configure_node(node):
|
|
499
|
+
"""
|
|
500
|
+
Configure individual xdist worker node.
|
|
501
|
+
|
|
502
|
+
Called for each worker process during parallel execution.
|
|
503
|
+
Only runs in worker processes, not in master process.
|
|
504
|
+
|
|
505
|
+
Responsibilities:
|
|
506
|
+
- Initialize worker-specific test results list
|
|
507
|
+
- Setup worker state for result collection
|
|
508
|
+
- Ensure worker isolation from master process
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
node: xdist WorkerController object representing the worker node
|
|
512
|
+
|
|
513
|
+
Note:
|
|
514
|
+
This hook is only called when running with pytest-xdist.
|
|
515
|
+
Does not run in serial execution mode.
|
|
516
|
+
"""
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ============================================================================
|
|
521
|
+
# HOOK 11: pytest_testnodedown (xdist only)
|
|
522
|
+
# Execution: When xdist worker process terminates
|
|
523
|
+
# Purpose: Aggregate results from workers back to master process
|
|
524
|
+
# Runs on: Master process only (for each completed worker)
|
|
525
|
+
# ============================================================================
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def pytest_testnodedown(node, error):
|
|
529
|
+
"""
|
|
530
|
+
Aggregate results from xdist worker process.
|
|
531
|
+
|
|
532
|
+
Called once per worker after all tests finish on that worker.
|
|
533
|
+
Only runs in the master process.
|
|
534
|
+
|
|
535
|
+
Process:
|
|
536
|
+
1. Get worker ID from node configuration
|
|
537
|
+
2. Extract test_results_summary from worker's workeroutput
|
|
538
|
+
3. Flatten and aggregate into master's _test_results_from_workers list
|
|
539
|
+
4. Log worker status (success or error)
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
node: xdist worker node object
|
|
543
|
+
error: Exception if worker crashed, None if successful
|
|
544
|
+
"""
|
|
545
|
+
# Use global _MASTER_CONFIG for aggregation
|
|
546
|
+
config = _MASTER_CONFIG
|
|
547
|
+
if config is None:
|
|
548
|
+
logger.warning("Master config not available for result aggregation")
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
# Get worker ID from xdist WorkerController
|
|
552
|
+
worker_id = (
|
|
553
|
+
node.workerinput.get("workerid", "unknown")
|
|
554
|
+
if hasattr(node, "workerinput")
|
|
555
|
+
else "unknown"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Log if worker had an error
|
|
559
|
+
if error:
|
|
560
|
+
logger.warning(f"Worker {worker_id} encountered error: {error}")
|
|
561
|
+
|
|
562
|
+
# Validate node has workeroutput
|
|
563
|
+
if not hasattr(node, "workeroutput") or node.workeroutput is None:
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
# Initialize aggregation list if needed
|
|
567
|
+
if not hasattr(config, "_test_results_from_workers"):
|
|
568
|
+
config._test_results_from_workers = []
|
|
569
|
+
|
|
570
|
+
# Extract and aggregate worker results
|
|
571
|
+
results = node.workeroutput.get("test_results_summary", [])
|
|
572
|
+
|
|
573
|
+
if not results:
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
flatten_results(results, config)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ============================================================================
|
|
580
|
+
# HOOK 12: pytest_sessionfinish
|
|
581
|
+
# Execution: After all tests have finished, before pytest_unconfigure
|
|
582
|
+
# Purpose: Perform final cleanup and session-level operations
|
|
583
|
+
# Runs on: Both master and worker processes
|
|
584
|
+
# ============================================================================
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
588
|
+
"""
|
|
589
|
+
Called after test session is complete, before report generation.
|
|
590
|
+
|
|
591
|
+
Process:
|
|
592
|
+
- All tests have been executed
|
|
593
|
+
- xdist workers have been aggregated (if applicable)
|
|
594
|
+
- Before HTML report generation
|
|
595
|
+
|
|
596
|
+
Purpose:
|
|
597
|
+
- Perform final session cleanup
|
|
598
|
+
- Aggregate final results
|
|
599
|
+
- Execute session-level teardown logic
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
session: Test session object
|
|
603
|
+
exitstatus: Exit status code of the session
|
|
604
|
+
(0: all passed, 1: failures, 2: interrupted, etc.)
|
|
605
|
+
|
|
606
|
+
Note:
|
|
607
|
+
- Called on both master and worker processes
|
|
608
|
+
- Worker processes will not generate reports
|
|
609
|
+
- This runs before pytest_unconfigure hook
|
|
610
|
+
"""
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ============================================================================
|
|
615
|
+
# HOOK 13: pytest_unconfigure
|
|
616
|
+
# Execution: Last hook - after all teardown and xdist aggregation complete
|
|
617
|
+
# Purpose: Generate final HTML report with all collected results
|
|
618
|
+
# Runs on: Master process only (not in xdist workers)
|
|
619
|
+
# ============================================================================
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def pytest_unconfigure(config):
|
|
623
|
+
"""
|
|
624
|
+
Generate final HTML report after all tests complete.
|
|
625
|
+
|
|
626
|
+
Called after all tests have finished, xdist workers aggregated, and cleanup complete.
|
|
627
|
+
Only runs in master process (not in xdist workers).
|
|
628
|
+
|
|
629
|
+
Execution Sequence:
|
|
630
|
+
1. Verify running in master process (skip if worker)
|
|
631
|
+
2. Retrieve session start time
|
|
632
|
+
3. Aggregate test results from master and all workers
|
|
633
|
+
4. Create summary statistics (pass/fail/skip counts)
|
|
634
|
+
5. Generate HTML report with visualizations
|
|
635
|
+
6. Save report to reports/ directory with timestamp
|
|
636
|
+
|
|
637
|
+
Process:
|
|
638
|
+
- Aggregate results from config.test_results_summary (master)
|
|
639
|
+
- Aggregate results from config._test_results_from_workers (all workers)
|
|
640
|
+
- Calculate summary statistics (total, passed, failed, skipped)
|
|
641
|
+
- Render Jinja2 HTML template with chart data
|
|
642
|
+
- Save to reports/test_report_<timestamp>.html
|
|
643
|
+
|
|
644
|
+
Report Contents:
|
|
645
|
+
- Test execution dashboard with summary metrics
|
|
646
|
+
- Status breakdown charts (passed/failed/skipped)
|
|
647
|
+
- Category breakdown by custom fields (Phase, Center, etc.)
|
|
648
|
+
- Detailed results table with all test data
|
|
649
|
+
- Test durations and error messages
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
config: Pytest config object
|
|
653
|
+
|
|
654
|
+
Note:
|
|
655
|
+
- Called only in master process: skips if hasattr(config, 'workerinput')
|
|
656
|
+
- Called only once per session after all xdist aggregation
|
|
657
|
+
- No action needed if no tests were collected/executed
|
|
658
|
+
"""
|
|
659
|
+
# Only run in master process
|
|
660
|
+
if hasattr(config, "workerinput"):
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
# Get report configuration
|
|
664
|
+
start_time = getattr(config, "_sessionstart_time", None)
|
|
665
|
+
|
|
666
|
+
# Generate HTML report
|
|
667
|
+
|
|
668
|
+
# Aggregate test results from master and workers
|
|
669
|
+
report_rows = aggregate_test_results(config)
|
|
670
|
+
|
|
671
|
+
# # Print results summary to console
|
|
672
|
+
# print_results_summary(report_rows)
|
|
673
|
+
|
|
674
|
+
# Create summary object matching template expectations
|
|
675
|
+
report_summary = create_report_summary(report_rows, start_time)
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
generate_report(report_rows, report_summary, start_time)
|
|
679
|
+
except Exception as e:
|
|
680
|
+
logger.error(f"Failed to generate HTML report: {e}", exc_info=True)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ============================================================================
|
|
684
|
+
# Pytest Fixtures (provided by plugin for all consuming projects)
|
|
685
|
+
# ============================================================================
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@pytest.fixture(scope="function")
|
|
689
|
+
def row(request):
|
|
690
|
+
"""
|
|
691
|
+
Fixture to provide parametrized test data row.
|
|
692
|
+
|
|
693
|
+
SCOPE: Function-scoped (created/destroyed for each test)
|
|
694
|
+
|
|
695
|
+
Usage:
|
|
696
|
+
======
|
|
697
|
+
Parametrize tests with CSV/Excel data:
|
|
698
|
+
|
|
699
|
+
@pytest.mark.datafile("TestData.csv")
|
|
700
|
+
def test_user_login(row, driver, wait):
|
|
701
|
+
'''Test login with parametrized data.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
row: Dict containing one row from TestData.csv
|
|
705
|
+
driver: Selenium WebDriver instance
|
|
706
|
+
wait: WebDriverWait with configured timeout
|
|
707
|
+
'''
|
|
708
|
+
username = row['Username']
|
|
709
|
+
password = row['Password']
|
|
710
|
+
# ... test code ...
|
|
711
|
+
|
|
712
|
+
Supported Markers:
|
|
713
|
+
- @pytest.mark.datafile("filename.csv"): Load parametrized data from CSV
|
|
714
|
+
- @pytest.mark.datafile("filename.xlsx"): Load parametrized data from Excel
|
|
715
|
+
|
|
716
|
+
CSV/Excel Location:
|
|
717
|
+
- Files must be in: data/ directory (sibling to tests/ directory)
|
|
718
|
+
- Example: data/TestData.csv → loaded for tests in tests/test_Template.py
|
|
719
|
+
|
|
720
|
+
Encoding Support:
|
|
721
|
+
- CSV: utf-8-sig, latin-1, utf-8 (tries all with fallback)
|
|
722
|
+
- Excel: .xlsx files via openpyxl library
|
|
723
|
+
|
|
724
|
+
Row Data:
|
|
725
|
+
- Each row is converted to a dict with column headers as keys
|
|
726
|
+
- Empty cells are converted to empty strings (not NaN or None)
|
|
727
|
+
- All values are strings (numeric values must be converted in test)
|
|
728
|
+
|
|
729
|
+
Request Parameter:
|
|
730
|
+
- Provided automatically by pytest
|
|
731
|
+
- request.param contains the parametrized value (the row dict)
|
|
732
|
+
"""
|
|
733
|
+
return request.param
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@pytest.fixture(scope="function")
|
|
737
|
+
def driver(request):
|
|
738
|
+
"""
|
|
739
|
+
Fixture that provides a Chrome WebDriver instance with a unique profile.
|
|
740
|
+
|
|
741
|
+
SCOPE: Function-scoped (created/destroyed for each test)
|
|
742
|
+
|
|
743
|
+
Automatically handles:
|
|
744
|
+
- Creating unique browser profile (isolated from other tests)
|
|
745
|
+
- Setting headless mode based on HEADLESS environment variable
|
|
746
|
+
- Cleanup and profile directory removal on test completion
|
|
747
|
+
|
|
748
|
+
Environment Variables:
|
|
749
|
+
- HEADLESS (default: "N")
|
|
750
|
+
- "Y" = Run browser in headless mode (no GUI)
|
|
751
|
+
- "N" = Run browser with visible window
|
|
752
|
+
- Useful for CI/CD environments vs. local debugging
|
|
753
|
+
|
|
754
|
+
Browser Configuration:
|
|
755
|
+
- --user-data-dir: Unique temporary profile directory per test
|
|
756
|
+
- --no-sandbox: Required for some environments
|
|
757
|
+
- --disable-dev-shm-usage: Prevents shared memory issues
|
|
758
|
+
- --headless=new: Modern headless implementation (if HEADLESS="Y")
|
|
759
|
+
|
|
760
|
+
Profile Isolation:
|
|
761
|
+
- Each test gets its own temporary profile directory
|
|
762
|
+
- Profile directory is automatically cleaned up after test
|
|
763
|
+
- Prevents cache/cookie contamination between tests
|
|
764
|
+
|
|
765
|
+
Cleanup:
|
|
766
|
+
- Automatically called via pytest finalizer
|
|
767
|
+
- Calls driver.quit() to close browser
|
|
768
|
+
- Removes temporary profile directory recursively
|
|
769
|
+
|
|
770
|
+
Usage:
|
|
771
|
+
======
|
|
772
|
+
def test_login(driver, wait):
|
|
773
|
+
'''Test with Selenium WebDriver.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
driver: Chrome WebDriver instance
|
|
777
|
+
wait: WebDriverWait instance (see wait fixture)
|
|
778
|
+
'''
|
|
779
|
+
driver.get("https://example.com/login")
|
|
780
|
+
wait.until(expected_conditions.presence_of_element_located((By.ID, "username")))
|
|
781
|
+
# ... test code ...
|
|
782
|
+
|
|
783
|
+
Browser Information:
|
|
784
|
+
- profile_name: Get profile name via profile_name_from_driver(driver)
|
|
785
|
+
- capabilities: driver.capabilities contains browser details
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
request: pytest request fixture (provided by pytest)
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
Selenium WebDriver instance for Chrome browser
|
|
792
|
+
"""
|
|
793
|
+
# Create a temporary directory for the unique profile
|
|
794
|
+
profile_dir = tempfile.mkdtemp(prefix="chrome_profile_")
|
|
795
|
+
profile_name = os.path.basename(profile_dir)
|
|
796
|
+
|
|
797
|
+
chrome_options = Options()
|
|
798
|
+
chrome_options.add_argument(f"--user-data-dir={profile_dir}")
|
|
799
|
+
chrome_options.add_argument("--no-sandbox")
|
|
800
|
+
chrome_options.add_argument("--disable-dev-shm-usage")
|
|
801
|
+
|
|
802
|
+
# Check HEADLESS environment variable (Y = headless, N = visible)
|
|
803
|
+
headless = get_env("HEADLESS", "N")
|
|
804
|
+
if headless.upper() == "Y":
|
|
805
|
+
chrome_options.add_argument("--headless=new")
|
|
806
|
+
|
|
807
|
+
driver = webdriver.Chrome(options=chrome_options)
|
|
808
|
+
|
|
809
|
+
# Register a finalizer to always clean up driver and profile directory
|
|
810
|
+
def finalizer():
|
|
811
|
+
try:
|
|
812
|
+
driver.quit()
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
try:
|
|
816
|
+
shutil.rmtree(profile_dir, ignore_errors=True)
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
request.addfinalizer(finalizer)
|
|
821
|
+
|
|
822
|
+
yield driver
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@pytest.fixture()
|
|
826
|
+
def wait(driver):
|
|
827
|
+
"""
|
|
828
|
+
Function-scoped WebDriverWait fixture.
|
|
829
|
+
|
|
830
|
+
SCOPE: Function-scoped (created/destroyed for each test)
|
|
831
|
+
|
|
832
|
+
Purpose:
|
|
833
|
+
- Provides WebDriverWait instance for implicit waits in tests
|
|
834
|
+
- Configured with timeout from WAIT_TIME environment variable
|
|
835
|
+
- Default timeout is 15 seconds if not configured
|
|
836
|
+
|
|
837
|
+
Environment Variables:
|
|
838
|
+
- WAIT_TIME (default: "15")
|
|
839
|
+
- Integer number of seconds to wait for elements
|
|
840
|
+
- Used by Selenium's expected_conditions in tests
|
|
841
|
+
|
|
842
|
+
Usage:
|
|
843
|
+
======
|
|
844
|
+
def test_find_element(driver, wait):
|
|
845
|
+
'''Test with WebDriverWait for element visibility.'''
|
|
846
|
+
from selenium.webdriver.common.by import By
|
|
847
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
848
|
+
|
|
849
|
+
element = wait.until(
|
|
850
|
+
EC.presence_of_element_located((By.ID, "submit_button"))
|
|
851
|
+
)
|
|
852
|
+
element.click()
|
|
853
|
+
|
|
854
|
+
Common Expected Conditions:
|
|
855
|
+
- EC.presence_of_element_located((By, locator)): Element in DOM
|
|
856
|
+
- EC.visibility_of_element_located((By, locator)): Element visible
|
|
857
|
+
- EC.element_to_be_clickable((By, locator)): Element clickable
|
|
858
|
+
- EC.text_to_be_present_in_element((By, locator), text): Text present
|
|
859
|
+
|
|
860
|
+
Args:
|
|
861
|
+
driver: Selenium WebDriver instance (provided by driver fixture)
|
|
862
|
+
|
|
863
|
+
Returns:
|
|
864
|
+
WebDriverWait instance with configured timeout
|
|
865
|
+
"""
|
|
866
|
+
timeout = int(get_env("WAIT_TIME", "15"))
|
|
867
|
+
return WebDriverWait(driver, timeout)
|