diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index 99c5edab4..a3ddf520e 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -3153,12 +3153,19 @@ class Flow(Generic[T], metaclass=FlowMeta): else: logger.warning(message) - def plot(self, filename: str = "crewai_flow.html", show: bool = True) -> str: + def plot( + self, + filename: str = "crewai_flow.html", + show: bool = True, + output_dir: str | None = None, + ) -> str: """Create interactive HTML visualization of Flow structure. Args: filename: Output HTML filename (default: "crewai_flow.html"). show: Whether to open in browser (default: True). + output_dir: Directory to save generated files. Defaults to the + current working directory. Returns: Absolute path to generated HTML file. @@ -3171,7 +3178,9 @@ class Flow(Generic[T], metaclass=FlowMeta): ), ) structure = build_flow_structure(self) - return render_interactive(structure, filename=filename, show=show) + return render_interactive( + structure, filename=filename, show=show, output_dir=output_dir + ) @staticmethod def _show_tracing_disabled_message() -> None: diff --git a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py index 88242bea6..5ef4e5f43 100644 --- a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py +++ b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py @@ -2,7 +2,6 @@ import json from pathlib import Path -import tempfile from typing import Any, ClassVar import webbrowser @@ -205,20 +204,24 @@ def render_interactive( dag: FlowStructure, filename: str = "flow_dag.html", show: bool = True, + output_dir: str | None = None, ) -> str: """Create interactive HTML visualization of Flow structure. - Generates three output files in a temporary directory: HTML template, - CSS stylesheet, and JavaScript. Optionally opens the visualization in - default browser. + Generates three output files: HTML template, CSS stylesheet, and + JavaScript. Files are saved to the specified output directory, or the + current working directory when *output_dir* is ``None``. Optionally + opens the visualization in the default browser. Args: dag: FlowStructure to visualize. filename: Output HTML filename (basename only, no path). show: Whether to open in browser. + output_dir: Directory to save generated files. Defaults to the + current working directory (``os.getcwd()``). Returns: - Absolute path to generated HTML file in temporary directory. + Absolute path to generated HTML file. """ node_positions = calculate_node_positions(dag) @@ -403,12 +406,13 @@ def render_interactive( extensions=[CSSExtension, JSExtension], ) - temp_dir = Path(tempfile.mkdtemp(prefix="crewai_flow_")) - output_path = temp_dir / Path(filename).name + dest_dir = Path(output_dir) if output_dir else Path.cwd() + dest_dir.mkdir(parents=True, exist_ok=True) + output_path = dest_dir / Path(filename).name css_filename = output_path.stem + "_style.css" - css_output_path = temp_dir / css_filename + css_output_path = dest_dir / css_filename js_filename = output_path.stem + "_script.js" - js_output_path = temp_dir / js_filename + js_output_path = dest_dir / js_filename css_file = template_dir / "style.css" css_content = css_file.read_text(encoding="utf-8") diff --git a/lib/crewai/tests/test_flow_visualization.py b/lib/crewai/tests/test_flow_visualization.py index d55e98bac..764f4a2e2 100644 --- a/lib/crewai/tests/test_flow_visualization.py +++ b/lib/crewai/tests/test_flow_visualization.py @@ -333,9 +333,9 @@ def test_visualization_plot_method(): """Test that flow.plot() method works.""" flow = SimpleFlow() - html_file = flow.plot("test_plot.html", show=False) - - assert os.path.exists(html_file) + with tempfile.TemporaryDirectory() as tmp_dir: + html_file = flow.plot("test_plot.html", show=False, output_dir=tmp_dir) + assert os.path.exists(html_file) def test_router_paths_to_string_conditions(): @@ -667,4 +667,94 @@ def test_no_warning_for_properly_typed_router(caplog): # No warnings should be logged warning_messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING] assert not any("Could not determine return paths" in msg for msg in warning_messages) - assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages) \ No newline at end of file + assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages) + + +def test_plot_saves_to_current_working_directory(): + """Test that plot() saves the HTML file to the current working directory by default. + + Regression test for https://github.com/crewAIInc/crewAI/issues/4991 + """ + flow = SimpleFlow() + + with tempfile.TemporaryDirectory() as tmp_dir: + original_cwd = os.getcwd() + try: + os.chdir(tmp_dir) + html_file = flow.plot("test_cwd_plot.html", show=False) + + # The returned path must live inside the CWD, not a hidden temp dir + assert Path(html_file).parent == Path(tmp_dir) + assert os.path.exists(html_file) + assert html_file == str(Path(tmp_dir) / "test_cwd_plot.html") + finally: + os.chdir(original_cwd) + + +def test_plot_saves_to_explicit_output_dir(): + """Test that plot() saves files to a user-specified output directory.""" + flow = SimpleFlow() + + with tempfile.TemporaryDirectory() as output_dir: + html_file = flow.plot( + "custom_output.html", show=False, output_dir=output_dir + ) + + assert Path(html_file).parent == Path(output_dir) + assert os.path.exists(html_file) + + # CSS and JS companion files should also be in the same directory + html_path = Path(html_file) + css_file = html_path.parent / f"{html_path.stem}_style.css" + js_file = html_path.parent / f"{html_path.stem}_script.js" + assert css_file.exists() + assert js_file.exists() + + +def test_render_interactive_saves_to_cwd_by_default(): + """Test that render_interactive() writes to CWD when output_dir is None. + + Regression test for https://github.com/crewAIInc/crewAI/issues/4991 + """ + flow = SimpleFlow() + structure = build_flow_structure(flow) + + with tempfile.TemporaryDirectory() as tmp_dir: + original_cwd = os.getcwd() + try: + os.chdir(tmp_dir) + html_file = visualize_flow_structure( + structure, "cwd_test.html", show=False + ) + + assert Path(html_file).parent == Path(tmp_dir) + assert os.path.exists(html_file) + finally: + os.chdir(original_cwd) + + +def test_render_interactive_saves_to_specified_output_dir(): + """Test that render_interactive() writes to the specified output_dir.""" + flow = SimpleFlow() + structure = build_flow_structure(flow) + + with tempfile.TemporaryDirectory() as output_dir: + html_file = visualize_flow_structure( + structure, "output_dir_test.html", show=False, output_dir=output_dir + ) + + assert Path(html_file).parent == Path(output_dir) + assert os.path.exists(html_file) + + with open(html_file, "r", encoding="utf-8") as f: + html_content = f.read() + assert "" in html_content + + +def test_plot_returned_path_is_absolute(): + """Test that the path returned by plot() is always absolute.""" + flow = SimpleFlow() + + with tempfile.TemporaryDirectory() as tmp_dir: + html_file = flow.plot("abs_path_test.html", show=False, output_dir=tmp_dir) + assert os.path.isabs(html_file)