hitl_tester.test_cases.cyber_6t.conftest

Pytest hooks for modifying tests as they run. Mainly used for modifying the HTML report.

(c) 2020-2024 TurnAround Factor, Inc.

#

CUI DISTRIBUTION CONTROL

Controlled by: DLA J68 R&D SBIP

CUI Category: Small Business Research and Technology

Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS

POC: GOV SBIP Program Manager Denise Price, 571-767-0111

Distribution authorized to U.S. Government Agencies only, to protect information not owned by the

U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that

it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests

for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317,

Fort Belvoir, VA 22060-6221

#

SBIR DATA RIGHTS

Contract No.:SP4701-23-C-0083

Contractor Name: TurnAround Factor, Inc.

Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005

Expiration of SBIR Data Rights Period: September 24, 2029

The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer

software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights

in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause

contained in the above identified contract. No restrictions apply after the expiration date shown above. Any

reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce

the markings.

  1"""
  2Pytest hooks for modifying tests as they run. Mainly used for modifying the HTML report.
  3
  4# (c) 2020-2024 TurnAround Factor, Inc.
  5#
  6# CUI DISTRIBUTION CONTROL
  7# Controlled by: DLA J68 R&D SBIP
  8# CUI Category: Small Business Research and Technology
  9# Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS
 10# POC: GOV SBIP Program Manager Denise Price, 571-767-0111
 11# Distribution authorized to U.S. Government Agencies only, to protect information not owned by the
 12# U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that
 13# it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests
 14# for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317,
 15# Fort Belvoir, VA 22060-6221
 16#
 17# SBIR DATA RIGHTS
 18# Contract No.:SP4701-23-C-0083
 19# Contractor Name: TurnAround Factor, Inc.
 20# Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005
 21# Expiration of SBIR Data Rights Period: September 24, 2029
 22# The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer
 23# software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights
 24# in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause
 25# contained in the above identified contract. No restrictions apply after the expiration date shown above. Any
 26# reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce
 27# the markings.
 28"""
 29
 30import html
 31import platform
 32import re
 33import signal
 34import sys
 35import time
 36from contextlib import suppress
 37from dataclasses import dataclass, field
 38from datetime import datetime, timedelta, timezone
 39from multiprocessing.managers import BaseManager
 40from pathlib import Path
 41
 42import pytest
 43from pytest_metadata.plugin import metadata_key
 44from hitl_tester.modules.cyber_6t.gpio import GPIOController
 45from hitl_tester.modules.cyber_6t.lcd_manager import PORT, AUTH_KEY
 46from hitl_tester.modules.cyber_6t.relay import RelayController
 47
 48from hitl_tester.modules import properties
 49from hitl_tester.modules.html_merger import Duration
 50from hitl_tester.modules.logger import logger
 51
 52PROFILE = ""
 53
 54properties.apply()
 55
 56
 57class SafeShutdown:
 58    """Manage shutting down safely."""
 59
 60    SIGNALS = [signal.SIGTSTP, signal.SIGQUIT, signal.SIGABRT, signal.SIGHUP, signal.SIGINT, signal.SIGTERM]
 61
 62    @staticmethod
 63    def handler(_signo, _stack_frame):
 64        """
 65        This handler will be called during normal / abnormal termination.
 66        i.e. ssh connection drops out. In this event, we're going to
 67        ensure that the test setup is transitioned to a safe state.
 68        """
 69        # pylint: disable=comparison-with-callable  # We want to check handler function
 70        # Failsafe: CTRL-Z sends kill
 71        signal.signal(signal.SIGTSTP, lambda _signo, _stack_frame: signal.raise_signal(signal.SIGKILL))
 72        if _signo:
 73            for signal_no in signal.valid_signals():  # Ignore all future signals if already shutting down
 74                if signal.getsignal(signal_no) == SafeShutdown.handler and signal_no != signal.SIGINT:
 75                    logger.write_info_to_report(f"Ignoring {signal.Signals(signal_no).name}")
 76                    signal.signal(signal_no, signal.SIG_IGN)
 77            logger.write_critical_to_report(f"Caught {signal.Signals(_signo).name}, Powering off HITL.")
 78        pytest.exit("HITL exiting test suite early", 1)  # Exit gracefully
 79
 80
 81class QueueManager(BaseManager):
 82    """Manager for multiprocess queue."""
 83
 84
 85@dataclass
 86class CaseStatus:
 87    """Holds information about the current test case."""
 88
 89    passed_tests: int = 0
 90    failed_tests: int = 0
 91    total_tests: int = 0
 92    completed_tests: int = 0
 93    test_ete: dict[str, int] = field(default_factory=dict)
 94    module_name: str = "Profile"
 95    mode: str = "Profile"
 96
 97
 98def get_test_name(item):
 99    """Make a unique name from an item."""
