From fb5165abde0c2639856451552e512f7509d46a79 Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 28 May 2026 03:36:23 +0800 Subject: [PATCH 1/5] added crash.py and updated gitignore --- .gitignore | 23 ++++++++++++ cli/localci/cli/main.py | 64 +++++++++++++++++++++++++++++++-- cli/localci/utils/crash.py | 35 ++++++++++++++++++ cli/tests/test_cli.py | 72 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 cli/localci/utils/crash.py diff --git a/.gitignore b/.gitignore index 3ac6eb3..a5abef1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,26 @@ # image *.tar images/capy/dist/ + +# data +data/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +**/.___* +**/._* +.Spotlight-V100 +.Trashes +.fseventsd +.TemporaryItems +.AppleDB +.AppleDesktop +.apdisk +Icon? +.com.apple.timemachine.donotpresent +.VolumeIcon.icns +.cursor + diff --git a/cli/localci/cli/main.py b/cli/localci/cli/main.py index 3f59d82..1635025 100644 --- a/cli/localci/cli/main.py +++ b/cli/localci/cli/main.py @@ -8,7 +8,8 @@ from __future__ import annotations import logging -import sys +from pathlib import Path +from typing import Any import click @@ -20,14 +21,64 @@ ConfigIOError, ConfigValidationError, ) -from localci.utils.output import configure_console, console, print_error +from localci.utils.crash import log_crash +from localci.utils.output import configure_console, print_error + +# --------------------------------------------------------------------------- +# Catch-all exception handling +# --------------------------------------------------------------------------- + + +class CatchAllGroup(click.Group): + """Click group that catches unhandled exceptions at the CLI entry point.""" + + def invoke(self, ctx: click.Context) -> Any: + try: + return super().invoke(ctx) + except (click.exceptions.Exit, click.ClickException): + raise + except Exception as exc: + if _is_debug(ctx): + raise + path = log_crash(exc) + _print_unhandled_error(exc, path) + ctx.exit(2) + + +def _is_debug(ctx: click.Context) -> bool: + """Return True when ``--debug`` was passed on the CLI.""" + obj = ctx.obj + if obj is not None and obj.get("debug"): + return True + return bool(ctx.params.get("debug")) + + +def _print_unhandled_error(exc: BaseException, log_path: Path) -> None: + """Print a user-friendly message for an unhandled internal error.""" + display_path = str(log_path.expanduser()) + messages = [ + "An unexpected internal error occurred.", + f"{type(exc).__name__}: {exc}", + f"Please file a bug report and attach {display_path} " + "(contains the full traceback).", + ] + try: + for msg in messages: + print_error(msg) + except Exception: + for msg in messages: + click.echo(msg, err=True) + # --------------------------------------------------------------------------- # Root CLI group # --------------------------------------------------------------------------- -@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.group( + cls=CatchAllGroup, + context_settings={"help_option_names": ["-h", "--help"]}, +) @click.option( "--config", "-c", @@ -39,6 +90,11 @@ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose/debug output.") @click.option("--quiet", "-q", is_flag=True, help="Suppress non-essential output.") @click.option("--no-color", is_flag=True, help="Disable coloured output.") +@click.option( + "--debug", + is_flag=True, + help="Show full tracebacks instead of friendly errors.", +) @click.version_option(version=__version__, prog_name="localci") @click.pass_context def cli( @@ -47,9 +103,11 @@ def cli( verbose: bool, quiet: bool, no_color: bool, + debug: bool, ) -> None: """Local CI - Run GitHub Actions workflows locally.""" ctx.ensure_object(dict) + ctx.obj["debug"] = debug # Configure Rich console early so all downstream output respects flags. configure_console(no_color=no_color, quiet=quiet) diff --git a/cli/localci/utils/crash.py b/cli/localci/utils/crash.py new file mode 100644 index 0000000..a386fba --- /dev/null +++ b/cli/localci/utils/crash.py @@ -0,0 +1,35 @@ +"""Crash log utilities for unhandled CLI exceptions.""" + +from __future__ import annotations + +import platform +import sys +import traceback +from datetime import datetime, timezone +from pathlib import Path + +from localci import __version__ +from localci.utils.paths import localci_home + +CRASH_LOG_NAME = "crash.log" + + +def crash_log_path() -> Path: + """Return the path to the crash log file (``~/.localci/crash.log``).""" + return localci_home() / CRASH_LOG_NAME + + +def log_crash(exc: BaseException) -> Path: + """Write a full crash report for *exc* and return the log file path.""" + path = crash_log_path() + lines = [ + f"timestamp: {datetime.now(timezone.utc).isoformat()}", + f"localci_version: {__version__}", + f"python_version: {sys.version}", + f"platform: {platform.platform()}", + "", + "traceback:", + *traceback.format_exception(type(exc), exc, exc.__traceback__), + ] + path.write_text("".join(lines), encoding="utf-8") + return path diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 032e3c5..de9f69e 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -8,10 +8,13 @@ import json from pathlib import Path +from unittest.mock import patch +import pytest from click.testing import CliRunner from localci.cli.main import cli +from localci.utils.crash import CRASH_LOG_NAME runner = CliRunner() @@ -298,3 +301,72 @@ def test_config_set_and_get(self, tmp_path, monkeypatch): result = runner.invoke(cli, ["-c", str(tmp_path / ".localci.yml"), "config", "get", "parallel.max_jobs"]) assert result.exit_code == 0 assert "16" in result.output + + +# --------------------------------------------------------------------------- +# Catch-all exception handler (w4_issue_01) +# --------------------------------------------------------------------------- + + +class TestCatchAllHandler: + """Unhandled exceptions produce friendly output and crash.log diagnostics.""" + + @staticmethod + def _workflow_file(tmp_path: Path) -> Path: + wf = tmp_path / "ci.yml" + wf.write_text("name: CI\n") + return wf + + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_unhandled_exception_friendly_output(self, mock_analyze, tmp_path): + mock_analyze.side_effect = RuntimeError("test boom") + wf = self._workflow_file(tmp_path) + result = runner.invoke( + cli, + ["analyze", str(wf)], + env={"HOME": str(tmp_path)}, + ) + assert result.exit_code == 2 + assert "Traceback" not in result.output + assert "unexpected internal error" in result.output.lower() + assert "RuntimeError" in result.output + assert "test boom" in result.output + assert "bug report" in result.output.lower() + assert "crash.log" in result.output + + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_unhandled_exception_writes_crash_log(self, mock_analyze, tmp_path): + mock_analyze.side_effect = RuntimeError("test boom") + wf = self._workflow_file(tmp_path) + runner.invoke( + cli, + ["analyze", str(wf)], + env={"HOME": str(tmp_path)}, + ) + log_path = tmp_path / ".localci" / CRASH_LOG_NAME + assert log_path.is_file() + content = log_path.read_text(encoding="utf-8") + assert "RuntimeError" in content + assert "test boom" in content + assert "traceback:" in content.lower() + assert "localci_version:" in content + + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_debug_flag_reraises_exception(self, mock_analyze, tmp_path): + mock_analyze.side_effect = RuntimeError("test boom") + wf = self._workflow_file(tmp_path) + with pytest.raises(RuntimeError, match="test boom"): + runner.invoke( + cli, + ["--debug", "analyze", str(wf)], + env={"HOME": str(tmp_path)}, + catch_exceptions=False, + ) + + def test_config_error_exit_code_unchanged(self, tmp_path): + result = runner.invoke( + cli, + ["--config", str(tmp_path / "missing.yml"), "list"], + env={"HOME": str(tmp_path)}, + ) + assert result.exit_code == 1 From 99560b170821824235c14f5898ffbcbd601f5094 Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 28 May 2026 04:06:42 +0800 Subject: [PATCH 2/5] fixed test error --- cli/localci/cli/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/localci/cli/main.py b/cli/localci/cli/main.py index 1635025..91aa2da 100644 --- a/cli/localci/cli/main.py +++ b/cli/localci/cli/main.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -from pathlib import Path from typing import Any import click @@ -21,7 +20,7 @@ ConfigIOError, ConfigValidationError, ) -from localci.utils.crash import log_crash +from localci.utils.crash import CRASH_LOG_NAME, log_crash from localci.utils.output import configure_console, print_error # --------------------------------------------------------------------------- @@ -40,8 +39,8 @@ def invoke(self, ctx: click.Context) -> Any: except Exception as exc: if _is_debug(ctx): raise - path = log_crash(exc) - _print_unhandled_error(exc, path) + log_crash(exc) + _print_unhandled_error(exc) ctx.exit(2) @@ -53,9 +52,10 @@ def _is_debug(ctx: click.Context) -> bool: return bool(ctx.params.get("debug")) -def _print_unhandled_error(exc: BaseException, log_path: Path) -> None: +def _print_unhandled_error(exc: BaseException) -> None: """Print a user-friendly message for an unhandled internal error.""" - display_path = str(log_path.expanduser()) + # Stable short path for display (avoids terminal wrap on long temp dirs in CI). + display_path = f"~/.localci/{CRASH_LOG_NAME}" messages = [ "An unexpected internal error occurred.", f"{type(exc).__name__}: {exc}", From d6154e0ca3e8f1f47c1b1a6932897e22dc714bde Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 28 May 2026 04:11:12 +0800 Subject: [PATCH 3/5] addressed AI reviews --- cli/localci/utils/crash.py | 12 ++++++------ cli/tests/test_cli.py | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/cli/localci/utils/crash.py b/cli/localci/utils/crash.py index a386fba..6fd6b37 100644 --- a/cli/localci/utils/crash.py +++ b/cli/localci/utils/crash.py @@ -23,12 +23,12 @@ def log_crash(exc: BaseException) -> Path: """Write a full crash report for *exc* and return the log file path.""" path = crash_log_path() lines = [ - f"timestamp: {datetime.now(timezone.utc).isoformat()}", - f"localci_version: {__version__}", - f"python_version: {sys.version}", - f"platform: {platform.platform()}", - "", - "traceback:", + f"timestamp: {datetime.now(timezone.utc).isoformat()}\n", + f"localci_version: {__version__}\n", + f"python_version: {sys.version}\n", + f"platform: {platform.platform()}\n", + "\n", + "traceback:\n", *traceback.format_exception(type(exc), exc, exc.__traceback__), ] path.write_text("".join(lines), encoding="utf-8") diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index de9f69e..76d7ae7 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -90,7 +90,9 @@ def test_basic_invocation(self): assert result.exit_code == 0 def test_platform_filter(self): - result = runner.invoke(cli, ["list", "--workflow", SAMPLE_CI, "--platform", "linux"]) + result = runner.invoke( + cli, ["list", "--workflow", SAMPLE_CI, "--platform", "linux"] + ) assert result.exit_code == 0 def test_no_workflow_errors(self): @@ -138,9 +140,7 @@ def test_help(self): assert "--dry-run" in result.output def test_dry_run(self): - result = runner.invoke( - cli, ["run", "--workflow", SAMPLE_WORKFLOW, "--dry-run"] - ) + result = runner.invoke(cli, ["run", "--workflow", SAMPLE_WORKFLOW, "--dry-run"]) assert result.exit_code == 0 assert "Dry run" in result.output or "dry" in result.output.lower() @@ -295,10 +295,29 @@ def test_config_set_and_get(self, tmp_path, monkeypatch): # Init a config first. runner.invoke(cli, ["config", "init"]) # Set a value. - result = runner.invoke(cli, ["-c", str(tmp_path / ".localci.yml"), "config", "set", "parallel.max_jobs", "16"]) + result = runner.invoke( + cli, + [ + "-c", + str(tmp_path / ".localci.yml"), + "config", + "set", + "parallel.max_jobs", + "16", + ], + ) assert result.exit_code == 0 # Verify. - result = runner.invoke(cli, ["-c", str(tmp_path / ".localci.yml"), "config", "get", "parallel.max_jobs"]) + result = runner.invoke( + cli, + [ + "-c", + str(tmp_path / ".localci.yml"), + "config", + "get", + "parallel.max_jobs", + ], + ) assert result.exit_code == 0 assert "16" in result.output @@ -332,7 +351,12 @@ def test_unhandled_exception_friendly_output(self, mock_analyze, tmp_path): assert "RuntimeError" in result.output assert "test boom" in result.output assert "bug report" in result.output.lower() - assert "crash.log" in result.output + # Check for crash log path, resilient to text wrapping + output_no_newlines = result.output.replace("\n", " ") + assert ( + CRASH_LOG_NAME in output_no_newlines + or f".localci/{CRASH_LOG_NAME}" in output_no_newlines + ) @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") def test_unhandled_exception_writes_crash_log(self, mock_analyze, tmp_path): From 09b78e724bda20684f3fa74c3110ab7d9ad4bde5 Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 28 May 2026 23:34:45 +0800 Subject: [PATCH 4/5] addressed reviewer's intentons --- cli/localci/cli/main.py | 35 ++++++++---- cli/localci/utils/crash.py | 50 ++++++++++++----- cli/tests/test_cli.py | 111 ++++++++++++++++++++++++++++++------- 3 files changed, 152 insertions(+), 44 deletions(-) diff --git a/cli/localci/cli/main.py b/cli/localci/cli/main.py index 91aa2da..1ff7c27 100644 --- a/cli/localci/cli/main.py +++ b/cli/localci/cli/main.py @@ -19,8 +19,9 @@ ConfigFileNotFoundError, ConfigIOError, ConfigValidationError, + LocalCIError, ) -from localci.utils.crash import CRASH_LOG_NAME, log_crash +from localci.utils.crash import crash_log_display_path, log_crash from localci.utils.output import configure_console, print_error # --------------------------------------------------------------------------- @@ -36,31 +37,41 @@ def invoke(self, ctx: click.Context) -> Any: return super().invoke(ctx) except (click.exceptions.Exit, click.ClickException): raise + except LocalCIError as exc: + if _is_debug(ctx): + raise + print_error(str(exc)) + ctx.exit(1) except Exception as exc: if _is_debug(ctx): raise - log_crash(exc) - _print_unhandled_error(exc) + log_path = log_crash(exc) + _print_unhandled_error(exc, log_path is not None) ctx.exit(2) def _is_debug(ctx: click.Context) -> bool: """Return True when ``--debug`` was passed on the CLI.""" obj = ctx.obj - if obj is not None and obj.get("debug"): - return True - return bool(ctx.params.get("debug")) + return bool(obj is not None and obj.get("debug")) -def _print_unhandled_error(exc: BaseException) -> None: +def _print_unhandled_error(exc: BaseException, crash_log_written: bool) -> None: """Print a user-friendly message for an unhandled internal error.""" - # Stable short path for display (avoids terminal wrap on long temp dirs in CI). - display_path = f"~/.localci/{CRASH_LOG_NAME}" + if crash_log_written: + attach_hint = ( + f"Please file a bug report and attach {crash_log_display_path()} " + "(contains the full traceback)." + ) + else: + attach_hint = ( + "Please file a bug report; the full traceback was printed to stderr " + "(crash log could not be written)." + ) messages = [ "An unexpected internal error occurred.", f"{type(exc).__name__}: {exc}", - f"Please file a bug report and attach {display_path} " - "(contains the full traceback).", + attach_hint, ] try: for msg in messages: @@ -87,7 +98,7 @@ def _print_unhandled_error(exc: BaseException) -> None: default=None, help="Path to config file (.localci.yml).", ) -@click.option("--verbose", "-v", is_flag=True, help="Enable verbose/debug output.") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.") @click.option("--quiet", "-q", is_flag=True, help="Suppress non-essential output.") @click.option("--no-color", is_flag=True, help="Disable coloured output.") @click.option( diff --git a/cli/localci/utils/crash.py b/cli/localci/utils/crash.py index 6fd6b37..5a4eb72 100644 --- a/cli/localci/utils/crash.py +++ b/cli/localci/utils/crash.py @@ -19,17 +19,41 @@ def crash_log_path() -> Path: return localci_home() / CRASH_LOG_NAME -def log_crash(exc: BaseException) -> Path: - """Write a full crash report for *exc* and return the log file path.""" +def crash_log_display_path() -> str: + """Return a short user-facing path for the crash log (e.g. ``~/.localci/crash.log``).""" path = crash_log_path() - lines = [ - f"timestamp: {datetime.now(timezone.utc).isoformat()}\n", - f"localci_version: {__version__}\n", - f"python_version: {sys.version}\n", - f"platform: {platform.platform()}\n", - "\n", - "traceback:\n", - *traceback.format_exception(type(exc), exc, exc.__traceback__), - ] - path.write_text("".join(lines), encoding="utf-8") - return path + try: + rel = path.relative_to(Path.home()) + return "~/" + rel.as_posix() + except ValueError: + return str(path) + + +def _build_crash_report(exc: BaseException) -> str: + return "".join( + [ + f"timestamp: {datetime.now(timezone.utc).isoformat()}\n", + f"localci_version: {__version__}\n", + f"python_version: {sys.version}\n", + f"platform: {platform.platform()}\n", + "\n", + "traceback:\n", + *traceback.format_exception(type(exc), exc, exc.__traceback__), + ] + ) + + +def log_crash(exc: BaseException) -> Path | None: + """Write a full crash report for *exc* and return the log file path. + + On write failure, prints the report to stderr and returns ``None``. + """ + path = crash_log_path() + report = _build_crash_report(exc) + try: + path.write_text(report, encoding="utf-8") + return path + except OSError: + sys.stderr.write(f"Could not write crash log to {path}:\n") + sys.stderr.write(report) + return None diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 76d7ae7..f736280 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -8,12 +8,13 @@ import json from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner from localci.cli.main import cli +from localci.errors import YqNotFoundError from localci.utils.crash import CRASH_LOG_NAME @@ -323,10 +324,24 @@ def test_config_set_and_get(self, tmp_path, monkeypatch): # --------------------------------------------------------------------------- -# Catch-all exception handler (w4_issue_01) +# Catch-all exception handler # --------------------------------------------------------------------------- +@pytest.fixture +def isolated_localci_home(monkeypatch, tmp_path): + """Redirect ``localci_home()`` to a temp dir (Unix and Windows).""" + home = tmp_path / ".localci" + home.mkdir(parents=True, exist_ok=True) + + def _home(): + return home + + monkeypatch.setattr("localci.utils.paths.localci_home", _home) + monkeypatch.setattr("localci.utils.crash.localci_home", _home) + return home + + class TestCatchAllHandler: """Unhandled exceptions produce friendly output and crash.log diagnostics.""" @@ -337,21 +352,18 @@ def _workflow_file(tmp_path: Path) -> Path: return wf @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") - def test_unhandled_exception_friendly_output(self, mock_analyze, tmp_path): + def test_unhandled_exception_friendly_output( + self, mock_analyze, tmp_path, isolated_localci_home + ): mock_analyze.side_effect = RuntimeError("test boom") wf = self._workflow_file(tmp_path) - result = runner.invoke( - cli, - ["analyze", str(wf)], - env={"HOME": str(tmp_path)}, - ) + result = runner.invoke(cli, ["analyze", str(wf)]) assert result.exit_code == 2 assert "Traceback" not in result.output assert "unexpected internal error" in result.output.lower() assert "RuntimeError" in result.output assert "test boom" in result.output assert "bug report" in result.output.lower() - # Check for crash log path, resilient to text wrapping output_no_newlines = result.output.replace("\n", " ") assert ( CRASH_LOG_NAME in output_no_newlines @@ -359,38 +371,99 @@ def test_unhandled_exception_friendly_output(self, mock_analyze, tmp_path): ) @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") - def test_unhandled_exception_writes_crash_log(self, mock_analyze, tmp_path): + def test_unhandled_exception_writes_crash_log( + self, mock_analyze, tmp_path, isolated_localci_home + ): mock_analyze.side_effect = RuntimeError("test boom") wf = self._workflow_file(tmp_path) - runner.invoke( - cli, - ["analyze", str(wf)], - env={"HOME": str(tmp_path)}, - ) - log_path = tmp_path / ".localci" / CRASH_LOG_NAME + runner.invoke(cli, ["analyze", str(wf)]) + log_path = isolated_localci_home / CRASH_LOG_NAME assert log_path.is_file() content = log_path.read_text(encoding="utf-8") assert "RuntimeError" in content assert "test boom" in content assert "traceback:" in content.lower() assert "localci_version:" in content + assert "python_version:" in content + assert "platform:" in content + assert content.split("python_version:", 1)[1].split("\n", 1)[0].strip() + assert content.split("platform:", 1)[1].split("\n", 1)[0].strip() @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") - def test_debug_flag_reraises_exception(self, mock_analyze, tmp_path): + def test_debug_flag_reraises_exception( + self, mock_analyze, tmp_path, isolated_localci_home + ): mock_analyze.side_effect = RuntimeError("test boom") wf = self._workflow_file(tmp_path) with pytest.raises(RuntimeError, match="test boom"): runner.invoke( cli, ["--debug", "analyze", str(wf)], - env={"HOME": str(tmp_path)}, catch_exceptions=False, ) + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_leaked_localci_error_exit_one_no_crash_log( + self, mock_analyze, tmp_path, isolated_localci_home + ): + mock_analyze.side_effect = YqNotFoundError() + wf = self._workflow_file(tmp_path) + result = runner.invoke(cli, ["analyze", str(wf)]) + assert result.exit_code == 1 + assert "unexpected internal error" not in result.output.lower() + assert "mikefarah/yq" in result.output.lower() + assert not (isolated_localci_home / CRASH_LOG_NAME).exists() + + @patch("localci.cli.run.JobExecutor.check_act") + def test_run_unhandled_exception_uses_catch_all( + self, mock_check_act, isolated_localci_home, tmp_path + ): + mock_check_act.side_effect = RuntimeError("act subprocess failed") + if not Path(SAMPLE_WORKFLOW).exists(): + pytest.skip("sample_workflow.yml not found") + logs_dir = tmp_path / "logs" + cache_dir = tmp_path / "cache" + images_registry = tmp_path / "images" + cfg_file = tmp_path / ".localci.yml" + cfg_file.write_text( + "logging:\n" + f" directory: {logs_dir.as_posix()}\n" + "cache:\n" + f" directory: {cache_dir.as_posix()}\n" + " boost:\n" + " enabled: false\n" + "images:\n" + f" registry: {images_registry.as_posix()}\n" + ) + result = runner.invoke( + cli, + ["-c", str(cfg_file), "run", "--workflow", SAMPLE_WORKFLOW], + ) + assert result.exit_code == 2 + assert "unexpected internal error" in result.output.lower() + assert "act subprocess failed" in result.output + mock_check_act.assert_called_once() + assert (isolated_localci_home / CRASH_LOG_NAME).is_file() + def test_config_error_exit_code_unchanged(self, tmp_path): result = runner.invoke( cli, ["--config", str(tmp_path / "missing.yml"), "list"], - env={"HOME": str(tmp_path)}, ) assert result.exit_code == 1 + + @patch("localci.utils.crash.crash_log_path") + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_log_crash_write_failure_still_friendly( + self, mock_analyze, mock_crash_log_path, tmp_path, isolated_localci_home + ): + mock_analyze.side_effect = RuntimeError("test boom") + mock_path = MagicMock() + mock_path.write_text.side_effect = OSError("disk full") + mock_crash_log_path.return_value = mock_path + wf = self._workflow_file(tmp_path) + result = runner.invoke(cli, ["analyze", str(wf)]) + assert result.exit_code == 2 + assert "unexpected internal error" in result.output.lower() + assert "stderr" in result.output.lower() + assert not (isolated_localci_home / CRASH_LOG_NAME).exists() From 9c42e538738bb44a3ba989c3d4f9c84cc890e396 Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 28 May 2026 23:37:51 +0800 Subject: [PATCH 5/5] fixed ci error --- cli/localci/utils/crash.py | 3 +++ cli/tests/test_cli.py | 6 +----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cli/localci/utils/crash.py b/cli/localci/utils/crash.py index 5a4eb72..f6bc6c0 100644 --- a/cli/localci/utils/crash.py +++ b/cli/localci/utils/crash.py @@ -22,6 +22,9 @@ def crash_log_path() -> Path: def crash_log_display_path() -> str: """Return a short user-facing path for the crash log (e.g. ``~/.localci/crash.log``).""" path = crash_log_path() + # Canonical layout: always show ~/.localci/crash.log (avoids long paths / terminal wrap). + if path.name == CRASH_LOG_NAME and path.parent.name == ".localci": + return f"~/.localci/{CRASH_LOG_NAME}" try: rel = path.relative_to(Path.home()) return "~/" + rel.as_posix() diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index f736280..455af05 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -364,11 +364,7 @@ def test_unhandled_exception_friendly_output( assert "RuntimeError" in result.output assert "test boom" in result.output assert "bug report" in result.output.lower() - output_no_newlines = result.output.replace("\n", " ") - assert ( - CRASH_LOG_NAME in output_no_newlines - or f".localci/{CRASH_LOG_NAME}" in output_no_newlines - ) + assert f"~/.localci/{CRASH_LOG_NAME}" in result.output.replace("\n", " ") @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") def test_unhandled_exception_writes_crash_log(