Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions cvs/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@

@pytest.hookimpl(tryfirst=True)
def pytest_configure(config):
if config.args:
suite_name = Path(config.args[0]).stem
else:
suite_name = "test"
suite_name = "test"
for arg in config.args:
bare = arg.split("::")[0]
if not bare.startswith("-") and bare.endswith(".py"):
suite_name = Path(bare).stem
break
config._suite_name = suite_name
config._test_html_dir = f"{suite_name}_html"
config._html_report_manager = HtmlReportManager(config)

Expand Down Expand Up @@ -64,16 +67,15 @@ def pytest_metadata(metadata):
except Exception as e:
cvs_version = f"Unknown (Error: {e})"

# Get command line arguments directly from sys.argv
# Parse command line arguments to get our custom options (just for display)
cluster_file = "Not specified"
config_file = "Not specified"

# Parse command line arguments to get our custom options
for i, arg in enumerate(sys.argv):
if arg == "--cluster_file" and i + 1 < len(sys.argv):
cluster_file = sys.argv[i + 1]
cluster_file = Path(sys.argv[i + 1]).name # Just filename for display
elif arg == "--config_file" and i + 1 < len(sys.argv):
config_file = sys.argv[i + 1]
config_file = Path(sys.argv[i + 1]).name # Just filename for display

# Add custom metadata
metadata["CVS version"] = cvs_version
Expand Down Expand Up @@ -107,7 +109,7 @@ def pytest_html_results_table_html(report, data):
HtmlReportManager.replace_table_html(report, data)


# Inject CSS overrides to simplify/hide unused report UI controls.
# Inject CSS overrides in Summary section.
def pytest_html_results_summary(prefix, summary, postfix):
HtmlReportManager.inject_style_overrides(prefix)

Expand Down
5 changes: 3 additions & 2 deletions cvs/lib/inference/inference_max.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ class InferenceMaxJob(InferenceBaseJob):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.if_dict.setdefault('inferencemax_repo', 'https://github.com/InferenceMAX/InferenceMAX.git')
self.if_dict.setdefault('inferencemax_repo', 'https://github.com/SemiAnalysisAI/InferenceX.git')
self.inferencemax_commit_id = 'c5bbe050ccef019615db0ceb4dd983cc2faa6335'

def get_server_script_directory(self):
"""InferenceMAX scripts are in the cloned repo."""
Expand All @@ -42,7 +43,7 @@ def get_log_subdir(self):