100    return f"{item.parent.name} - {item.originalname}"
101
102
103@pytest.hookimpl(trylast=True)
104def pytest_configure(config):
105    """Add information to the beginning of the test html report."""
106    config.addinivalue_line("markers", "profile: this test is used for generating a profile")
107
108    terminal_command = html.escape(" ".join(sys.argv))
109    hitl = html.escape(platform.node())
110    test_plan = html.escape(pytest.flags.test_plan.stem)
111    log_folder = html.escape(str(pytest.flags.report_filename.parent))
112    hardware_config = html.escape(pytest.flags.plateset_id)
113    properties_dict = {html.escape(name): html.escape(str(value)) for name, value in pytest.flags.properties.items()}
114
115    config.stash[metadata_key]["Terminal Command"] = f"<pre><code>{terminal_command}</code></pre>"
116    config.stash[metadata_key]["HITL"] = hitl
117    config.stash[metadata_key]["Test Plan"] = test_plan
118    config.stash[metadata_key]["Hardware Config"] = hardware_config
119    config.stash[metadata_key]["Properties"] = properties_dict
120    config.stash[metadata_key]["Logs"] = log_folder
121    config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = "Calculating..."
122
123    # Remove some rows
124    config.stash[metadata_key].pop("Plugins", None)
125    config.stash[metadata_key].pop("Packages", None)
126
127    # Rename some environment keys
128    config.stash[metadata_key]["Device Name"] = config.stash[metadata_key]["HITL"]
129    config.stash[metadata_key].pop("HITL", None)
130    config.stash[metadata_key]["Test Program"] = config.stash[metadata_key]["Test Plan"]
131    config.stash[metadata_key].pop("Test Plan", None)
132
133    # Move some environment keys to a details section
134    advanced_keys = ["Python", "Platform", "Terminal Command", "Hardware Config", "Properties"]
135    advanced_content = [f"{key}: {config.stash[metadata_key][key]}" for key in advanced_keys]
136    config.stash[metadata_key][
137        "Advanced Information"
138    ] = f"<details><summary>Expand</summary>{'<br>'.join(advanced_content)}</details>"
139
140    # Remove some rows
141    for key in advanced_keys:
142        config.stash[metadata_key].pop(key, None)
143
144
145def pytest_sessionstart(session):
146    """Setup variables."""
147    session.case_status = CaseStatus()
148    QueueManager.register("get_queue")
149    manager = QueueManager(address=("", PORT), authkey=AUTH_KEY)
150    session.queue = None
151    with suppress(OSError):
152        logger.write_info_to_report("Attempting to connect to LCD manager process...")
153        manager.connect()
154        session.queue = manager.get_queue()  # pylint: disable=no-member
155
156    for signal_flag in SafeShutdown.SIGNALS:
157        signal.signal(signal_flag, SafeShutdown.handler)
158
159    # Initialize relay and gpio for battery #1
160    RelayController.on(0)
161    GPIOController.battery_1.dormant_1 = False
162    GPIOController.battery_1.dormant_2 = False
163    GPIOController.battery_1.position_id = 0b1110
164    GPIOController.set_battery_state()
165    time.sleep(10)
166
167
168def pytest_collection_finish(session):
169    """Calculate the runtime for each test from their docstrings."""
170    session.case_status.total_tests = len(session.items)
171    total_estimated_runtime = timedelta()
172    for test in session.items:
173        time_match = (
174            re.search(
175                r"""
176            \|\s*Estimated\sDuration\s*\|\s*                 # Only look at the duration row
177            (\d+)                                            # Capture digits
178            \s+                                              # Space
179            (seconds* | minutes* | hours* | days* | weeks*)  # Capture unit of time (optional "s")
180            """,
181                test.function.__doc__,
182                re.IGNORECASE | re.VERBOSE,
183            )
184            if test.function.__doc__
185            else None
186        )
187        if time_match is not None:
188            unit_name = f"{time_match.group(2).rstrip('s')}s"
189            estimated_runtime = timedelta(**{unit_name: int(time_match.group(1))})
190            total_estimated_runtime += estimated_runtime
191            session.case_status.test_ete[get_test_name(test)] = estimated_runtime
192        else:
193            session.case_status.test_ete[get_test_name(test)] = timedelta()
194    session.config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = str(Duration(total_estimated_runtime))
195
196
197def pytest_runtest_setup(item):
198    """Send test status."""
199    name = item.parent.name.removeprefix("Test")
200    ete = sum(item.session.case_status.test_ete.values(), timedelta()).total_seconds() / 60
201    if item.parent.parent.name != "fingerprint.py":
202        item.session.case_status.mode = "PenTest"
203    else:
204        item.session.case_status.mode = "Profile" if not PROFILE else "Compare"
205    if item.session.queue:
206        item.session.queue.put(
207            {
208                "name": name,
209                "ete": ete,
210                "passed": item.session.case_status.passed_tests,
211                "failed": item.session.case_status.failed_tests,
212                "progress": item.session.case_status.completed_tests / item.session.case_status.total_tests,
213                "mode": item.session.case_status.mode,
214            }
215        )
216
217
218@pytest.hookimpl(tryfirst=True, hookwrapper=True)
219def pytest_runtest_makereport(item):
220    """Generate the report for one test."""
221    outcome = yield
222    report = outcome.get_result()
223    report.result_text = item.module.logger.html_result_text if hasattr(item.module, "logger") else ""
224    description_match = (
225        re.search(
226            r"""
227    \|\s*Description\s*\|\s*  # Description row
228    (.*?)                     # Capture description text
229    \s*\|                     # End of row
230    """,
231            item.function.__doc__,
232            re.IGNORECASE | re.VERBOSE,
233        )
234        if item.function.__doc__
235        else None
236    )
237    report.description = description_match.group(1) if description_match is not None else ""
238
239    # Modify pass/fail based on results
240    if (report.when == "setup" and report.skipped is True) or report.when == "call":
241        item.session.case_status.passed_tests += report.passed + report.skipped
242        item.session.case_status.failed_tests += report.failed
243        item.session.case_status.completed_tests += 1
244        item.session.case_status.test_ete.pop(get_test_name(item), None)
245
246
247def pytest_sessionfinish(session):
248    """Send final report status."""
249    if session.queue:
250        session.queue.put(
251            {
252                "name": "Test Completed",
253                "ete": 0.0,
254                "passed": session.case_status.passed_tests,
255                "failed": session.case_status.failed_tests,
256                "progress": 1.0,
257                "mode": session.case_status.mode,
258            }
259        )
260
261
262def pytest_html_results_table_row(report, cells):
263    """Generate the row for a test in the html report."""
264    del cells[:]
265
266    # Generate URL
267    test_case, line_number, test_name = report.location
268    test_name = test_name.partition("[")[0]  # Remove fixture information
269    test_case_html_path = str(Path(test_case).with_suffix(".html"))
270    base_link = html.escape(f"/docs/{test_case_html_path}#{test_name}")
271
272    # Generate result
273    pass_fail = report.outcome.title()
274    result = "<br>".join(report.result_text)
275    duration = timedelta(seconds=report.duration)
276    start_time = (datetime.now(timezone.utc) - duration).strftime("%Y/%m/%d %I:%M:%S %p")
277    safe_test_name = html.escape(test_name)
278    safe_test_case = html.escape(test_case)
279    cells.extend(
280        [
281            f'<td class="col-result">{pass_fail}</td>',
282            f"<td>{result}</td>",
283            f"<td>{report.description}</td>",
284            f'<td class="col-time">{start_time}</td>',
285            f'<td class="col-duration">{duration}</td>',
286            f'<td><a target="_blank" style="color:#337788;" href="{base_link}">{safe_test_name}</a> '
287            f"(File {safe_test_case}, line {line_number})</td>",
288        ]
289    )
290
291
292def pytest_html_results_table_header(cells):
293    """Generate html report headers."""
294    del cells[:]
295    cells.extend(
296        [
297            '<th class="sortable" data-column-type="result">Pass / Fail</th>',
298            "<th>Result</th>",
299            "<th>Description</th>",
300            '<th class="sortable time" data-column-type="time">Start Time</th>',
301            '<th class="sortable" data-column-type="duration">Duration (h:mm:ss.u)</th>',
302            '<th class="sortable" data-column-type="testId">Test / Docs</th>',
303        ]
304    )
305
306
307def pytest_html_report_title(report):
308    """Change the html report title."""
309    report.title = "HITL Report"
PROFILE = ''
class SafeShutdown:
58class SafeShutdown:
59    """Manage shutting down safely."""
60
61    SIGNALS = [signal.SIGTSTP, signal.SIGQUIT, signal.SIGABRT, signal.SIGHUP, signal.SIGINT, signal.SIGTERM]
62
63    @staticmethod
64    def handler(_signo, _stack_frame):
65        """
66        This handler will be called during normal / abnormal termination.
67        i.e. ssh connection drops out. In this event, we're going to
68        ensure that the test setup is transitioned to a safe state.
69        """
70        # pylint: disable=comparison-with-callable  # We want to check handler function
71        # Failsafe: CTRL-Z sends kill
72        signal.signal(signal.SIGTSTP, lambda _signo, _stack_frame: signal.raise_signal(signal.SIGKILL))
73        if _signo:
74            for signal_no in signal.valid_signals():  # Ignore all future signals if already shutting down
75                if signal.getsignal(signal_no) == SafeShutdown.handler and signal_no != signal.SIGINT:
76                    logger.write_info_to_report(f"Ignoring {signal.Signals(signal_no).name}")
77                    signal.signal(signal_no, signal.SIG_IGN)
78            logger.write_critical_to_report(f"Caught {signal.Signals(_signo).name}, Powering off HITL.")
79        pytest.exit("HITL exiting test suite early", 1)  # Exit gracefully

