From 93ebf785bf01903c53c028bc080af40aa4db5298 Mon Sep 17 00:00:00 2001 From: richardcocks Date: Wed, 11 Mar 2026 21:59:38 +0000 Subject: [PATCH 1/5] Adds todo-reporting --- src/gleeunit/internal/reporting.gleam | 64 +++++++++++++++------------ test/gleeunit_test_ffi.erl | 21 ++++++++- test/gleeunit_test_ffi.mjs | 10 +++++ test/reporting_test.gleam | 47 ++++++++++++++++++++ 4 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 test/reporting_test.gleam diff --git a/src/gleeunit/internal/reporting.gleam b/src/gleeunit/internal/reporting.gleam index 72f766f..b22789d 100644 --- a/src/gleeunit/internal/reporting.gleam +++ b/src/gleeunit/internal/reporting.gleam @@ -9,42 +9,41 @@ import gleam/string import gleeunit/internal/gleam_panic.{type GleamPanic} pub type State { - State(passed: Int, failed: Int, skipped: Int) + State(passed: Int, failed: Int, skipped: Int, todos: Int) } pub fn new_state() -> State { - State(passed: 0, failed: 0, skipped: 0) + State(passed: 0, failed: 0, skipped: 0, todos: 0) } pub fn finished(state: State) -> Int { + let todo_suffix = case state.todos { + 0 -> "" + n -> ", " <> int.to_string(n) <> " todo" + } + let skipped_suffix = case state.skipped { + 0 -> "" + n -> ", " <> int.to_string(n) <> " skipped" + } + case state { - State(passed: 0, failed: 0, skipped: 0) -> { + State(passed: 0, failed: 0, skipped: 0, todos: 0) -> { io.println("\nNo tests found!") 1 } - State(failed: 0, skipped: 0, ..) -> { + State(failed: 0, skipped: 0, todos: 0, ..) -> { let message = "\n" <> int.to_string(state.passed) <> " passed, no failures" io.println(green(message)) 0 } - State(skipped: 0, ..) -> { - let message = - "\n" - <> int.to_string(state.passed) - <> " passed, " - <> int.to_string(state.failed) - <> " failures" - io.println(red(message)) - 1 - } State(failed: 0, ..) -> { let message = "\n" <> int.to_string(state.passed) - <> " passed, 0 failures, " - <> int.to_string(state.skipped) - <> " skipped" + <> " passed, 0 failures" + <> todo_suffix + <> skipped_suffix io.println(yellow(message)) 1 } @@ -54,9 +53,9 @@ pub fn finished(state: State) -> Int { <> int.to_string(state.passed) <> " passed, " <> int.to_string(state.failed) - <> " failures, " - <> int.to_string(state.skipped) - <> " skipped" + <> " failures" + <> todo_suffix + <> skipped_suffix io.println(red(message)) 1 } @@ -74,16 +73,25 @@ pub fn test_failed( function: String, error: dynamic.Dynamic, ) -> State { - let message = case gleam_panic.from_dynamic(error) { - Ok(error) -> { - let src = option.from_result(read_file(error.file)) - format_gleam_error(error, module, function, src) + case gleam_panic.from_dynamic(error) { + Ok(gleam_panic.GleamPanic(kind: gleam_panic.Todo, ..) as e) -> { + let src = option.from_result(read_file(e.file)) + let message = format_gleam_error(e, module, function, src) + io.print("\n" <> message) + State(..state, todos: state.todos + 1) + } + Ok(e) -> { + let src = option.from_result(read_file(e.file)) + let message = format_gleam_error(e, module, function, src) + io.print("\n" <> message) + State(..state, failed: state.failed + 1) + } + Error(_) -> { + let message = format_unknown(module, function, error) + io.print("\n" <> message) + State(..state, failed: state.failed + 1) } - Error(_) -> format_unknown(module, function, error) } - - io.print("\n" <> message) - State(..state, failed: state.failed + 1) } pub fn eunit_missing() -> Result(never, Nil) { diff --git a/test/gleeunit_test_ffi.erl b/test/gleeunit_test_ffi.erl index bb26f40..ceb9e12 100644 --- a/test/gleeunit_test_ffi.erl +++ b/test/gleeunit_test_ffi.erl @@ -1,5 +1,5 @@ -module(gleeunit_test_ffi). --export([rescue/1]). +-export([rescue/1, suppress_output/1]). rescue(F) -> try @@ -7,3 +7,22 @@ rescue(F) -> catch _:Error:_ -> {error, Error} end. + +suppress_output(F) -> + OldGL = group_leader(), + Sink = spawn_link(fun Loop() -> + receive + {io_request, From, ReplyAs, _Request} -> + From ! {io_reply, ReplyAs, ok}, + Loop(); + _ -> + Loop() + end + end), + group_leader(Sink, self()), + try F() + after + group_leader(OldGL, self()), + unlink(Sink), + exit(Sink, normal) + end. diff --git a/test/gleeunit_test_ffi.mjs b/test/gleeunit_test_ffi.mjs index 783209d..665e935 100644 --- a/test/gleeunit_test_ffi.mjs +++ b/test/gleeunit_test_ffi.mjs @@ -7,3 +7,13 @@ export function rescue(f) { return new Error(e); } } + +export function suppress_output(f) { + const oldWrite = process.stdout.write; + process.stdout.write = () => true; + try { + return f(); + } finally { + process.stdout.write = oldWrite; + } +} diff --git a/test/reporting_test.gleam b/test/reporting_test.gleam new file mode 100644 index 0000000..7e2985b --- /dev/null +++ b/test/reporting_test.gleam @@ -0,0 +1,47 @@ +import gleam/dynamic +import gleeunit/internal/reporting +import testhelper + +@external(erlang, "gleeunit_test_ffi", "rescue") +@external(javascript, "./gleeunit_test_ffi.mjs", "rescue") +fn rescue(f: fn() -> t) -> Result(t, dynamic.Dynamic) + +@external(erlang, "gleeunit_test_ffi", "suppress_output") +@external(javascript, "./gleeunit_test_ffi.mjs", "suppress_output") +fn suppress_output(f: fn() -> t) -> t + +pub fn todo_counted_as_todo_not_failure_test() { + let state = reporting.new_state() + let assert Error(error) = rescue(fn() { testhelper.run_todo() }) + let state = + suppress_output(fn() { + reporting.test_failed(state, "test_module", "my_test", error) + }) + assert state.todos == 1 + assert state.failed == 0 + assert state.passed == 0 +} + +pub fn panic_still_counted_as_failure_test() { + let state = reporting.new_state() + let assert Error(error) = rescue(fn() { panic as "something broke" }) + let state = + suppress_output(fn() { + reporting.test_failed(state, "test_module", "my_test", error) + }) + assert state.failed == 1 + assert state.todos == 0 + assert state.passed == 0 +} + +pub fn finished_returns_1_when_todos_present_test() { + let state = reporting.State(passed: 5, failed: 0, skipped: 0, todos: 2) + let exit_code = suppress_output(fn() { reporting.finished(state) }) + assert exit_code == 1 +} + +pub fn finished_returns_0_when_no_todos_or_failures_test() { + let state = reporting.State(passed: 5, failed: 0, skipped: 0, todos: 0) + let exit_code = suppress_output(fn() { reporting.finished(state) }) + assert exit_code == 0 +} From b4fdb565e8bd8cfcc7a9fb729535232d8e44667e Mon Sep 17 00:00:00 2001 From: richardcocks Date: Wed, 11 Mar 2026 22:37:20 +0000 Subject: [PATCH 2/5] Handle cancelation within OTP --- src/gleeunit_progress.erl | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/gleeunit_progress.erl b/src/gleeunit_progress.erl index e6576a5..c2071e5 100644 --- a/src/gleeunit_progress.erl +++ b/src/gleeunit_progress.erl @@ -47,8 +47,39 @@ handle_end(test, Data, State) -> end. -handle_cancel(_test_or_group, Data, State) -> - ?reporting:test_failed(State, <<"gleeunit">>, <<"main">>, Data). +handle_cancel(test, Data, State) -> + case {proplists:get_value(source, Data), proplists:get_value(reason, Data)} of + {{AtomModule, AtomFunction, _Arity}, Reason} when Reason =/= undefined -> + Module = erlang:atom_to_binary(AtomModule), + Function = erlang:atom_to_binary(AtomFunction), + ?reporting:test_failed(State, Module, Function, extract_exception(Reason)); + {{AtomModule, AtomFunction, _Arity}, undefined} -> + %% Test was cancelled without a reason (e.g. linked process crash). + %% Store test info for when the group cancel arrives with the reason. + put(gleeunit_cancelled_test, {AtomModule, AtomFunction}), + State; + _ -> + State + end; +handle_cancel(group, Data, State) -> + case proplists:get_value(reason, Data) of + undefined -> + State; + Reason -> + Exception = extract_exception(Reason), + case erase(gleeunit_cancelled_test) of + {AtomModule, AtomFunction} -> + Module = erlang:atom_to_binary(AtomModule), + Function = erlang:atom_to_binary(AtomFunction), + ?reporting:test_failed(State, Module, Function, Exception); + undefined -> + ?reporting:test_failed(State, <<"gleeunit">>, <<"main">>, Exception) + end + end. + +extract_exception({exit, {Exception, _Stack}}) -> Exception; +extract_exception({abort, Exception}) -> Exception; +extract_exception(Other) -> Other. terminate({ok, _Data}, State) -> ?reporting:finished(State), From c96b665badfa1d300729434e7185323393d79ce6 Mon Sep 17 00:00:00 2001 From: richardcocks Date: Wed, 18 Mar 2026 19:51:16 +0000 Subject: [PATCH 3/5] Handle other cancelations --- src/gleeunit_progress.erl | 4 +++- test/gleeunit_test_ffi.erl | 7 ++++++- test/reporting_test.gleam | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/gleeunit_progress.erl b/src/gleeunit_progress.erl index c2071e5..f21e1ac 100644 --- a/src/gleeunit_progress.erl +++ b/src/gleeunit_progress.erl @@ -75,7 +75,9 @@ handle_cancel(group, Data, State) -> undefined -> ?reporting:test_failed(State, <<"gleeunit">>, <<"main">>, Exception) end - end. + end; +handle_cancel(_Other, _Data, State) -> + State. extract_exception({exit, {Exception, _Stack}}) -> Exception; extract_exception({abort, Exception}) -> Exception; diff --git a/test/gleeunit_test_ffi.erl b/test/gleeunit_test_ffi.erl index ceb9e12..4ac193b 100644 --- a/test/gleeunit_test_ffi.erl +++ b/test/gleeunit_test_ffi.erl @@ -1,5 +1,5 @@ -module(gleeunit_test_ffi). --export([rescue/1, suppress_output/1]). +-export([rescue/1, suppress_output/1, handle_cancel_unknown_type/0]). rescue(F) -> try @@ -26,3 +26,8 @@ suppress_output(F) -> unlink(Sink), exit(Sink, normal) end. + +handle_cancel_unknown_type() -> + State = 'gleeunit@internal@reporting':new_state(), + State = gleeunit_progress:handle_cancel(other, [], State), + ok. diff --git a/test/reporting_test.gleam b/test/reporting_test.gleam index 7e2985b..87e0903 100644 --- a/test/reporting_test.gleam +++ b/test/reporting_test.gleam @@ -40,6 +40,13 @@ pub fn finished_returns_1_when_todos_present_test() { assert exit_code == 1 } +@external(erlang, "gleeunit_test_ffi", "handle_cancel_unknown_type") +fn handle_cancel_unknown_type() -> Nil + +pub fn handle_cancel_unknown_type_does_not_crash_test() { + handle_cancel_unknown_type() +} + pub fn finished_returns_0_when_no_todos_or_failures_test() { let state = reporting.State(passed: 5, failed: 0, skipped: 0, todos: 0) let exit_code = suppress_output(fn() { reporting.finished(state) }) From 00abf553d21045d64e9e8c7bf6cb06202c801e5e Mon Sep 17 00:00:00 2001 From: richardcocks Date: Wed, 18 Mar 2026 19:55:46 +0000 Subject: [PATCH 4/5] Cleanup output, make sure works on Deno --- src/gleeunit/internal/reporting.gleam | 27 ++++++++++++++------------- test/gleeunit_test_ffi.mjs | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/gleeunit/internal/reporting.gleam b/src/gleeunit/internal/reporting.gleam index b22789d..1f8c742 100644 --- a/src/gleeunit/internal/reporting.gleam +++ b/src/gleeunit/internal/reporting.gleam @@ -17,15 +17,6 @@ pub fn new_state() -> State { } pub fn finished(state: State) -> Int { - let todo_suffix = case state.todos { - 0 -> "" - n -> ", " <> int.to_string(n) <> " todo" - } - let skipped_suffix = case state.skipped { - 0 -> "" - n -> ", " <> int.to_string(n) <> " skipped" - } - case state { State(passed: 0, failed: 0, skipped: 0, todos: 0) -> { io.println("\nNo tests found!") @@ -42,8 +33,7 @@ pub fn finished(state: State) -> Int { "\n" <> int.to_string(state.passed) <> " passed, 0 failures" - <> todo_suffix - <> skipped_suffix + <> suffix(state) io.println(yellow(message)) 1 } @@ -54,14 +44,25 @@ pub fn finished(state: State) -> Int { <> " passed, " <> int.to_string(state.failed) <> " failures" - <> todo_suffix - <> skipped_suffix + <> suffix(state) io.println(red(message)) 1 } } } +fn suffix(state: State) -> String { + let todo_part = case state.todos { + 0 -> "" + n -> ", " <> int.to_string(n) <> " todo" + } + let skipped_part = case state.skipped { + 0 -> "" + n -> ", " <> int.to_string(n) <> " skipped" + } + todo_part <> skipped_part +} + pub fn test_passed(state: State) -> State { io.print(green(".")) State(..state, passed: state.passed + 1) diff --git a/test/gleeunit_test_ffi.mjs b/test/gleeunit_test_ffi.mjs index 665e935..c59d0bd 100644 --- a/test/gleeunit_test_ffi.mjs +++ b/test/gleeunit_test_ffi.mjs @@ -9,11 +9,22 @@ export function rescue(f) { } export function suppress_output(f) { - const oldWrite = process.stdout.write; - process.stdout.write = () => true; + const saved = {}; + if (typeof process === "object" && process.stdout?.write) { + saved.processWrite = process.stdout.write; + process.stdout.write = () => true; + } + if (typeof Deno === "object" && Deno.stdout?.writeSync) { + saved.denoWriteSync = Deno.stdout.writeSync; + Deno.stdout.writeSync = () => 0; + } + const oldLog = console.log; + console.log = () => {}; try { return f(); } finally { - process.stdout.write = oldWrite; + if (saved.processWrite) process.stdout.write = saved.processWrite; + if (saved.denoWriteSync) Deno.stdout.writeSync = saved.denoWriteSync; + console.log = oldLog; } } From 1033bb7c57b0878e8d87306af0b8d4df9e89616d Mon Sep 17 00:00:00 2001 From: richardcocks Date: Wed, 18 Mar 2026 20:11:51 +0000 Subject: [PATCH 5/5] Use dictionary for process to handle multi-test --- src/gleeunit_progress.erl | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/gleeunit_progress.erl b/src/gleeunit_progress.erl index f21e1ac..4f5342a 100644 --- a/src/gleeunit_progress.erl +++ b/src/gleeunit_progress.erl @@ -56,7 +56,11 @@ handle_cancel(test, Data, State) -> {{AtomModule, AtomFunction, _Arity}, undefined} -> %% Test was cancelled without a reason (e.g. linked process crash). %% Store test info for when the group cancel arrives with the reason. - put(gleeunit_cancelled_test, {AtomModule, AtomFunction}), + Existing = case get(gleeunit_cancelled_tests) of + undefined -> []; + L -> L + end, + put(gleeunit_cancelled_tests, [{AtomModule, AtomFunction} | Existing]), State; _ -> State @@ -67,13 +71,15 @@ handle_cancel(group, Data, State) -> State; Reason -> Exception = extract_exception(Reason), - case erase(gleeunit_cancelled_test) of - {AtomModule, AtomFunction} -> - Module = erlang:atom_to_binary(AtomModule), - Function = erlang:atom_to_binary(AtomFunction), - ?reporting:test_failed(State, Module, Function, Exception); + case erase(gleeunit_cancelled_tests) of undefined -> - ?reporting:test_failed(State, <<"gleeunit">>, <<"main">>, Exception) + ?reporting:test_failed(State, <<"gleeunit">>, <<"main">>, Exception); + Tests -> + lists:foldl(fun({AtomModule, AtomFunction}, Acc) -> + Module = erlang:atom_to_binary(AtomModule), + Function = erlang:atom_to_binary(AtomFunction), + ?reporting:test_failed(Acc, Module, Function, Exception) + end, State, Tests) end end; handle_cancel(_Other, _Data, State) ->