1%% Copyright 2014 Sean Cribbs
2%%
3%% Licensed under the Apache License, Version 2.0 (the "License");
4%% you may not use this file except in compliance with the License.
5%% You may obtain a copy of the License at
6%%
7%%   http://www.apache.org/licenses/LICENSE-2.0
8%%
9%% Unless required by applicable law or agreed to in writing, software
10%% distributed under the License is distributed on an "AS IS" BASIS,
11%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12%% See the License for the specific language governing permissions and
13%% limitations under the License.
14
15
16%% @doc A listener/reporter for eunit that prints '.' for each
17%% success, 'F' for each failure, and 'E' for each error. It can also
18%% optionally summarize the failures at the end.
19-module(eunit_progress).
20-behaviour(eunit_listener).
21-define(NOTEST, true).
22-include_lib("eunit/include/eunit.hrl").
23
24-define(RED, "\e[0;31m").
25-define(GREEN, "\e[0;32m").
26-define(YELLOW, "\e[0;33m").
27-define(WHITE, "\e[0;37m").
28-define(CYAN, "\e[0;36m").
29-define(RESET, "\e[0m").
30
31%% eunit_listener callbacks
32-export([
33         init/1,
34         handle_begin/3,
35         handle_end/3,
36         handle_cancel/3,
37         terminate/2
38        ]).
39
40-export([
41         start/0,
42         start/1
43        ]).
44
45-ifdef(namespaced_dicts).
46-type euf_dict() :: dict:dict().
47-else.
48-type euf_dict() :: dict().
49-endif.
50
51-record(state, {
52          status = dict:new() :: euf_dict(),
53          failures = [] :: [[pos_integer()]],
54          skips = [] :: [[pos_integer()]],
55          timings = binomial_heap:new() :: binomial_heap:binomial_heap(),
56          colored = true :: boolean(),
57          profile = false :: boolean()
58         }).
59
60%% Startup
61start() ->
62    start([]).
63
64start(Options) ->
65    eunit_listener:start(?MODULE, Options).
66
67%%------------------------------------------
68%% eunit_listener callbacks
69%%------------------------------------------
70init(Options) ->
71    #state{colored=proplists:get_bool(colored, Options),
72           profile=proplists:get_bool(profile, Options)}.
73
74handle_begin(group, Data, St) ->
75    GID = proplists:get_value(id, Data),
76    Dict = St#state.status,
77    St#state{status=dict:store(GID, orddict:from_list([{type, group}|Data]), Dict)};
78handle_begin(test, Data, St) ->
79    TID = proplists:get_value(id, Data),
80    Dict = St#state.status,
81    St#state{status=dict:store(TID, orddict:from_list([{type, test}|Data]), Dict)}.
82
83handle_end(group, Data, St) ->
84    St#state{status=merge_on_end(Data, St#state.status)};
85handle_end(test, Data, St) ->
86    NewStatus = merge_on_end(Data, St#state.status),
87    St1 = print_progress(Data, St),
88    St2 = record_timing(Data, St1),
89    St2#state{status=NewStatus}.
90
91handle_cancel(_, Data, #state{status=Status, skips=Skips}=St) ->
92    Status1 = merge_on_end(Data, Status),
93    ID = proplists:get_value(id, Data),
94    St#state{status=Status1, skips=[ID|Skips]}.
95
96terminate({ok, Data}, St) ->
97    print_failures(St),
98    print_pending(St),
99    print_profile(St),
100    print_timing(St),
101    print_results(Data, St);
102terminate({error, Reason}, St) ->
103    io:nl(), io:nl(),
104    print_colored(io_lib:format("Eunit failed: ~25p~n", [Reason]), ?RED, St),
105    sync_end(error).
106
107sync_end(Result) ->
108    receive
109        {stop, Reference, ReplyTo} ->
110            ReplyTo ! {result, Reference, Result},
111            ok
112    end.
113
114%%------------------------------------------
115%% Print and collect information during run
116%%------------------------------------------
117print_progress(Data, St) ->
118    TID = proplists:get_value(id, Data),
119    case proplists:get_value(status, Data) of
120        ok ->
121            print_progress_success(St),
122            St;
123        {skipped, _Reason} ->
124            print_progress_skipped(St),
125            St#state{skips=[TID|St#state.skips]};
126        {error, Exception} ->
127            print_progress_failed(Exception, St),
128            St#state{failures=[TID|St#state.failures]}
129    end.
130
131record_timing(Data, State=#state{timings=T, profile=true}) ->
132    TID = proplists:get_value(id, Data),
133    case lists:keyfind(time, 1, Data) of
134        {time, Int} ->
135            %% It's a min-heap, so we insert negative numbers instead
136            %% of the actuals and normalize when we report on them.
137            T1 = binomial_heap:insert(-Int, TID, T),
138            State#state{timings=T1};
139        false ->
140            State
141    end;
142record_timing(_Data, State) ->
143    State.
144
145print_progress_success(St) ->
146    print_colored(".", ?GREEN, St).
147
148print_progress_skipped(St) ->
149    print_colored("*", ?YELLOW, St).
150
151print_progress_failed(_Exc, St) ->
152    print_colored("F", ?RED, St).
153
154merge_on_end(Data, Dict) ->
155    ID = proplists:get_value(id, Data),
156    dict:update(ID,
157                fun(Old) ->
158                        orddict:merge(fun merge_data/3, Old, orddict:from_list(Data))
159                end, Dict).
160
161merge_data(_K, undefined, X) -> X;
162merge_data(_K, X, undefined) -> X;
163merge_data(_K, _, X) -> X.
164
165%%------------------------------------------
166%% Print information at end of run
167%%------------------------------------------
168print_failures(#state{failures=[]}) ->
169    ok;
170print_failures(#state{failures=Fails}=State) ->
171    io:nl(),
172    io:fwrite("Failures:~n~n",[]),
173    lists:foldr(print_failure_fun(State), 1, Fails),
174    ok.
175
176print_failure_fun(#state{status=Status}=State) ->
177    fun(Key, Count) ->
178            TestData = dict:fetch(Key, Status),
179            TestId = format_test_identifier(TestData),
180            io:fwrite("  ~p) ~ts~n", [Count, TestId]),
181            print_failure_reason(proplists:get_value(status, TestData),
182                                 proplists:get_value(output, TestData),
183                                 State),
184            io:nl(),
185            Count + 1
186    end.
187
188print_failure_reason({skipped, Reason}, _Output, State) ->
189    print_colored(io_lib:format("     ~ts~n", [format_pending_reason(Reason)]),
190                  ?RED, State);
191print_failure_reason({error, {_Class, Term, Stack}}, Output, State) when
192      is_tuple(Term), tuple_size(Term) == 2, is_list(element(2, Term)) ->
193    print_assertion_failure(Term, Stack, Output, State),
194    print_failure_output(5, Output, State);
195print_failure_reason({error, Reason}, Output, State) ->
196    print_colored(indent(5, "Failure/Error: ~p~n", [Reason]), ?RED, State),
197    print_failure_output(5, Output, State).
198
199print_failure_output(_, <<>>, _) -> ok;
200print_failure_output(_, undefined, _) -> ok;
201print_failure_output(Indent, Output, State) ->
202    print_colored(indent(Indent, "Output: ~ts", [Output]), ?CYAN, State).
203
204print_assertion_failure({Type, Props}, Stack, Output, State) ->
205    FailureDesc = format_assertion_failure(Type, Props, 5),
206    {M,F,A,Loc} = lists:last(Stack),
207    LocationText = io_lib:format("     %% ~ts:~p:in `~ts`", [proplists:get_value(file, Loc),
208                                                           proplists:get_value(line, Loc),
209                                                           format_function_name(M,F,A)]),
210    print_colored(FailureDesc, ?RED, State),
211    io:nl(),
212    print_colored(LocationText, ?CYAN, State),
213    io:nl(),
214    print_failure_output(5, Output, State),
215    io:nl().
216
217print_pending(#state{skips=[]}) ->
218    ok;
219print_pending(#state{status=Status, skips=Skips}=State) ->
220    io:nl(),
221    io:fwrite("Pending:~n", []),
222    lists:foreach(fun(ID) ->
223                          Info = dict:fetch(ID, Status),
224                          case proplists:get_value(reason, Info) of
225                              undefined ->
226                                  ok;
227                              Reason ->
228                                  print_pending_reason(Reason, Info, State)
229                          end
230                  end, lists:reverse(Skips)),
231    io:nl().
232
233print_pending_reason(Reason0, Data, State) ->
234    Text = case proplists:get_value(type, Data) of
235               group ->
236                   io_lib:format("  ~ts~n", [proplists:get_value(desc, Data)]);
237               test ->
238                   io_lib:format("  ~ts~n", [format_test_identifier(Data)])
239           end,
240    Reason = io_lib:format("    %% ~ts~n", [format_pending_reason(Reason0)]),
241    print_colored(Text, ?YELLOW, State),
242    print_colored(Reason, ?CYAN, State).
243
244print_profile(#state{timings=T, status=Status, profile=true}=State) ->
245    TopN = binomial_heap:take(10, T),
246    TopNTime = abs(lists:sum([ Time || {Time, _} <- TopN ])),
247    TLG = dict:fetch([], Status),
248    TotalTime = proplists:get_value(time, TLG),
249    if TotalTime =/= undefined andalso TotalTime > 0 andalso TopN =/= [] ->
250            TopNPct = (TopNTime / TotalTime) * 100,
251            io:nl(), io:nl(),
252            io:fwrite("Top ~p slowest tests (~ts, ~.1f% of total time):", [length(TopN), format_time(TopNTime), TopNPct]),
253            lists:foreach(print_timing_fun(State), TopN),
254            io:nl();
255       true -> ok
256    end;
257print_profile(#state{profile=false}) ->
258    ok.
259
260print_timing(#state{status=Status}) ->
261    TLG = dict:fetch([], Status),
262    Time = proplists:get_value(time, TLG),
263    io:nl(),
264    io:fwrite("Finished in ~ts~n", [format_time(Time)]),
265    ok.
266
267print_results(Data, State) ->
268    Pass = proplists:get_value(pass, Data, 0),
269    Fail = proplists:get_value(fail, Data, 0),
270    Skip = proplists:get_value(skip, Data, 0),
271    Cancel = proplists:get_value(cancel, Data, 0),
272    Total = Pass + Fail + Skip + Cancel,
273    {Color, Result} = if Fail > 0 -> {?RED, error};
274                         Skip > 0; Cancel > 0 -> {?YELLOW, error};
275                         Pass =:= 0 -> {?YELLOW, ok};
276                         true -> {?GREEN, ok}
277                      end,
278    print_results(Color, Total, Fail, Skip, Cancel, State),
279    sync_end(Result).
280
281print_results(Color, 0, _, _, _, State) ->
282    print_colored(Color, "0 tests\n", State);
283print_results(Color, Total, Fail, Skip, Cancel, State) ->
284    SkipText = format_optional_result(Skip, "skipped"),
285    CancelText = format_optional_result(Cancel, "cancelled"),
286    Text = io_lib:format("~p tests, ~p failures~ts~ts~n", [Total, Fail, SkipText, CancelText]),
287    print_colored(Text, Color, State).
288
289print_timing_fun(#state{status=Status}=State) ->
290    fun({Time, Key}) ->
291            TestData = dict:fetch(Key, Status),
292            TestId = format_test_identifier(TestData),
293            io:nl(),
294            io:fwrite("  ~ts~n", [TestId]),
295            print_colored(["    "|format_time(abs(Time))], ?CYAN, State)
296    end.
297
298%%------------------------------------------
299%% Print to the console with the given color
300%% if enabled.
301%%------------------------------------------
302print_colored(Text, Color, #state{colored=true}) ->
303    io:fwrite("~s~ts~s", [Color, Text, ?RESET]);
304print_colored(Text, _Color, #state{colored=false}) ->
305    io:fwrite("~ts", [Text]).
306
307%%------------------------------------------
308%% Generic data formatters
309%%------------------------------------------
310format_function_name(M, F, A) ->
311    io_lib:format("~ts:~ts/~p", [M, F, A]).
312
313format_optional_result(0, _) ->
314    [];
315format_optional_result(Count, Text) ->
316    io_lib:format(", ~p ~ts", [Count, Text]).
317
318format_test_identifier(Data) ->
319    {Mod, Fun, Arity} = proplists:get_value(source, Data),
320    Line = case proplists:get_value(line, Data) of
321               0 -> "";
322               L -> io_lib:format(":~p", [L])
323           end,
324    Desc = case proplists:get_value(desc, Data) of
325               undefined ->  "";
326               DescText -> io_lib:format(": ~ts", [DescText])
327           end,
328    io_lib:format("~ts~ts~ts", [format_function_name(Mod, Fun, Arity), Line, Desc]).
329
330format_time(undefined) ->
331    "? seconds";
332format_time(Time) ->
333    io_lib:format("~.3f seconds", [Time / 1000]).
334
335format_pending_reason({module_not_found, M}) ->
336    io_lib:format("Module '~ts' missing", [M]);
337format_pending_reason({no_such_function, {M,F,A}}) ->
338    io_lib:format("Function ~ts undefined", [format_function_name(M,F,A)]);
339format_pending_reason({exit, Reason}) ->
340    io_lib:format("Related process exited with reason: ~p", [Reason]);
341format_pending_reason(Reason) ->
342    io_lib:format("Unknown error: ~p", [Reason]).
343
344%% @doc Formats all the known eunit assertions, you're on your own if
345%% you make an assertion yourself.
346format_assertion_failure(Type, Props, I) when Type =:= assertion_failed
347                                            ; Type =:= assert ->
348    Keys = proplists:get_keys(Props),
349    HasEUnitProps = ([expression, value] -- Keys) =:= [],
350    HasHamcrestProps = ([expected, actual, matcher] -- Keys) =:= [],
351    if
352        HasEUnitProps ->
353            [indent(I, "Failure/Error: ?assert(~ts)~n", [proplists:get_value(expression, Props)]),
354             indent(I, "  expected: true~n", []),
355             case proplists:get_value(value, Props) of
356                 false ->
357                     indent(I, "       got: false", []);
358                 {not_a_boolean, V} ->
359                     indent(I, "       got: ~p", [V])
360             end];
361        HasHamcrestProps ->
362            [indent(I, "Failure/Error: ?assertThat(~p)~n", [proplists:get_value(matcher, Props)]),
363             indent(I, "  expected: ~p~n", [proplists:get_value(expected, Props)]),
364             indent(I, "       got: ~p", [proplists:get_value(actual, Props)])];
365        true ->
366            [indent(I, "Failure/Error: unknown assert: ~p", [Props])]
367    end;
368
369format_assertion_failure(Type, Props, I) when Type =:= assertMatch_failed
370                                            ; Type =:= assertMatch ->
371    Expr = proplists:get_value(expression, Props),
372    Pattern = proplists:get_value(pattern, Props),
373    Value = proplists:get_value(value, Props),
374    [indent(I, "Failure/Error: ?assertMatch(~ts, ~ts)~n", [Pattern, Expr]),
375     indent(I, "  expected: = ~ts~n", [Pattern]),
376     indent(I, "       got: ~p", [Value])];
377
378format_assertion_failure(Type, Props, I) when Type =:= assertNotMatch_failed
379                                                             ; Type =:= assertNotMatch  ->
380    Expr = proplists:get_value(expression, Props),
381    Pattern = proplists:get_value(pattern, Props),
382    Value = proplists:get_value(value, Props),
383    [indent(I, "Failure/Error: ?assertNotMatch(~ts, ~ts)~n", [Pattern, Expr]),
384     indent(I, "  expected not: = ~ts~n", [Pattern]),
385     indent(I, "           got:   ~p", [Value])];
386
387format_assertion_failure(Type, Props, I) when Type =:= assertEqual_failed
388                                            ; Type =:= assertEqual  ->
389    Expr = proplists:get_value(expression, Props),
390    Expected = proplists:get_value(expected, Props),
391    Value = proplists:get_value(value, Props),
392    [indent(I, "Failure/Error: ?assertEqual(~w, ~ts)~n", [Expected,
393                                                         Expr]),
394     indent(I, "  expected: ~p~n", [Expected]),
395     indent(I, "       got: ~p", [Value])];
396
397format_assertion_failure(Type, Props, I) when Type =:= assertNotEqual_failed
398                                            ; Type =:= assertNotEqual ->
399    Expr = proplists:get_value(expression, Props),
400    Value = proplists:get_value(value, Props),
401    [indent(I, "Failure/Error: ?assertNotEqual(~p, ~ts)~n",
402            [Value, Expr]),
403     indent(I, "  expected not: == ~p~n", [Value]),
404     indent(I, "           got:    ~p", [Value])];
405
406format_assertion_failure(Type, Props, I) when Type =:= assertException_failed
407                                            ; Type =:= assertException ->
408    Expr = proplists:get_value(expression, Props),
409    Pattern = proplists:get_value(pattern, Props),
410    {Class, Term} = extract_exception_pattern(Pattern), % I hate that we have to do this, why not just give DATA
411    [indent(I, "Failure/Error: ?assertException(~ts, ~ts, ~ts)~n", [Class, Term, Expr]),
412     case proplists:is_defined(unexpected_success, Props) of
413         true ->
414             [indent(I, "  expected: exception ~ts but nothing was raised~n", [Pattern]),
415              indent(I, "       got: value ~p", [proplists:get_value(unexpected_success, Props)])];
416         false ->
417             Ex = proplists:get_value(unexpected_exception, Props),
418             [indent(I, "  expected: exception ~ts~n", [Pattern]),
419              indent(I, "       got: exception ~p", [Ex])]
420     end];
421
422format_assertion_failure(Type, Props, I) when Type =:= assertNotException_failed
423                                            ; Type =:= assertNotException ->
424    Expr = proplists:get_value(expression, Props),
425    Pattern = proplists:get_value(pattern, Props),
426    {Class, Term} = extract_exception_pattern(Pattern), % I hate that we have to do this, why not just give DAT
427    Ex = proplists:get_value(unexpected_exception, Props),
428    [indent(I, "Failure/Error: ?assertNotException(~ts, ~ts, ~ts)~n", [Class, Term, Expr]),
429     indent(I, "  expected not: exception ~ts~n", [Pattern]),
430     indent(I, "           got: exception ~p", [Ex])];
431
432format_assertion_failure(Type, Props, I) when Type =:= command_failed
433                                            ; Type =:= command ->
434    Cmd = proplists:get_value(command, Props),
435    Expected = proplists:get_value(expected_status, Props),
436    Status = proplists:get_value(status, Props),
437    [indent(I, "Failure/Error: ?cmdStatus(~p, ~p)~n", [Expected, Cmd]),
438     indent(I, "  expected: status ~p~n", [Expected]),
439     indent(I, "       got: status ~p", [Status])];
440
441format_assertion_failure(Type, Props, I) when Type =:= assertCmd_failed
442                                            ; Type =:= assertCmd ->
443    Cmd = proplists:get_value(command, Props),
444    Expected = proplists:get_value(expected_status, Props),
445    Status = proplists:get_value(status, Props),
446    [indent(I, "Failure/Error: ?assertCmdStatus(~p, ~p)~n", [Expected, Cmd]),
447     indent(I, "  expected: status ~p~n", [Expected]),
448     indent(I, "       got: status ~p", [Status])];
449
450format_assertion_failure(Type, Props, I) when Type =:= assertCmdOutput_failed
451                                            ; Type =:= assertCmdOutput ->
452    Cmd = proplists:get_value(command, Props),
453    Expected = proplists:get_value(expected_output, Props),
454    Output = proplists:get_value(output, Props),
455    [indent(I, "Failure/Error: ?assertCmdOutput(~p, ~p)~n", [Expected, Cmd]),
456     indent(I, "  expected: ~p~n", [Expected]),
457     indent(I, "       got: ~p", [Output])];
458
459format_assertion_failure(Type, Props, I) ->
460    indent(I, "~p", [{Type, Props}]).
461
462indent(I, Fmt, Args) ->
463    io_lib:format("~" ++ integer_to_list(I) ++ "s" ++ Fmt, [" "|Args]).
464
465extract_exception_pattern(Str) ->
466    ["{", Class, Term|_] = re:split(Str, "[, ]{1,2}", [unicode,{return,list}]),
467    {Class, Term}.
468