From c16fdd624d04b1c1af37943009488bfae03eefe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Mon, 31 Aug 2026 19:54:33 +0200 Subject: [PATCH 1/9] tests --- .../test/ex_unit/cli_formatter_test.exs | 83 +++++++++++++++++++ lib/ex_unit/test/ex_unit/formatter_test.exs | 25 ++++++ 2 files changed, 108 insertions(+) create mode 100644 lib/ex_unit/test/ex_unit/cli_formatter_test.exs diff --git a/lib/ex_unit/test/ex_unit/cli_formatter_test.exs b/lib/ex_unit/test/ex_unit/cli_formatter_test.exs new file mode 100644 index 00000000000..f1bcc470a9e --- /dev/null +++ b/lib/ex_unit/test/ex_unit/cli_formatter_test.exs @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2021 The Elixir Team +# SPDX-FileCopyrightText: 2012 Plataformatec + +Code.require_file("../test_helper.exs", __DIR__) + +defmodule ExUnit.CLIFormatterTest do + use ExUnit.Case + + import ExUnit.CaptureIO + + @opts [ + seed: 0, + max_cases: 1, + repeat_until_failure: 0, + dry_run: false, + trace: false, + colors: [enabled: false], + slowest: 0, + slowest_modules: 0, + include: [], + exclude: [] + ] + + defp failed_test(message, logs) do + exception = + try do + flunk(message) + rescue + e -> e + end + + %ExUnit.Test{ + name: :"test poisoned", + description: "poisoned", + module: Hello, + state: {:failed, [{:error, exception, []}]}, + logs: logs, + time: 0, + tags: %{file: "file.ex", line: 1, test_type: :test} + } + end + + defp run_formatter(events) do + capture_io(fn -> + {:ok, formatter} = GenServer.start(ExUnit.CLIFormatter, @opts) + + for event <- events do + GenServer.cast(formatter, event) + end + + # Casts are processed in order before this call, so a reply + # proves none of the events crashed the formatter + assert is_map(:sys.get_state(formatter)) + GenServer.stop(formatter) + end) + end + + test "survives a failure message with invalid UTF-8 and keeps reporting" do + poisoned = failed_test("frame bytes: " <> <<0xC3, 0x28, 0xFF>>, "") + clean = failed_test("clean failure", "") + + output = + run_formatter([ + {:test_finished, poisoned}, + {:test_finished, clean} + ]) + + assert String.valid?(output) + assert output =~ "frame bytes:" + assert output =~ "clean failure" + end + + test "survives captured logs with invalid UTF-8" do + poisoned = failed_test("oops", "log line: " <> <<0xFF>>) + + output = run_formatter([{:test_finished, poisoned}]) + + assert String.valid?(output) + assert output =~ "The following output was logged:" + assert output =~ "log line:" + end +end diff --git a/lib/ex_unit/test/ex_unit/formatter_test.exs b/lib/ex_unit/test/ex_unit/formatter_test.exs index 01843bdc775..0c971929dfd 100644 --- a/lib/ex_unit/test/ex_unit/formatter_test.exs +++ b/lib/ex_unit/test/ex_unit/formatter_test.exs @@ -642,6 +642,31 @@ defmodule ExUnit.FormatterTest do """ end + test "formats invalid UTF-8 in assertion messages" do + failure = [{:error, catch_assertion(flunk("frame bytes: " <> <<0xC3, 0x28, 0xFF>>)), []}] + formatted = format_test_failure(test(), failure, 1, 80, &formatter/2) + + assert String.valid?(formatted) + assert formatted =~ "1) world (Hello)" + assert formatted =~ "frame bytes:" + end + + test "formats invalid UTF-8 in exception messages" do + failure = [{:error, catch_error(raise "bad payload: " <> <<0xFF, 0xFE>>), []}] + formatted = format_test_failure(test(), failure, 1, 80, &formatter/2) + + assert String.valid?(formatted) + assert formatted =~ "** (RuntimeError) bad payload:" + end + + test "formats invalid UTF-8 in setup_all failure messages" do + failure = [{:error, catch_error(raise "bad payload: " <> <<0xFF>>), []}] + formatted = format_test_all_failure(test_module(), failure, 1, 80, &formatter/2) + + assert String.valid?(formatted) + assert formatted =~ "** (RuntimeError) bad payload:" + end + test "formats long test name using full description" do failure = [{:error, catch_error(raise "oops"), []}] long = long_test() From 05adee9ceabd1c249afccb1eb04ff3aa6d27cadc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Mon, 31 Aug 2026 19:54:40 +0200 Subject: [PATCH 2/9] fixes --- lib/ex_unit/lib/ex_unit/cli_formatter.ex | 11 ++++++- lib/ex_unit/lib/ex_unit/formatter.ex | 38 ++++++++++++++---------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/ex_unit/lib/ex_unit/cli_formatter.ex b/lib/ex_unit/lib/ex_unit/cli_formatter.ex index 807d4c4aaff..e0a41ca1413 100644 --- a/lib/ex_unit/lib/ex_unit/cli_formatter.ex +++ b/lib/ex_unit/lib/ex_unit/cli_formatter.ex @@ -586,7 +586,16 @@ defmodule ExUnit.CLIFormatter do defp print_logs(output) do indent = "\n " - output = String.replace(output, "\n", indent) + + output = + output + |> ensure_printable() + |> String.replace("\n", indent) + IO.puts([" The following output was logged:", indent | output]) end + + def ensure_printable(binary) do + if String.valid?(binary), do: binary, else: String.replace_invalid(binary) + end end diff --git a/lib/ex_unit/lib/ex_unit/formatter.ex b/lib/ex_unit/lib/ex_unit/formatter.ex index 43c10d146f7..caf2cf0522f 100644 --- a/lib/ex_unit/lib/ex_unit/formatter.ex +++ b/lib/ex_unit/lib/ex_unit/formatter.ex @@ -281,15 +281,16 @@ defmodule ExUnit.Formatter do parameters: parameters } = test - test_info(with_counter(counter, "#{description} (#{inspect(module)})"), formatter) <> - test_parameters(parameters, formatter) <> - test_location(with_location(tags), formatter) <> - Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> - {text, stack} = format_kind_reason(test, kind, reason, stack, width, formatter) - - failure_header(failures, index) <> - text <> format_stacktrace(stack, module, name, formatter) - end) + (test_info(with_counter(counter, "#{description} (#{inspect(module)})"), formatter) <> + test_parameters(parameters, formatter) <> + test_location(with_location(tags), formatter) <> + Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> + {text, stack} = format_kind_reason(test, kind, reason, stack, width, formatter) + + failure_header(failures, index) <> + text <> format_stacktrace(stack, module, name, formatter) + end)) + |> ensure_printable() end @doc false @@ -321,12 +322,13 @@ defmodule ExUnit.Formatter do def format_test_all_failure(test_module, failures, counter, width, formatter) do %{name: name, parameters: parameters} = test_module - test_module_info(with_counter(counter, "#{inspect(name)}: "), formatter) <> - test_parameters(parameters, formatter) <> - Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> - {text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter) - failure_header(failures, index) <> text <> format_stacktrace(stack, name, nil, formatter) - end) + (test_module_info(with_counter(counter, "#{inspect(name)}: "), formatter) <> + test_parameters(parameters, formatter) <> + Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> + {text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter) + failure_header(failures, index) <> text <> format_stacktrace(stack, name, nil, formatter) + end)) + |> ensure_printable() end ## kind/reason formatting @@ -355,6 +357,10 @@ defmodule ExUnit.Formatter do end end + defp ensure_printable(binary) do + if String.valid?(binary), do: binary, else: String.replace_invalid(binary) + end + defp linked_or_trapped_exit({:EXIT, pid}, {reason, [_ | _] = stack}) when reason.__struct__ in @formatter_exceptions when reason == :function_clause do @@ -634,7 +640,7 @@ defmodule ExUnit.Formatter do end defp format_message(value, formatter) do - value = pad_multiline(value, 5) + value = value |> pad_multiline(5) if String.contains?(value, IO.ANSI.reset()) do value From 55d943fab9c2fecace6fad0d73c918f7c86ed61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Mon, 31 Aug 2026 20:01:18 +0200 Subject: [PATCH 3/9] def -> defp --- lib/ex_unit/lib/ex_unit/cli_formatter.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ex_unit/lib/ex_unit/cli_formatter.ex b/lib/ex_unit/lib/ex_unit/cli_formatter.ex index e0a41ca1413..d92c442b91a 100644 --- a/lib/ex_unit/lib/ex_unit/cli_formatter.ex +++ b/lib/ex_unit/lib/ex_unit/cli_formatter.ex @@ -595,7 +595,7 @@ defmodule ExUnit.CLIFormatter do IO.puts([" The following output was logged:", indent | output]) end - def ensure_printable(binary) do + defp ensure_printable(binary) do if String.valid?(binary), do: binary, else: String.replace_invalid(binary) end end From ade02047c9016c4da88c1a50c80ede9f5436c027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Mon, 31 Aug 2026 20:02:12 +0200 Subject: [PATCH 4/9] remove change from `format_message/2` --- lib/ex_unit/lib/ex_unit/formatter.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ex_unit/lib/ex_unit/formatter.ex b/lib/ex_unit/lib/ex_unit/formatter.ex index caf2cf0522f..0cba1cd0890 100644 --- a/lib/ex_unit/lib/ex_unit/formatter.ex +++ b/lib/ex_unit/lib/ex_unit/formatter.ex @@ -640,7 +640,7 @@ defmodule ExUnit.Formatter do end defp format_message(value, formatter) do - value = value |> pad_multiline(5) + value = pad_multiline(value, 5) if String.contains?(value, IO.ANSI.reset()) do value From 044ad3e0d6f9a09846d13c7bb2e4577583af76d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Mon, 31 Aug 2026 20:44:44 +0200 Subject: [PATCH 5/9] Re-use ExUnit.Formatter.ensure_printable --- lib/ex_unit/lib/ex_unit/cli_formatter.ex | 6 +-- lib/ex_unit/lib/ex_unit/formatter.ex | 47 ++++++++++++++---------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/lib/ex_unit/lib/ex_unit/cli_formatter.ex b/lib/ex_unit/lib/ex_unit/cli_formatter.ex index d92c442b91a..e4a24d0dee0 100644 --- a/lib/ex_unit/lib/ex_unit/cli_formatter.ex +++ b/lib/ex_unit/lib/ex_unit/cli_formatter.ex @@ -589,13 +589,9 @@ defmodule ExUnit.CLIFormatter do output = output - |> ensure_printable() + |> ExUnit.Formatter.ensure_printable() |> String.replace("\n", indent) IO.puts([" The following output was logged:", indent | output]) end - - defp ensure_printable(binary) do - if String.valid?(binary), do: binary, else: String.replace_invalid(binary) - end end diff --git a/lib/ex_unit/lib/ex_unit/formatter.ex b/lib/ex_unit/lib/ex_unit/formatter.ex index 0cba1cd0890..bf3c925be78 100644 --- a/lib/ex_unit/lib/ex_unit/formatter.ex +++ b/lib/ex_unit/lib/ex_unit/formatter.ex @@ -281,16 +281,18 @@ defmodule ExUnit.Formatter do parameters: parameters } = test - (test_info(with_counter(counter, "#{description} (#{inspect(module)})"), formatter) <> - test_parameters(parameters, formatter) <> - test_location(with_location(tags), formatter) <> - Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> - {text, stack} = format_kind_reason(test, kind, reason, stack, width, formatter) + formatted = + test_info(with_counter(counter, "#{description} (#{inspect(module)})"), formatter) <> + test_parameters(parameters, formatter) <> + test_location(with_location(tags), formatter) <> + Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> + {text, stack} = format_kind_reason(test, kind, reason, stack, width, formatter) + + failure_header(failures, index) <> + text <> format_stacktrace(stack, module, name, formatter) + end) - failure_header(failures, index) <> - text <> format_stacktrace(stack, module, name, formatter) - end)) - |> ensure_printable() + ensure_printable(formatted) end @doc false @@ -322,13 +324,22 @@ defmodule ExUnit.Formatter do def format_test_all_failure(test_module, failures, counter, width, formatter) do %{name: name, parameters: parameters} = test_module - (test_module_info(with_counter(counter, "#{inspect(name)}: "), formatter) <> - test_parameters(parameters, formatter) <> - Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> - {text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter) - failure_header(failures, index) <> text <> format_stacktrace(stack, name, nil, formatter) - end)) - |> ensure_printable() + formatted = + test_module_info(with_counter(counter, "#{inspect(name)}: "), formatter) <> + test_parameters(parameters, formatter) <> + Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> + {text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter) + + failure_header(failures, index) <> + text <> format_stacktrace(stack, name, nil, formatter) + end) + + ensure_printable(formatted) + end + + @doc false + def ensure_printable(binary) do + if String.valid?(binary), do: binary, else: String.replace_invalid(binary) end ## kind/reason formatting @@ -357,10 +368,6 @@ defmodule ExUnit.Formatter do end end - defp ensure_printable(binary) do - if String.valid?(binary), do: binary, else: String.replace_invalid(binary) - end - defp linked_or_trapped_exit({:EXIT, pid}, {reason, [_ | _] = stack}) when reason.__struct__ in @formatter_exceptions when reason == :function_clause do From 9d98bdc2ed363a8525ba0111c11355e6d02b0114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Mon, 31 Aug 2026 20:46:02 +0200 Subject: [PATCH 6/9] Add sanitization to exception messages --- lib/ex_unit/lib/ex_unit/formatter.ex | 4 +++- lib/ex_unit/test/ex_unit/formatter_test.exs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/ex_unit/lib/ex_unit/formatter.ex b/lib/ex_unit/lib/ex_unit/formatter.ex index bf3c925be78..0f9b357ac53 100644 --- a/lib/ex_unit/lib/ex_unit/formatter.ex +++ b/lib/ex_unit/lib/ex_unit/formatter.ex @@ -422,7 +422,9 @@ defmodule ExUnit.Formatter do @doc false def format_assertion_error(%ExUnit.AssertionError{} = struct) do - format_exception(%{}, struct, [], :infinity, fn _, msg -> msg end, "") |> elem(0) + format_exception(%{}, struct, [], :infinity, fn _, msg -> msg end, "") + |> elem(0) + |> ensure_printable() end @doc """ diff --git a/lib/ex_unit/test/ex_unit/formatter_test.exs b/lib/ex_unit/test/ex_unit/formatter_test.exs index 0c971929dfd..7bb4a3665e0 100644 --- a/lib/ex_unit/test/ex_unit/formatter_test.exs +++ b/lib/ex_unit/test/ex_unit/formatter_test.exs @@ -667,6 +667,13 @@ defmodule ExUnit.FormatterTest do assert formatted =~ "** (RuntimeError) bad payload:" end + test "formats invalid UTF-8 in Exception.message on assertion errors" do + message = Exception.message(catch_assertion(flunk("frame bytes: " <> <<0xC3, 0x28, 0xFF>>))) + + assert String.valid?(message) + assert message =~ "frame bytes:" + end + test "formats long test name using full description" do failure = [{:error, catch_error(raise "oops"), []}] long = long_test() From 7002711c4df9d55e5167bed224bc7c452e5b547c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Mon, 31 Aug 2026 20:46:25 +0200 Subject: [PATCH 7/9] Make formatter opts inherit ExUnit.configuration() --- .../test/ex_unit/cli_formatter_test.exs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/lib/ex_unit/test/ex_unit/cli_formatter_test.exs b/lib/ex_unit/test/ex_unit/cli_formatter_test.exs index f1bcc470a9e..b82853b1e5e 100644 --- a/lib/ex_unit/test/ex_unit/cli_formatter_test.exs +++ b/lib/ex_unit/test/ex_unit/cli_formatter_test.exs @@ -9,19 +9,6 @@ defmodule ExUnit.CLIFormatterTest do import ExUnit.CaptureIO - @opts [ - seed: 0, - max_cases: 1, - repeat_until_failure: 0, - dry_run: false, - trace: false, - colors: [enabled: false], - slowest: 0, - slowest_modules: 0, - include: [], - exclude: [] - ] - defp failed_test(message, logs) do exception = try do @@ -43,7 +30,17 @@ defmodule ExUnit.CLIFormatterTest do defp run_formatter(events) do capture_io(fn -> - {:ok, formatter} = GenServer.start(ExUnit.CLIFormatter, @opts) + opts = + Keyword.merge(ExUnit.configuration(), + seed: 0, + max_cases: 1, + trace: false, + colors: [enabled: false], + include: [], + exclude: [] + ) + + {:ok, formatter} = GenServer.start(ExUnit.CLIFormatter, opts) for event <- events do GenServer.cast(formatter, event) From 6e73479c22ea5f0fa6e48f9415ec7ea22e5b88f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Tue, 1 Sep 2026 11:54:38 +0200 Subject: [PATCH 8/9] Move sanitization to CLIFormatter --- lib/ex_unit/lib/ex_unit/cli_formatter.ex | 13 +-- lib/ex_unit/lib/ex_unit/formatter.ex | 47 ++++------- .../test/ex_unit/cli_formatter_test.exs | 80 ------------------- lib/ex_unit/test/ex_unit/formatter_test.exs | 32 -------- lib/ex_unit/test/ex_unit_test.exs | 23 ++++++ 5 files changed, 48 insertions(+), 147 deletions(-) delete mode 100644 lib/ex_unit/test/ex_unit/cli_formatter_test.exs diff --git a/lib/ex_unit/lib/ex_unit/cli_formatter.ex b/lib/ex_unit/lib/ex_unit/cli_formatter.ex index e4a24d0dee0..fc6b2c8526b 100644 --- a/lib/ex_unit/lib/ex_unit/cli_formatter.ex +++ b/lib/ex_unit/lib/ex_unit/cli_formatter.ex @@ -436,7 +436,13 @@ defmodule ExUnit.CLIFormatter do true -> IO.puts("\n") end - IO.puts(formatted) + formatted + |> ensure_printable() + |> IO.puts() + end + + defp ensure_printable(binary) do + if String.valid?(binary), do: binary, else: String.replace_invalid(binary) end defp format_type_counts(type_counter) do @@ -587,10 +593,7 @@ defmodule ExUnit.CLIFormatter do defp print_logs(output) do indent = "\n " - output = - output - |> ExUnit.Formatter.ensure_printable() - |> String.replace("\n", indent) + output = String.replace(output, "\n", indent) IO.puts([" The following output was logged:", indent | output]) end diff --git a/lib/ex_unit/lib/ex_unit/formatter.ex b/lib/ex_unit/lib/ex_unit/formatter.ex index 0f9b357ac53..1bdaf7fcb70 100644 --- a/lib/ex_unit/lib/ex_unit/formatter.ex +++ b/lib/ex_unit/lib/ex_unit/formatter.ex @@ -281,18 +281,15 @@ defmodule ExUnit.Formatter do parameters: parameters } = test - formatted = - test_info(with_counter(counter, "#{description} (#{inspect(module)})"), formatter) <> - test_parameters(parameters, formatter) <> - test_location(with_location(tags), formatter) <> - Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> - {text, stack} = format_kind_reason(test, kind, reason, stack, width, formatter) - - failure_header(failures, index) <> - text <> format_stacktrace(stack, module, name, formatter) - end) - - ensure_printable(formatted) + test_info(with_counter(counter, "#{description} (#{inspect(module)})"), formatter) <> + test_parameters(parameters, formatter) <> + test_location(with_location(tags), formatter) <> + Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> + {text, stack} = format_kind_reason(test, kind, reason, stack, width, formatter) + + failure_header(failures, index) <> + text <> format_stacktrace(stack, module, name, formatter) + end) end @doc false @@ -324,22 +321,14 @@ defmodule ExUnit.Formatter do def format_test_all_failure(test_module, failures, counter, width, formatter) do %{name: name, parameters: parameters} = test_module - formatted = - test_module_info(with_counter(counter, "#{inspect(name)}: "), formatter) <> - test_parameters(parameters, formatter) <> - Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> - {text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter) - - failure_header(failures, index) <> - text <> format_stacktrace(stack, name, nil, formatter) - end) - - ensure_printable(formatted) - end + test_module_info(with_counter(counter, "#{inspect(name)}: "), formatter) <> + test_parameters(parameters, formatter) <> + Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> + {text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter) - @doc false - def ensure_printable(binary) do - if String.valid?(binary), do: binary, else: String.replace_invalid(binary) + failure_header(failures, index) <> + text <> format_stacktrace(stack, name, nil, formatter) + end) end ## kind/reason formatting @@ -422,9 +411,7 @@ defmodule ExUnit.Formatter do @doc false def format_assertion_error(%ExUnit.AssertionError{} = struct) do - format_exception(%{}, struct, [], :infinity, fn _, msg -> msg end, "") - |> elem(0) - |> ensure_printable() + format_exception(%{}, struct, [], :infinity, fn _, msg -> msg end, "") |> elem(0) end @doc """ diff --git a/lib/ex_unit/test/ex_unit/cli_formatter_test.exs b/lib/ex_unit/test/ex_unit/cli_formatter_test.exs deleted file mode 100644 index b82853b1e5e..00000000000 --- a/lib/ex_unit/test/ex_unit/cli_formatter_test.exs +++ /dev/null @@ -1,80 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: 2021 The Elixir Team -# SPDX-FileCopyrightText: 2012 Plataformatec - -Code.require_file("../test_helper.exs", __DIR__) - -defmodule ExUnit.CLIFormatterTest do - use ExUnit.Case - - import ExUnit.CaptureIO - - defp failed_test(message, logs) do - exception = - try do - flunk(message) - rescue - e -> e - end - - %ExUnit.Test{ - name: :"test poisoned", - description: "poisoned", - module: Hello, - state: {:failed, [{:error, exception, []}]}, - logs: logs, - time: 0, - tags: %{file: "file.ex", line: 1, test_type: :test} - } - end - - defp run_formatter(events) do - capture_io(fn -> - opts = - Keyword.merge(ExUnit.configuration(), - seed: 0, - max_cases: 1, - trace: false, - colors: [enabled: false], - include: [], - exclude: [] - ) - - {:ok, formatter} = GenServer.start(ExUnit.CLIFormatter, opts) - - for event <- events do - GenServer.cast(formatter, event) - end - - # Casts are processed in order before this call, so a reply - # proves none of the events crashed the formatter - assert is_map(:sys.get_state(formatter)) - GenServer.stop(formatter) - end) - end - - test "survives a failure message with invalid UTF-8 and keeps reporting" do - poisoned = failed_test("frame bytes: " <> <<0xC3, 0x28, 0xFF>>, "") - clean = failed_test("clean failure", "") - - output = - run_formatter([ - {:test_finished, poisoned}, - {:test_finished, clean} - ]) - - assert String.valid?(output) - assert output =~ "frame bytes:" - assert output =~ "clean failure" - end - - test "survives captured logs with invalid UTF-8" do - poisoned = failed_test("oops", "log line: " <> <<0xFF>>) - - output = run_formatter([{:test_finished, poisoned}]) - - assert String.valid?(output) - assert output =~ "The following output was logged:" - assert output =~ "log line:" - end -end diff --git a/lib/ex_unit/test/ex_unit/formatter_test.exs b/lib/ex_unit/test/ex_unit/formatter_test.exs index 7bb4a3665e0..01843bdc775 100644 --- a/lib/ex_unit/test/ex_unit/formatter_test.exs +++ b/lib/ex_unit/test/ex_unit/formatter_test.exs @@ -642,38 +642,6 @@ defmodule ExUnit.FormatterTest do """ end - test "formats invalid UTF-8 in assertion messages" do - failure = [{:error, catch_assertion(flunk("frame bytes: " <> <<0xC3, 0x28, 0xFF>>)), []}] - formatted = format_test_failure(test(), failure, 1, 80, &formatter/2) - - assert String.valid?(formatted) - assert formatted =~ "1) world (Hello)" - assert formatted =~ "frame bytes:" - end - - test "formats invalid UTF-8 in exception messages" do - failure = [{:error, catch_error(raise "bad payload: " <> <<0xFF, 0xFE>>), []}] - formatted = format_test_failure(test(), failure, 1, 80, &formatter/2) - - assert String.valid?(formatted) - assert formatted =~ "** (RuntimeError) bad payload:" - end - - test "formats invalid UTF-8 in setup_all failure messages" do - failure = [{:error, catch_error(raise "bad payload: " <> <<0xFF>>), []}] - formatted = format_test_all_failure(test_module(), failure, 1, 80, &formatter/2) - - assert String.valid?(formatted) - assert formatted =~ "** (RuntimeError) bad payload:" - end - - test "formats invalid UTF-8 in Exception.message on assertion errors" do - message = Exception.message(catch_assertion(flunk("frame bytes: " <> <<0xC3, 0x28, 0xFF>>))) - - assert String.valid?(message) - assert message =~ "frame bytes:" - end - test "formats long test name using full description" do failure = [{:error, catch_error(raise "oops"), []}] long = long_test() diff --git a/lib/ex_unit/test/ex_unit_test.exs b/lib/ex_unit/test/ex_unit_test.exs index 3acd3436e24..12915601352 100644 --- a/lib/ex_unit/test/ex_unit_test.exs +++ b/lib/ex_unit/test/ex_unit_test.exs @@ -1281,4 +1281,27 @@ defmodule ExUnitTest do defp max_failures_reached_msg() do "--max-failures reached, aborting test suite" end + + test "reports failures with invalid UTF-8 in messages" do + defmodule InvalidUtf8Test do + use ExUnit.Case + + test "poisoned" do + flunk("frame bytes: " <> <<0xC3, 0x28, 0xFF>>) + end + + test "clean" do + flunk("never silenced") + end + end + + output = + capture_io(fn -> + assert ExUnit.run() == %{total: 2, excluded: 0, failures: 2, skipped: 0} + end) + + assert String.valid?(output) + assert output =~ "frame bytes:" + assert output =~ "never silenced" + end end From 13b3da14dbecf667cf262092dac7581b6fd6e4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Migda=C5=82?= Date: Tue, 1 Sep 2026 11:56:14 +0200 Subject: [PATCH 9/9] Minimize delta --- lib/ex_unit/lib/ex_unit/cli_formatter.ex | 2 -- lib/ex_unit/lib/ex_unit/formatter.ex | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/ex_unit/lib/ex_unit/cli_formatter.ex b/lib/ex_unit/lib/ex_unit/cli_formatter.ex index fc6b2c8526b..dd03351f15c 100644 --- a/lib/ex_unit/lib/ex_unit/cli_formatter.ex +++ b/lib/ex_unit/lib/ex_unit/cli_formatter.ex @@ -592,9 +592,7 @@ defmodule ExUnit.CLIFormatter do defp print_logs(output) do indent = "\n " - output = String.replace(output, "\n", indent) - IO.puts([" The following output was logged:", indent | output]) end end diff --git a/lib/ex_unit/lib/ex_unit/formatter.ex b/lib/ex_unit/lib/ex_unit/formatter.ex index 1bdaf7fcb70..43c10d146f7 100644 --- a/lib/ex_unit/lib/ex_unit/formatter.ex +++ b/lib/ex_unit/lib/ex_unit/formatter.ex @@ -325,9 +325,7 @@ defmodule ExUnit.Formatter do test_parameters(parameters, formatter) <> Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} -> {text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter) - - failure_header(failures, index) <> - text <> format_stacktrace(stack, name, nil, formatter) + failure_header(failures, index) <> text <> format_stacktrace(stack, name, nil, formatter) end) end