Manage shutting down safely.

SIGNALS = [<Signals.SIGTSTP: 20>, <Signals.SIGQUIT: 3>, <Signals.SIGABRT: 6>, <Signals.SIGHUP: 1>, <Signals.SIGINT: 2>, <Signals.SIGTERM: 15>]
@staticmethod
def handler(_signo, _stack_frame):
63    @staticmethod
64    def handler(_signo, _stack_frame):
65        """
66        This handler will be called during normal / abnormal termination.
67        i.e. ssh connection drops out. In this event, we're going to
68        ensure that the test setup is transitioned to a safe state.
69        """
70        # pylint: disable=comparison-with-callable  # We want to check handler function
71        # Failsafe: CTRL-Z sends kill
72        signal.signal(signal.SIGTSTP, lambda _signo, _stack_frame: signal.raise_signal(signal.SIGKILL))
73        if _signo:
74            for signal_no in signal.valid_signals():  # Ignore all future signals if already shutting down
75                if signal.getsignal(signal_no) == SafeShutdown.handler and signal_no != signal.SIGINT:
76                    logger.write_info_to_report(f"Ignoring {signal.Signals(signal_no).name}")
77                    signal.signal(signal_no, signal.SIG_IGN)
78            logger.write_critical_to_report(f"Caught {signal.Signals(_signo).name}, Powering off HITL.")
79        pytest.exit("HITL exiting test suite early", 1)  # Exit gracefully