def clone_inferencemax_repo(self):
"""Clone InferenceMAX repository."""
cmd = f'''docker exec {self.container_name} /bin/bash -c "git clone {self.if_dict['inferencemax_repo']}" '''
cmd = f'''docker exec {self.container_name} /bin/bash -c "git clone {self.if_dict['inferencemax_repo']} && cd InferenceX && git checkout {self.inferencemax_commit_id}" '''
out_dict = self.s_phdl.exec(cmd)
for node in out_dict.keys():
if re.search('error|fail', out_dict[node], re.I):
Expand Down
268 changes: 262 additions & 6 deletions cvs/lib/report_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
'''

import datetime
import re
import shutil
import sys
import zipfile
from pathlib import Path
import uuid
Expand All @@ -32,6 +34,11 @@ def __init__(self, config):
self._config = config
self._htmlpath = getattr(config.option, "htmlpath", None)
self._test_html_dir = getattr(config, "_test_html_dir", "test_html")
self._custom_test_reports = [] # Track reports added via add_html_to_report
self._config_files = {} # Track copied config files {original_path: relative_path}

# Store reference for access from pytest hooks
HtmlReportManager._current_instance = self

@property
def is_enabled(self):
Expand Down Expand Up @@ -117,6 +124,220 @@ def write_test_log(self, report, test_name=None):

return extras

def add_html_to_report(self, html_file, link_name=None, request=None):
"""Copy an external HTML file to the report directory for inclusion in ZIP bundle.

Args:
html_file (str or Path): Path to the HTML file to include in the report
link_name (str, optional): If provided, adds a clickable link in the pytest-html report
request (pytest request fixture, optional): Required if link_name is provided

Returns:
str: Relative path to the copied file (for linking), or None if copying failed
"""
if not self.is_enabled:
log.info("Skipping HTML file copy because HTML reporting is disabled.")
return None

try:
source_path = Path(html_file)
if not source_path.exists():
log.warning("HTML file not found, cannot add to report: %s", source_path)
return None

# Ensure log directory exists
self.log_dir.mkdir(parents=True, exist_ok=True)

# Copy file to log directory with same name
dest_path = self.log_dir / source_path.name
shutil.copy2(source_path, dest_path)

log.info("Added HTML file to report bundle: %s -> %s", source_path, dest_path)

# Return relative path for potential linking
rel_path = dest_path.relative_to(self.htmlpath.parent)
rel_path_str = str(rel_path)

# Track the added report
report_info = {
'name': link_name or source_path.name,
'path': rel_path_str,
'original_path': str(source_path),
}
self._custom_test_reports.append(report_info)

# Add clickable link to pytest-html report if requested
if link_name and request:
try:
extra = pytest_html.extras.url(rel_path_str, name=link_name)
request.node.user_properties.append(("pytest_html_extra", extra))
log.info("Added pytest-html link: %s -> %s", link_name, rel_path_str)
except Exception as e:
log.error("Failed to add pytest-html link: %s - %s", link_name, e)

return rel_path_str

except Exception as e:
log.error("Failed to add HTML file to report: %s - %s", html_file, e)
return None

def copy_config_files_to_bundle(self, cluster_file_path, config_file_path):
"""Copy cluster and config files to the report bundle directory.

Args:
cluster_file_path (str): Path to cluster file
config_file_path (str): Path to config file

Returns:
dict: Mapping of file types to relative paths in bundle
"""
if not self.is_enabled:
return {}

copied_files = {}

try:
# Ensure log directory exists
self.log_dir.mkdir(parents=True, exist_ok=True)

# Copy cluster file
if cluster_file_path and cluster_file_path != "Not specified":
cluster_path = Path(cluster_file_path)
if cluster_path.exists():
dest_cluster = self.log_dir / f"cluster_{cluster_path.name}"
shutil.copy2(cluster_path, dest_cluster)
rel_cluster = dest_cluster.relative_to(self.htmlpath.parent)
copied_files['cluster'] = str(rel_cluster)
self._config_files[cluster_file_path] = str(rel_cluster)
log.info("Copied cluster file to bundle: %s -> %s", cluster_path, dest_cluster)
else:
log.warning("Cluster file not found: %s", cluster_path)

# Copy config file
if config_file_path and config_file_path != "Not specified":
config_path = Path(config_file_path)
if config_path.exists():
dest_config = self.log_dir / f"config_{config_path.name}"
shutil.copy2(config_path, dest_config)
rel_config = dest_config.relative_to(self.htmlpath.parent)
copied_files['config'] = str(rel_config)
self._config_files[config_file_path] = str(rel_config)
log.info("Copied config file to bundle: %s -> %s", config_path, dest_config)
else:
log.warning("Config file not found: %s", config_path)

except Exception as e:
log.error("Failed to copy config files to bundle: %s", e)

return copied_files

def copy_config_files_from_args(self):
"""Copy config files from command line arguments to bundle."""

# Parse command line arguments to get file paths
cluster_file_path = None
config_file_path = None

for i, arg in enumerate(sys.argv):
if arg == "--cluster_file" and i + 1 < len(sys.argv):
cluster_file_path = str(Path(sys.argv[i + 1]).resolve())
elif arg == "--config_file" and i + 1 < len(sys.argv):
config_file_path = str(Path(sys.argv[i + 1]).resolve())

# Copy files to bundle if we found them
if cluster_file_path or config_file_path:
return self.copy_config_files_to_bundle(cluster_file_path, config_file_path)

return {}

def inject_reports_section_into_html(self, htmlpath):
"""Inject Reports section and update Environment table with config file links."""
try:
# Read the HTML file
with open(htmlpath, 'r', encoding='utf-8') as f:
html_content = f.read()

# Update Environment table with clickable config file links
html_content = self._update_environment_config_links(html_content)

# Generate Reports section (test reports only)
reports_html = self.generate_reports_section()

# Inject Reports section if we have test reports
if reports_html:
# Find the Environment table and inject Reports section after it
env_pos = html_content.find('<table id="environment">')
if env_pos != -1:
# Find the closing </table> after the environment table
table_end_pos = html_content.find('</table>', env_pos)
if table_end_pos != -1:
# Insert Reports section after the environment table
insertion_pos = table_end_pos + len('</table>')

# Add some spacing and the Reports section
reports_section = f'\n {reports_html}\n'

html_content = html_content[:insertion_pos] + reports_section + html_content[insertion_pos:]

log.info("Injected Reports section between Environment and Summary")
else:
log.warning("Could not find Environment table end in HTML report")
else:
log.warning("Could not find Environment table in HTML report")

# Write back the modified HTML
with open(htmlpath, 'w', encoding='utf-8') as f:
f.write(html_content)

log.info("Updated Environment table with config file links")

except Exception as e:
log.error("Failed to inject Reports section: %s", e)

def _update_environment_config_links(self, html_content):
"""Update the JSON data to make config files clickable in Environment table."""
import json

if not self._config_files:
return html_content

# Find the JSON data container
json_pattern = r'data-jsonblob="([^"]*)"'
match = re.search(json_pattern, html_content)

if not match:
log.warning("Could not find JSON data in HTML report")
return html_content

# Decode the HTML-encoded JSON
import html

json_str = html.unescape(match.group(1))

try:
data = json.loads(json_str)

# Update environment data with clickable links for config files
for original_path, relative_path in self._config_files.items():
filename = Path(original_path).name
if "cluster" in filename.lower():
# Replace plain filename with HTML link
data["environment"]["Cluster File"] = f'<a href="{relative_path}" target="_blank">{filename}</a>'
elif "config" in filename.lower():
# Replace plain filename with HTML link
data["environment"]["Config File"] = f'<a href="{relative_path}" target="_blank">{filename}</a>'

# Re-encode the JSON and update the HTML
updated_json = json.dumps(data)
encoded_json = html.escape(updated_json, quote=True)

html_content = re.sub(json_pattern, f'data-jsonblob="{encoded_json}"', html_content)

except json.JSONDecodeError as e:
log.error("Failed to parse JSON data: %s", e)

return html_content

@staticmethod
def replace_table_html(report, data):
"""Replace inline log content with a message pointing to the external log file."""
Expand All @@ -126,6 +347,22 @@ def replace_table_html(report, data):
else:
data.append("<div class='empty log'>Log externalized (see link above).</div>")

def generate_reports_section(self):
"""Generate HTML for the Reports section showing added HTML reports only."""
if not self._custom_test_reports:
return ""

# Simple Reports section - only test reports, no config files
html = '<div><h2>Reports</h2><ul>'

# Add test reports only
for report in self._custom_test_reports:
html += f'<li><a href="{report["path"]}" target="_blank">{report["name"]}</a></li>'

html += '</ul></div>'

return html

@staticmethod
def inject_style_overrides(prefix):
"""Inject CSS to hide show/hide details UI elements."""
Expand All @@ -137,21 +374,22 @@ def create_zip_bundle(self, session):
log.info("Skipping zip bundle creation because HTML reporting is disabled.")
return

# Copy config files before creating ZIP (tracking handled internally)
self.copy_config_files_from_args()

# Resolve path after session completes; report file is expected to exist by now.
htmlpath = Path(self._htmlpath).resolve()
if not htmlpath.is_file():
log.info("Skipping zip bundle creation because HTML report was not found: %s", htmlpath)
return

# Inject Reports section into main HTML report
self.inject_reports_section_into_html(htmlpath)

report_dir = htmlpath.parent
timestamp = datetime.datetime.now().strftime("%Y-%m-%dT%H%M%S")

invocation_params = getattr(session.config, "invocation_params", None)
if invocation_params and hasattr(invocation_params, "args") and invocation_params.args:
# Prefer suite/test target name for archive readability.
suite_name_part = Path(invocation_params.args[0]).stem
else:
suite_name_part = htmlpath.stem
suite_name_part = getattr(session.config, "_suite_name", htmlpath.stem)

zip_path = report_dir / f"{suite_name_part}_{timestamp}.zip"
log_dir = report_dir / self._test_html_dir
Expand All @@ -162,6 +400,24 @@ def create_zip_bundle(self, session):
# Main summary report at zip root.
zf.write(htmlpath, htmlpath.name)

# Include assets directory if pytest-html created it (for CSS, etc.)
# This happens when --self-contained-html=false (the default)
assets_dir = report_dir / "assets"
if assets_dir.is_dir():
# Check if self-contained mode is disabled (default behavior)
self_contained = getattr(session.config.option, 'self_contained_html', False)
if not self_contained:
log.info("Including assets directory in ZIP bundle (external CSS mode)")
for filepath in sorted(assets_dir.iterdir()):
if filepath.is_file():
zf.write(filepath, Path("assets") / filepath.name)
files_added += 1
log.info("Added asset to ZIP: %s", filepath.name)
else:
log.info("Skipping assets directory (self-contained HTML mode enabled)")
else:
log.info("No assets directory found (likely self-contained HTML mode)")

if log_dir.is_dir():
for filepath in sorted(log_dir.iterdir()):
if filepath.is_file():
Expand Down
Loading
Loading