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

' + + 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' + )