This handler will be called during normal / abnormal termination. i.e. ssh connection drops out. In this event, we're going to ensure that the test setup is transitioned to a safe state.

class QueueManager(multiprocessing.managers.BaseManager):
82class QueueManager(BaseManager):
83    """Manager for multiprocess queue."""

Manager for multiprocess queue.

Inherited Members
multiprocessing.managers.BaseManager
BaseManager
get_server
connect
start
join
address
register
@dataclass
class CaseStatus:
86@dataclass
87class CaseStatus:
88    """Holds information about the current test case."""
89
90    passed_tests: int = 0
91    failed_tests: int = 0
92    total_tests: int = 0
93    completed_tests: int = 0
94    test_ete: dict[str, int] = field(default_factory=dict)
95    module_name: str = "Profile"
96    mode: str = "Profile"

Holds information about the current test case.

CaseStatus( passed_tests: int = 0, failed_tests: int = 0, total_tests: int = 0, completed_tests: int = 0, test_ete: dict[str, int] = <factory>, module_name: str = 'Profile', mode: str = 'Profile')
passed_tests: int = 0
failed_tests: int = 0
total_tests: int = 0
completed_tests: int = 0
test_ete: dict[str, int]
module_name: str = 'Profile'
mode: str = 'Profile'
def get_test_name(item):
 99def get_test_name(item):
100    """Make a unique name from an item."""
101    return f"{item.parent.name} - {item.originalname}"

Make a unique name from an item.

