diff --git a/src/devedeng/error.py b/src/devedeng/error.py index 1b613b4..57bc8a0 100644 --- a/src/devedeng/error.py +++ b/src/devedeng/error.py @@ -14,14 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -from gi.repository import Gtk, Gdk +from gi.repository import Gtk, Gdk, GLib import os import devedeng.configuration_data class error_window: - def __init__(self): + def __init__(self, process=None): self.config = devedeng.configuration_data.configuration.get_config() @@ -34,10 +34,60 @@ class error_window: wdebug_buffer = builder.get_object("debug_buffer") wdebug_buffer.set_text(self.config.get_log()) + # lead with a focused summary of what actually failed, so the cause is + # visible without digging through the full log in the expander + wlabel = builder.get_object("label1") + if (wlabel is not None) and (process is not None): + wlabel.set_line_wrap(True) + wlabel.set_max_width_chars(80) + wlabel.set_selectable(True) + wlabel.set_markup(error_window.build_summary(process)) + werror_window.show_all() werror_window.run() werror_window.destroy() + @staticmethod + def build_summary(process): + esc = GLib.markup_escape_text + parts = ["" + esc(_("The disc could not be created.")) + ""] + + step = getattr(process, "text", "") or "" + if step: + parts.append("") + parts.append(esc(_("Failed step: %s") % step)) + + command = getattr(process, "command_var", None) + if command: + cmd_str = " ".join(str(c) for c in command) + if len(cmd_str) > 400: + cmd_str = cmd_str[:400] + " ..." + parts.append(esc(_("Command: %s") % cmd_str)) + + tail = error_window._stderr_tail(process) + if tail: + parts.append("") + parts.append(esc(_("Last messages:"))) + parts.append("" + esc(tail) + "") + + parts.append("") + parts.append(esc(_("See the full log below for details."))) + return "\n".join(parts) + + @staticmethod + def _stderr_tail(process, max_lines=12, max_line_len=200): + raw = getattr(process, "stderr_data", "") or "" + if not raw: + raw = getattr(process, "stdout_data", "") or "" + if not raw: + return "" + lines = [l.strip() for l in raw.replace("\r", "\n").split("\n")] + lines = [l for l in lines if l] + tail = lines[-max_lines:] + tail = [(l[:max_line_len] + " ...") if len(l) > max_line_len else l + for l in tail] + return "\n".join(tail) + def on_copy_clicked(self, b): clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) diff --git a/src/devedeng/runner.py b/src/devedeng/runner.py index b548573..9b0ede0 100644 --- a/src/devedeng/runner.py +++ b/src/devedeng/runner.py @@ -132,7 +132,7 @@ class runner(GObject.GObject): for element in self.proc_list: element.cancel() self.wprogress.destroy() - devedeng.error.error_window() + devedeng.error.error_window(process) self.emit("done", 1) # there was an error return diff --git a/tests/test_error_summary.py b/tests/test_error_summary.py new file mode 100644 index 0000000..271b336 --- /dev/null +++ b/tests/test_error_summary.py @@ -0,0 +1,74 @@ +"""Tests for the focused failure summary shown in the error window.""" + +from devedeng.error import error_window + + +class FakeProc: + def __init__(self, text="", command_var=None, stderr_data="", stdout_data=""): + self.text = text + self.command_var = command_var or [] + self.stderr_data = stderr_data + self.stdout_data = stdout_data + + +def test_stderr_tail_takes_last_lines(): + raw = "\n".join("line %d" % i for i in range(100)) + tail = error_window._stderr_tail(FakeProc(stderr_data=raw), max_lines=5) + assert tail.splitlines() == ["line 95", "line 96", "line 97", + "line 98", "line 99"] + + +def test_stderr_tail_handles_carriage_returns(): + raw = "p 1\rp 2\rp 3\ndone\n" + tail = error_window._stderr_tail(FakeProc(stderr_data=raw)) + assert tail.splitlines()[-2:] == ["p 3", "done"] + + +def test_stderr_tail_skips_blank_lines(): + raw = "real\n\n \n\nlast\n" + assert error_window._stderr_tail(FakeProc(stderr_data=raw)) == "real\nlast" + + +def test_stderr_tail_falls_back_to_stdout(): + p = FakeProc(stderr_data="", stdout_data="only on stdout\n") + assert error_window._stderr_tail(p) == "only on stdout" + + +def test_stderr_tail_truncates_long_lines(): + raw = "x" * 500 + tail = error_window._stderr_tail(FakeProc(stderr_data=raw), max_line_len=200) + assert tail.endswith(" ...") + assert len(tail) <= 210 + + +def test_build_summary_includes_step_command_and_tail(): + p = FakeProc( + text="Converting My Movie", + command_var=["nice", "-n", "10", "ffmpeg", "-i", "in.mkv", "out.mpg"], + stderr_data="some warning\nfatal: invalid data found\n", + ) + s = error_window.build_summary(p) + assert "Converting My Movie" in s + assert "ffmpeg" in s + assert "invalid data found" in s + # headline is present and markup is well-formed (bold tag closed) + assert "" in s and "" in s + + +def test_build_summary_escapes_markup_special_chars(): + # a path with & and < must be escaped so set_markup doesn't choke + p = FakeProc(text="Converting A & B ", + command_var=["ffmpeg", "-i", "a&b.mkv"], + stderr_data="error: & worse\n") + s = error_window.build_summary(p) + assert "&" in s + assert "<" in s + # the raw unescaped sequences should not appear in dynamic content + assert "a&b.mkv" not in s + + +def test_build_summary_without_stderr_still_works(): + p = FakeProc(text="Creating ISO image", command_var=["mkisofs", "-o", "x"]) + s = error_window.build_summary(p) + assert "Creating ISO image" in s + assert "mkisofs" in s