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.
Files changed (26) hide show
  1. robo_automation_test_kit/__init__.py +8 -0
  2. robo_automation_test_kit/hookspec.py +36 -0
  3. robo_automation_test_kit/plugin.py +867 -0
  4. robo_automation_test_kit/templates/email_report/email_template.html +0 -0
  5. robo_automation_test_kit/templates/html_report/components/category-chart.html +148 -0
  6. robo_automation_test_kit/templates/html_report/components/center-chart.html +97 -0
  7. robo_automation_test_kit/templates/html_report/components/phase-chart.html +148 -0
  8. robo_automation_test_kit/templates/html_report/components/results-table.html +240 -0
  9. robo_automation_test_kit/templates/html_report/components/status-center-chart.html +148 -0
  10. robo_automation_test_kit/templates/html_report/components/summary-chart.html +94 -0
  11. robo_automation_test_kit/templates/html_report/html_template.html +62 -0
  12. robo_automation_test_kit/templates/html_report/scripts/css/material-icons.css +20 -0
  13. robo_automation_test_kit/templates/html_report/scripts/css/report.css +714 -0
  14. robo_automation_test_kit/templates/html_report/scripts/css/robo-fonts.css +24 -0
  15. robo_automation_test_kit/templates/html_report/scripts/js/chart.js +14 -0
  16. robo_automation_test_kit/templates/html_report/scripts/js/report.js +319 -0
  17. robo_automation_test_kit/utils/RoboHelper.py +420 -0
  18. robo_automation_test_kit/utils/__init__.py +19 -0
  19. robo_automation_test_kit/utils/reports/EmailReportUtils.py +0 -0
  20. robo_automation_test_kit/utils/reports/HtmlReportUtils.py +154 -0
  21. robo_automation_test_kit/utils/reports/__init__.py +3 -0
  22. robo_automation_test_kit-1.0.0.dist-info/METADATA +132 -0
  23. robo_automation_test_kit-1.0.0.dist-info/RECORD +26 -0
  24. robo_automation_test_kit-1.0.0.dist-info/WHEEL +4 -0
  25. robo_automation_test_kit-1.0.0.dist-info/entry_points.txt +3 -0
  26. 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)