@pytest.hookimpl(trylast=True)
def pytest_configure(config):
104@pytest.hookimpl(trylast=True)
105def pytest_configure(config):
106    """Add information to the beginning of the test html report."""
107    config.addinivalue_line("markers", "profile: this test is used for generating a profile")
108
109    terminal_command = html.escape(" ".join(sys.argv))
110    hitl = html.escape(platform.node())
111    test_plan = html.escape(pytest.flags.test_plan.stem)
112    log_folder = html.escape(str(pytest.flags.report_filename.parent))
113    hardware_config = html.escape(pytest.flags.plateset_id)
114    properties_dict = {html.escape(name): html.escape(str(value)) for name, value in pytest.flags.properties.items()}
115
116    config.stash[metadata_key]["Terminal Command"] = f"<pre><code>{terminal_command}</code></pre>"
117    config.stash[metadata_key]["HITL"] = hitl
118    config.stash[metadata_key]["Test Plan"] = test_plan
119    config.stash[metadata_key]["Hardware Config"] = hardware_config
120    config.stash[metadata_key]["Properties"] = properties_dict
121    config.stash[metadata_key]["Logs"] = log_folder
122    config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = "Calculating..."
123
124    # Remove some rows
125    config.stash[metadata_key].pop("Plugins", None)
126    config.stash[metadata_key].pop("Packages", None)
127
128    # Rename some environment keys
129    config.stash[metadata_key]["Device Name"] = config.stash[metadata_key]["HITL"]
130    config.stash[metadata_key].pop("HITL", None)
131    config.stash[metadata_key]["Test Program"] = config.stash[metadata_key]["Test Plan"]
132    config.stash[metadata_key].pop("Test Plan", None)
133
134    # Move some environment keys to a details section
135    advanced_keys = ["Python", "Platform", "Terminal Command", "Hardware Config", "Properties"]
136    advanced_content = [f"{key}: {config.stash[metadata_key][key]}" for key in advanced_keys]
137    config.stash[metadata_key][
138        "Advanced Information"
139    ] = f"<details><summary>Expand</summary>{'<br>'.join(advanced_content)}</details>"
140
141    # Remove some rows
142    for key in advanced_keys:
143        config.stash[metadata_key].pop(key, None)

Add information to the beginning of the test html report.

def pytest_sessionstart(session):
146def pytest_sessionstart(session):
147    """Setup variables."""
148    session.case_status = CaseStatus()
149    QueueManager.register("get_queue")
150    manager = QueueManager(address=("", PORT), authkey=AUTH_KEY)
151    session.queue = None
152    with suppress(OSError):
153        logger.write_info_to_report("Attempting to connect to LCD manager process...")
154        manager.connect()
155        session.queue = manager.get_queue()  # pylint: disable=no-member
156
157    for signal_flag in SafeShutdown.SIGNALS:
158        signal.signal(signal_flag, SafeShutdown.handler)
159
160    # Initialize relay and gpio for battery #1
161    RelayController.on(0)
162    GPIOController.battery_1.dormant_1 = False
163    GPIOController.battery_1.dormant_2 = False
164    GPIOController.battery_1.position_id = 0b1110
165    GPIOController.set_battery_state()
166    time.sleep(10)

Setup variables.

def pytest_collection_finish(session):
169def pytest_collection_finish(session):
170    """Calculate the runtime for each test from their docstrings."""
171    session.case_status.total_tests = len(session.items)
172    total_estimated_runtime = timedelta()
173    for test in session.items:
174        time_match = (
175            re.search(
176                r"""
177            \|\s*Estimated\sDuration\s*\|\s*                 # Only look at the duration row
178            (\d+)                                            # Capture digits
179            \s+                                              # Space
180            (seconds* | minutes* | hours* | days* | weeks*)  # Capture unit of time (optional "s")
181            """,
182                test.function.__doc__,
183                re.IGNORECASE | re.VERBOSE,
184            )
185            if test.function.__doc__
186            else None
187        )
188        if time_match is not None:
189            unit_name = f"{time_match.group(2).rstrip('s')}s"
190            estimated_runtime = timedelta(**{unit_name: int(time_match.group(1))})
191            total_estimated_runtime += estimated_runtime
192            session.case_status.test_ete[get_test_name(test)] = estimated_runtime
193        else:
194            session.case_status.test_ete[get_test_name(test)] = timedelta()
195    session.config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = str(Duration(total_estimated_runtime))

Calculate the runtime for each test from their docstrings.

