diff --git a/cvs/conftest.py b/cvs/conftest.py
index 2fcabfd2..e4694582 100644
--- a/cvs/conftest.py
+++ b/cvs/conftest.py
@@ -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)
@@ -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
@@ -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)
diff --git a/cvs/lib/inference/inference_max.py b/cvs/lib/inference/inference_max.py
index 61f91844..39a86d75 100644
--- a/cvs/lib/inference/inference_max.py
+++ b/cvs/lib/inference/inference_max.py
@@ -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."""
@@ -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):
diff --git a/cvs/lib/report_plugins.py b/cvs/lib/report_plugins.py
index e014fddc..5f6d2910 100644
--- a/cvs/lib/report_plugins.py
+++ b/cvs/lib/report_plugins.py
@@ -6,7 +6,9 @@
'''
import datetime
+import re
import shutil
+import sys
import zipfile
from pathlib import Path
import uuid
@@ -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):
@@ -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('
')
+ if env_pos != -1:
+ # Find the closing
after the environment table
+ table_end_pos = html_content.find('', env_pos)
+ if table_end_pos != -1:
+ # Insert Reports section after the environment table
+ insertion_pos = table_end_pos + len('')
+
+ # 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'{filename}'
+ elif "config" in filename.lower():
+ # Replace plain filename with HTML link
+ data["environment"]["Config File"] = f'{filename}'
+
+ # 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."""
@@ -126,6 +347,22 @@ def replace_table_html(report, data):
else:
data.append("Log externalized (see link above).
")
+ 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 = 'Reports
'
+
+ # Add test reports only
+ for report in self._custom_test_reports:
+ html += f'- {report["name"]}
'
+
+ html += '
'
+
+ return html
+
@staticmethod
def inject_style_overrides(prefix):
"""Inject CSS to hide show/hide details UI elements."""
@@ -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
@@ -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():
diff --git a/cvs/tests/rccl/rccl_heatmap_cvs.py b/cvs/tests/rccl/rccl_heatmap_cvs.py
index e772de4b..a467b980 100644
--- a/cvs/tests/rccl/rccl_heatmap_cvs.py
+++ b/cvs/tests/rccl/rccl_heatmap_cvs.py
@@ -445,7 +445,7 @@ def test_rccl_perf(cluster_dict, config_dict, rccl_collective, gpu_count, data_t
update_test_result()
-def test_gen_graph():
+def test_gen_graph(request):
print('Final Global result dict')
print(rccl_res_dict)
rccl_graph_dict = rccl_lib.convert_to_graph_dict(rccl_res_dict)
@@ -462,10 +462,21 @@ def test_gen_graph():
html_lib.build_rccl_result_default_table(html_file, rccl_graph_dict)
html_lib.add_json_data(html_file, json.dumps(rccl_graph_dict))
html_lib.add_html_end(html_file)
- print(f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view')
+
+ # Add the HTML file to the report bundle with clickable link
+ copied_path = request.config._html_report_manager.add_html_to_report(
+ html_file, link_name="RCCL Heatmap Performance Report", request=request
+ )
+
+ if copied_path:
+ print(f'Perf report saved and added to report bundle: {copied_path}')
+ else:
+ print(
+ f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view'
+ )
-def test_gen_heatmap(phdl, cluster_dict, config_dict):
+def test_gen_heatmap(request, phdl, cluster_dict, config_dict):
print('Generate Heatmap')
current_datetime = datetime.now()
time_stamp = current_datetime.strftime("%Y-%m-%d-%H-%M-%S")
@@ -534,6 +545,14 @@ def test_gen_heatmap(phdl, cluster_dict, config_dict):
html_lib.build_rccl_heatmap_table(heatmap_file, 'Heatmap data Table', rccl_res_json_file, rccl_ref_json_file)
html_lib.add_html_end(heatmap_file)
+ # Add the heatmap HTML file to the report bundle with clickable link
+ copied_path = request.config._html_report_manager.add_html_to_report(
+ heatmap_file, link_name="RCCL Heatmap Visualization", request=request
+ )
+
+ if copied_path:
+ print(f'Heatmap report saved and added to report bundle: {copied_path}')
+
# Get management/login node IP from cluster config
mgmt_node = cluster_dict.get('head_node_dict', {}).get('mgmt_ip', None)
diff --git a/cvs/tests/rccl/rccl_multinode_cvs.py b/cvs/tests/rccl/rccl_multinode_cvs.py
index 07c23a8f..571003f3 100644
--- a/cvs/tests/rccl/rccl_multinode_cvs.py
+++ b/cvs/tests/rccl/rccl_multinode_cvs.py
@@ -435,7 +435,7 @@ def test_rccl_perf(
update_test_result()
-def test_gen_graph():
+def test_gen_graph(request):
print('Final Global result dict')
print(rccl_res_dict)
rccl_graph_dict = rccl_lib.convert_to_graph_dict(rccl_res_dict)
@@ -452,4 +452,14 @@ def test_gen_graph():
html_lib.add_json_data(html_file, json.dumps(rccl_graph_dict))
html_lib.add_html_end(html_file)
- print(f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view')
+ # Add the HTML file to the report bundle with clickable link
+ copied_path = request.config._html_report_manager.add_html_to_report(
+ html_file, link_name="RCCL Multi Node Performance Report", request=request
+ )
+
+ if copied_path:
+ print(f'Perf report saved and added to report bundle: {copied_path}')
+ else:
+ print(
+ f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view'
+ )
diff --git a/cvs/tests/rccl/rccl_multinode_default_cvs.py b/cvs/tests/rccl/rccl_multinode_default_cvs.py
index 46bdc852..3d2b6461 100644
--- a/cvs/tests/rccl/rccl_multinode_default_cvs.py
+++ b/cvs/tests/rccl/rccl_multinode_default_cvs.py
@@ -359,7 +359,7 @@ def test_rccl_perf(phdl, shdl, cluster_dict, config_dict, rccl_collective):
update_test_result()
-def test_gen_graph():
+def test_gen_graph(request):
print('Final Global result dict')
print(rccl_res_dict)
rccl_graph_dict = rccl_lib.convert_to_graph_dict(rccl_res_dict)
@@ -376,4 +376,14 @@ def test_gen_graph():
html_lib.add_json_data(html_file, json.dumps(rccl_graph_dict))
html_lib.add_html_end(html_file)
- print(f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view')
+ # Add the HTML file to the report bundle with clickable link
+ copied_path = request.config._html_report_manager.add_html_to_report(
+ html_file, link_name="RCCL Performance Report", request=request
+ )
+
+ if copied_path:
+ print(f'Perf report saved and added to report bundle: {copied_path}')
+ else:
+ print(
+ f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view'
+ )
diff --git a/cvs/tests/rccl/rccl_singlenode_cvs.py b/cvs/tests/rccl/rccl_singlenode_cvs.py
index 9e25e411..ab785947 100644
--- a/cvs/tests/rccl/rccl_singlenode_cvs.py
+++ b/cvs/tests/rccl/rccl_singlenode_cvs.py
@@ -312,7 +312,7 @@ def test_singlenode_perf(phdl, cluster_dict, config_dict, rccl_collective):
update_test_result()
-def test_gen_graph():
+def test_gen_graph(request):
print('Final Global result dict')
print(rccl_res_dict)
rccl_graph_dict = rccl_lib.convert_to_graph_dict(rccl_res_dict)
@@ -329,4 +329,14 @@ def test_gen_graph():
html_lib.add_json_data(html_file, json.dumps(rccl_graph_dict))
html_lib.add_html_end(html_file)
- print(f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view')
+ # Add the HTML file to the report bundle with clickable link
+ copied_path = request.config._html_report_manager.add_html_to_report(
+ html_file, link_name="RCCL Single Node Performance Report", request=request
+ )
+
+ if copied_path:
+ print(f'Perf report saved and added to report bundle: {copied_path}')
+ else:
+ print(
+ f'Perf report is saved under {html_file}, pls copy it to your web server under /var/www/html folder to view'
+ )