def pytest_runtest_setup(item):
198def pytest_runtest_setup(item):
199    """Send test status."""
200    name = item.parent.name.removeprefix("Test")
201    ete = sum(item.session.case_status.test_ete.values(), timedelta()).total_seconds() / 60
202    if item.parent.parent.name != "fingerprint.py":
203        item.session.case_status.mode = "PenTest"
204    else:
205        item.session.case_status.mode = "Profile" if not PROFILE else "Compare"
206    if item.session.queue:
207        item.session.queue.put(
208            {
209                "name": name,
210                "ete": ete,
211                "passed": item.session.case_status.passed_tests,
212                "failed": item.session.case_status.failed_tests,
213                "progress": item.session.case_status.completed_tests / item.session.case_status.total_tests,
214                "mode": item.session.case_status.mode,
215            }
216        )

Send test status.

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item):
219@pytest.hookimpl(tryfirst=True, hookwrapper=True)
220def pytest_runtest_makereport(item):
221    """Generate the report for one test."""
222    outcome = yield
223    report = outcome.get_result()
224    report.result_text = item.module.logger.html_result_text if hasattr(item.module, "logger") else ""
225    description_match = (
226        re.search(
227            r"""
228    \|\s*Description\s*\|\s*  # Description row
229    (.*?)                     # Capture description text
230    \s*\|                     # End of row
231    """,
232            item.function.__doc__,
233            re.IGNORECASE | re.VERBOSE,
234        )
235        if item.function.__doc__
236        else None
237    )
238    report.description = description_match.group(1) if description_match is not None else ""
239
240    # Modify pass/fail based on results
241    if (report.when == "setup" and report.skipped is True) or report.when == "call":
242        item.session.case_status.passed_tests += report.passed + report.skipped
243        item.session.case_status.failed_tests += report.failed
244        item.session.case_status.completed_tests += 1
245        item.session.case_status.test_ete.pop(get_test_name(item), None)

Generate the report for one test.

def pytest_sessionfinish(session):
248def pytest_sessionfinish(session):
249    """Send final report status."""
250    if session.queue:
251        session.queue.put(
252            {
253                "name": "Test Completed",
254                "ete": 0.0,
255                "passed": session.case_status.passed_tests,
256                "failed": session.case_status.failed_tests,
257                "progress": 1.0,
258                "mode": session.case_status.mode,
259            }
260        )

Send final report status.

def pytest_html_results_table_row(report, cells):
263def pytest_html_results_table_row(report, cells):
264    """Generate the row for a test in the html report."""
265    del cells[:]
266
267    # Generate URL
268    test_case, line_number, test_name = report.location
269    test_name = test_name.partition("[")[0]  # Remove fixture information
270    test_case_html_path = str(Path(test_case).with_suffix(".html"))
271    base_link = html.escape(f"/docs/{test_case_html_path}#{test_name}")
272
273    # Generate result
274    pass_fail = report.outcome.title()
275    result = "<br>".join(report.result_text)
276    duration = timedelta(seconds=report.duration)
277    start_time = (datetime.now(timezone.utc) - duration).strftime("%Y/%m/%d %I:%M:%S %p")
278    safe_test_name = html.escape(test_name)
279    safe_test_case = html.escape(test_case)
280    cells.extend(
281        [
282            f'<td class="col-result">{pass_fail}</td>',
283            f"<td>{result}</td>",
284            f"<td>{report.description}</td>",
285            f'<td class="col-time">{start_time}</td>',
286            f'<td class="col-duration">{duration}</td>',
287            f'<td><a target="_blank" style="color:#337788;" href="{base_link}">{safe_test_name}</a> '
288            f"(File {safe_test_case}, line {line_number})</td>",
289        ]
290    )

Generate the row for a test in the html report.

def pytest_html_results_table_header(cells):
293def pytest_html_results_table_header(cells):
294    """Generate html report headers."""
295    del cells[:]
296    cells.extend(
297        [
298            '<th class="sortable" data-column-type="result">Pass / Fail</th>',
299            "<th>Result</th>",
300            "<th>Description</th>",
301            '<th class="sortable time" data-column-type="time">Start Time</th>',
302            '<th class="sortable" data-column-type="duration">Duration (h:mm:ss.u)</th>',
303            '<th class="sortable" data-column-type="testId">Test / Docs</th>',
304        ]
305    )

Generate html report headers.

def pytest_html_report_title(report):
308def pytest_html_report_title(report):
309    """Change the html report title."""
310    report.title = "HITL Report"

Change the html report title.