rebar3/src/rebar_prv_common_test.erl

960 lines
38 KiB
Erlang

%% -*- erlang-indent-level: 4;indent-tabs-mode: nil -*-
%% ex: ts=4 sw=4 et
-module(rebar_prv_common_test).
-behaviour(provider).
-export([init/1,
do/1,
format_error/1]).
-ifdef(TEST).
%% exported for test purposes
-export([compile/2, prepare_tests/1, translate_paths/2, maybe_write_coverdata/1]).
-endif.
-include("rebar.hrl").
-include_lib("providers/include/providers.hrl").
-define(PROVIDER, ct).
%% we need to modify app_info state before compile
-define(DEPS, [lock]).
%% ===================================================================
%% Public API
%% ===================================================================
-spec init(rebar_state:t()) -> {ok, rebar_state:t()}.
init(State) ->
Provider = providers:create([{name, ?PROVIDER},
{module, ?MODULE},
{deps, ?DEPS},
{bare, true},
{example, "rebar3 ct"},
{short_desc, "Run Common Tests."},
{desc, "Run Common Tests."},
{opts, ct_opts(State)},
{profiles, [test]}]),
{ok, rebar_state:add_provider(State, Provider)}.
-spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}.
do(State) ->
setup_name(State),
Tests = prepare_tests(State),
case compile(State, Tests) of
%% successfully compiled apps
{ok, S} ->
{RawOpts, _} = rebar_state:command_parsed_args(S),
case proplists:get_value(compile_only, RawOpts, false) of
true ->
{ok, S};
false ->
do(S, Tests)
end;
%% this should look like a compiler error, not a ct error
Error -> Error
end.
do(State, Tests) ->
?INFO("Running Common Test suites...", []),
rebar_paths:set_paths([deps, plugins], State),
%% Run ct provider prehooks
Providers = rebar_state:providers(State),
Cwd = rebar_dir:get_cwd(),
%% Run ct provider pre hooks for all project apps and top level project hooks
rebar_hooks:run_project_and_app_hooks(Cwd, pre, ?PROVIDER, Providers, State),
case Tests of
{ok, T} ->
case run_tests(State, T) of
ok ->
%% Run ct provider post hooks for all project apps and top level project hooks
rebar_hooks:run_project_and_app_hooks(Cwd, post, ?PROVIDER, Providers, State),
rebar_paths:set_paths([plugins, deps], State),
symlink_to_last_ct_logs(State, T),
{ok, State};
Error ->
rebar_paths:set_paths([plugins, deps], State),
symlink_to_last_ct_logs(State, T),
Error
end;
Error ->
rebar_paths:set_paths([plugins, deps], State),
Error
end.
run_tests(State, Opts) ->
T = translate_paths(State, Opts),
Opts1 = setup_logdir(State, T),
Opts2 = turn_off_auto_compile(Opts1),
?DEBUG("Running tests with {ct_opts, ~p}.", [Opts2]),
{RawOpts, _} = rebar_state:command_parsed_args(State),
Result = case proplists:get_value(verbose, RawOpts, false) of
true -> run_test_verbose(Opts2);
false -> run_test_quiet(Opts2)
end,
ok = maybe_write_coverdata(State),
Result.
-spec format_error(any()) -> iolist().
format_error({error, Reason}) ->
io_lib:format("Error running tests:~n ~p", [Reason]);
format_error({error_running_tests, Reason}) ->
format_error({error, Reason});
format_error({failures_running_tests, {Failed, AutoSkipped}}) ->
io_lib:format("Failures occurred running tests: ~b", [Failed+AutoSkipped]);
format_error({badconfig, {Msg, {Value, Key}}}) ->
io_lib:format(Msg, [Value, Key]);
format_error({badconfig, Msg}) ->
io_lib:format(Msg, []);
format_error({multiple_errors, Errors}) ->
io_lib:format(lists:concat(["Error running tests:"] ++
lists:map(fun(Error) -> "~n " ++ Error end, Errors)), []);
format_error({error_reading_testspec, Reason}) ->
io_lib:format("Error reading testspec: ~p", [Reason]).
%% ===================================================================
%% Internal functions
%% ===================================================================
%% @doc Tries to make the symlink `_build/<profile>/logs/last' to the `ct_run' directory
%% of the last common test run.
-spec symlink_to_last_ct_logs(rebar_state:t(), list()) -> ok.
symlink_to_last_ct_logs(State, Opts) ->
LogDir = case proplists:get_value(logdir, Opts) of
undefined -> filename:join([rebar_dir:base_dir(State), "logs"]);
Dir -> Dir
end,
{ok, Filenames} = file:list_dir(LogDir),
CtRunDirs = lists:filter(fun(S) -> re:run(S, "ct_run", [unicode]) /= nomatch end, Filenames),
case CtRunDirs of
[] ->
% If for some reason there are no such directories, we should not try to set up a link either.
ok;
_ ->
NewestDir = lists:last(lists:sort(CtRunDirs)),
Target = filename:join([LogDir, "last"]),
Existing = filename:join([LogDir, NewestDir]),
case rebar_file_utils:symlink_or_copy(Existing, Target) of
ok -> ok;
exists ->
%% in case the symlink already exists we remove it
%% and make a new updated one
rebar_file_utils:rm_rf(Target),
rebar_file_utils:symlink_or_copy(Existing, Target);
Reason -> ?DIAGNOSTIC("Warning, couldn't make a symlink to ~ts, reason: ~p.", [Target, Reason])
end
end.
setup_name(State) ->
{Long, Short, Opts} = rebar_dist_utils:find_options(State),
rebar_dist_utils:either(Long, Short, Opts).
prepare_tests(State) ->
%% command line test options
CmdOpts = cmdopts(State),
%% rebar.config test options
CfgOpts = cfgopts(State),
ProjectApps = rebar_state:project_apps(State),
%% prioritize tests to run first trying any command line specified
%% tests falling back to tests specified in the config file finally
%% running a default set if no other tests are present
select_tests(State, ProjectApps, CmdOpts, CfgOpts).
cmdopts(State) ->
{RawOpts, _} = rebar_state:command_parsed_args(State),
%% filter out opts common_test doesn't know about and convert
%% to ct acceptable forms
transform_retry(transform_opts(RawOpts, []), State).
transform_opts([], Acc) -> lists:reverse(Acc);
transform_opts([{dir, Dirs}|Rest], Acc) ->
transform_opts(Rest, [{dir, split_string(Dirs)}|Acc]);
transform_opts([{suite, Suites}|Rest], Acc) ->
transform_opts(Rest, [{suite, split_string(Suites)}|Acc]);
transform_opts([{group, Groups}|Rest], Acc) ->
transform_opts(Rest, [{group, transform_group(Groups)}|Acc]);
transform_opts([{testcase, Cases}|Rest], Acc) ->
transform_opts(Rest, [{testcase, split_string(Cases)}|Acc]);
transform_opts([{config, Configs}|Rest], Acc) ->
transform_opts(Rest, [{config, split_string(Configs)}|Acc]);
transform_opts([{spec, Specs}|Rest], Acc) ->
transform_opts(Rest, [{spec, split_string(Specs)}|Acc]);
transform_opts([{include, Includes}|Rest], Acc) ->
transform_opts(Rest, [{include, split_string(Includes)}|Acc]);
transform_opts([{logopts, LogOpts}|Rest], Acc) ->
transform_opts(Rest, [{logopts, lists:map(fun(P) -> list_to_atom(P) end, split_string(LogOpts))}|Acc]);
transform_opts([{force_stop, "true"}|Rest], Acc) ->
transform_opts(Rest, [{force_stop, true}|Acc]);
transform_opts([{force_stop, "false"}|Rest], Acc) ->
transform_opts(Rest, [{force_stop, false}|Acc]);
transform_opts([{force_stop, "skip_rest"}|Rest], Acc) ->
transform_opts(Rest, [{force_stop, skip_rest}|Acc]);
transform_opts([{create_priv_dir, CreatePrivDir}|Rest], Acc) ->
transform_opts(Rest, [{create_priv_dir, list_to_atom(CreatePrivDir)}|Acc]);
%% drop cover from opts, ct doesn't care about it
transform_opts([{cover, _}|Rest], Acc) ->
transform_opts(Rest, Acc);
%% drop verbose from opts, ct doesn't care about it
transform_opts([{verbose, _}|Rest], Acc) ->
transform_opts(Rest, Acc);
%% drop fail_fast from opts, ct doesn't care about it
transform_opts([{fail_fast, _}|Rest], Acc) ->
transform_opts(Rest, Acc);
%% getopt should handle anything else
transform_opts([Opt|Rest], Acc) ->
transform_opts(Rest, [Opt|Acc]).
%% @private only retry if specified and if no other spec
%% is given.
transform_retry(Opts, State) ->
case proplists:get_value(retry, Opts, false) andalso
not is_any_defined([spec,dir,suite], Opts) of
false ->
Opts;
true ->
Path = filename:join([rebar_dir:base_dir(State), "logs", "retry.spec"]),
filelib:is_file(Path) andalso [{spec, Path}|Opts]
end.
split_string(String) ->
rebar_string:lexemes(String, [$,]).
transform_group(String) ->
case rebar_string:consult([$[, String, $], $.]) of
[Terms] when is_list(Terms) ->
Terms;
Terms when is_list(Terms) ->
Terms;
{error, _} ->
%% try a normal string split
split_string(String)
end.
cfgopts(State) ->
case rebar_state:get(State, ct_opts, []) of
Opts when is_list(Opts) ->
ensure_opts(add_hooks(Opts, State), []);
Wrong ->
%% probably a single non list term
?PRV_ERROR({badconfig, {"Value `~p' of option `~p' must be a list", {Wrong, ct_opts}}})
end.
ensure_opts([], Acc) -> lists:reverse(Acc);
ensure_opts([{cover, _}|Rest], Acc) ->
?WARN("Cover specs not supported. See https://www.rebar3.org/docs/testing/ct/", []),
ensure_opts(Rest, Acc);
ensure_opts([{auto_compile, _}|Rest], Acc) ->
?WARN("Auto compile not supported", []),
ensure_opts(Rest, Acc);
ensure_opts([{suite, Suite}|Rest], Acc) when is_integer(hd(Suite)) ->
ensure_opts(Rest, [{suite, Suite}|Acc]);
ensure_opts([{suite, Suite}|Rest], Acc) when is_atom(Suite) ->
ensure_opts(Rest, [{suite, atom_to_list(Suite)}|Acc]);
ensure_opts([{suite, Suites}|Rest], Acc) ->
NewSuites = {suite, lists:map(fun(S) when is_atom(S) -> atom_to_list(S);
(S) when is_list(S) -> S
end,
Suites)},
ensure_opts(Rest, [NewSuites|Acc]);
ensure_opts([{K, V}|Rest], Acc) ->
ensure_opts(Rest, [{K, V}|Acc]);
%% pass through other options, in case of things like config terms
%% in `ct_opts`
ensure_opts([V|Rest], Acc) ->
ensure_opts(Rest, [V|Acc]).
add_hooks(Opts, State) ->
FailFast = case fails_fast(State) of
true -> [cth_fail_fast];
false -> []
end,
case {readable(State), lists:keyfind(ct_hooks, 1, Opts)} of
{false, _} ->
Opts;
{Other, false} ->
[{ct_hooks, [cth_readable_failonly, readable_shell_type(Other),
cth_retry] ++ FailFast} | Opts];
{Other, {ct_hooks, Hooks}} ->
%% Make sure hooks are there once only and add wanted hooks that are not defined yet
ReadableHooks = [cth_readable_failonly, readable_shell_type(Other),
cth_retry] ++ FailFast,
NewHooks = Hooks ++ [ReadableHook ||
ReadableHook <- ReadableHooks,
not is_defined(ReadableHook, Hooks)
],
lists:keyreplace(ct_hooks, 1, Opts, {ct_hooks, NewHooks})
end.
is_defined(_Key, []) -> false;
is_defined(Key, [Key | _Hs]) -> true;
is_defined(Key, [{Key, _Opts} | _Hs]) -> true;
is_defined(Key, [{Key, _Opts, _Prio} | _Hs]) -> true;
is_defined(Key, [_ | Hs]) -> is_defined(Key, Hs).
readable_shell_type(true) -> cth_readable_shell;
readable_shell_type(compact) -> cth_readable_compact_shell.
select_tests(_, _, _, {error, _} = Error) -> Error;
select_tests(State, ProjectApps, CmdOpts, CfgOpts) ->
%% set application env if sys_config argument is provided
SysConfigs = sys_config_list(CmdOpts, CfgOpts),
Configs = lists:flatmap(fun(Filename) ->
rebar_file_utils:consult_config(State, Filename)
end, SysConfigs),
%% NB: load the applications (from user directories too) to support OTP < 17
%% to our best ability.
rebar_paths:set_paths([deps, plugins], State),
[application:load(Application) || Config <- Configs, {Application, _} <- Config],
rebar_utils:reread_config(Configs, [update_logger]),
Opts = merge_opts(CmdOpts,CfgOpts),
discover_tests(State, ProjectApps, Opts).
%% Merge the option lists from command line and rebar.config:
%%
%% - Options set on the command line will replace the same options if
%% set in rebar.config.
%%
%% - Special care is taken with options that select which tests to
%% run - ANY such option on the command line will replace ALL such
%% options in the config.
%%
%% Note that if 'spec' is given, common_test will ignore all 'dir',
%% 'suite', 'group' and 'case', so there is no need to explicitly
%% remove any options from the command line.
%%
%% All faulty combinations of options are also handled by
%% common_test and are not taken into account here.
merge_opts(CmdOpts0, CfgOpts0) ->
TestSelectOpts = [spec,dir,suite,group,testcase],
CmdOpts = lists:ukeysort(1, CmdOpts0),
CfgOpts1 = lists:ukeysort(1, CfgOpts0),
CfgOpts = case is_any_defined(TestSelectOpts,CmdOpts) of
false ->
CfgOpts1;
true ->
[Opt || Opt={K,_} <- CfgOpts1,
not lists:member(K,TestSelectOpts)]
end,
lists:ukeymerge(1, CmdOpts, CfgOpts).
is_any_defined([Key|Keys],Opts) ->
proplists:is_defined(Key,Opts) orelse is_any_defined(Keys,Opts);
is_any_defined([],_Opts) ->
false.
sys_config_list(CmdOpts, CfgOpts) ->
CmdSysConfigs = split_string(proplists:get_value(sys_config, CmdOpts, "")),
case proplists:get_value(sys_config, CfgOpts, []) of
[H | _]=Configs when is_list(H) ->
Configs ++ CmdSysConfigs;
[] ->
CmdSysConfigs;
Configs ->
[Configs | CmdSysConfigs]
end.
discover_tests(State, ProjectApps, Opts) ->
case is_any_defined([spec,dir,suite],Opts) of
%% no tests defined, try using `$APP/test` and `$ROOT/test` as dirs
false -> {ok, default_tests(State, ProjectApps) ++ Opts};
true -> {ok, Opts}
end.
default_tests(State, ProjectApps) ->
BareTest = filename:join([rebar_state:dir(State), "test"]),
F = fun(App) -> rebar_app_info:dir(App) == rebar_state:dir(State) end,
AppTests = application_dirs(ProjectApps, []),
case filelib:is_dir(BareTest) andalso not lists:any(F, ProjectApps) of
%% `test` dir at root of project is already scheduled to be
%% included or `test` does not exist
false ->
%% The rest of the call-chain expects the list of tests to not be
%% empty, thus we drop the parameter in that case entirely.
case AppTests of
[] ->
[];
_ ->
[{dir, AppTests}]
end;
%% need to add `test` dir at root to dirs to be included
true ->
[{dir, AppTests ++ [BareTest]}]
end.
application_dirs([], []) -> [];
application_dirs([], Acc) -> lists:reverse(Acc);
application_dirs([App|Rest], Acc) ->
TestDir = filename:join([rebar_app_info:dir(App), "test"]),
case filelib:is_dir(TestDir) of
true -> application_dirs(Rest, [TestDir|Acc]);
false -> application_dirs(Rest, Acc)
end.
compile(State, {ok, _} = Tests) ->
%% inject `ct_first_files`, `ct_compile_opts` and `include` (from `ct_opts`
%% and command line options) into the applications to be compiled
case inject_ct_state(State, Tests) of
{ok, NewState} -> do_compile(NewState);
Error -> Error
end;
%% maybe compile even in the face of errors?
compile(_State, Error) -> Error.
do_compile(State) ->
?DEBUG("Re-compiling the project under the test profile with CT options injected...", []),
{ok, S} = rebar_prv_compile:do(State),
ok = maybe_cover_compile(S),
{ok, S}.
inject_ct_state(State, {ok, Tests}) ->
Apps = rebar_state:project_apps(State),
case inject_ct_state(State, Tests, Apps, []) of
{ok, {NewState, ModdedApps}} ->
test_dirs(NewState, ModdedApps, Tests);
{error, _} = Error -> Error
end.
inject_ct_state(State, Tests, [App|Rest], Acc) ->
case inject(rebar_app_info:opts(App), State, Tests) of
{error, _} = Error -> Error;
NewOpts ->
NewApp = rebar_app_info:opts(App, NewOpts),
inject_ct_state(State, Tests, Rest, [NewApp|Acc])
end;
inject_ct_state(State, Tests, [], Acc) ->
case inject(rebar_state:opts(State), State, Tests) of
{error, _} = Error -> Error;
NewOpts ->
{ok, {rebar_state:opts(State, NewOpts), lists:reverse(Acc)}}
end.
opts(Opts, Key, Default) ->
case rebar_opts:get(Opts, Key, Default) of
Vs when is_list(Vs) -> Vs;
Wrong ->
?PRV_ERROR({badconfig, {"Value `~p' of option `~p' must be a list", {Wrong, Key}}})
end.
inject(Opts, State, Tests) -> erl_opts(Opts, State, Tests).
erl_opts(Opts, State, Tests) ->
%% append `ct_compile_opts` to app defined `erl_opts`
ErlOpts = opts(Opts, erl_opts, []),
CTOpts = opts(Opts, ct_compile_opts, []),
case add_transforms(append(CTOpts, ErlOpts), State) of
{error, _} = Error -> Error;
NewErlOpts -> first_files(rebar_opts:set(Opts, erl_opts, NewErlOpts), Tests)
end.
first_files(Opts, Tests) ->
%% append `ct_first_files` to app defined `erl_first_files`
FirstFiles = opts(Opts, erl_first_files, []),
CTFirstFiles = opts(Opts, ct_first_files, []),
case append(CTFirstFiles, FirstFiles) of
{error, _} = Error -> Error;
NewFirstFiles -> include_files(rebar_opts:set(Opts, erl_first_files, NewFirstFiles), Tests)
end.
include_files(Opts, Tests) ->
%% append include dirs from command line and `ct_opts` to app defined
%% `erl_opts`
ErlOpts = opts(Opts, erl_opts, []),
Includes = proplists:get_value(include, Tests, []),
Is = lists:map(fun(I) -> {i, I} end, Includes),
case append(Is, ErlOpts) of
{error, _} = Error -> Error;
NewIncludes -> ct_macro(rebar_opts:set(Opts, erl_opts, NewIncludes))
end.
ct_macro(Opts) ->
ErlOpts = opts(Opts, erl_opts, []),
NewOpts = safe_define_ct_macro(ErlOpts),
rebar_opts:set(Opts, erl_opts, NewOpts).
safe_define_ct_macro(Opts) ->
%% defining a compile macro twice results in an exception so
%% make sure 'COMMON_TEST' is only defined once
case test_defined(Opts) of
true -> Opts;
false -> [{d, 'COMMON_TEST'}|Opts]
end.
test_defined([{d, 'COMMON_TEST'}|_]) -> true;
test_defined([{d, 'COMMON_TEST', true}|_]) -> true;
test_defined([_|Rest]) -> test_defined(Rest);
test_defined([]) -> false.
append({error, _} = Error, _) -> Error;
append(_, {error, _} = Error) -> Error;
append(A, B) -> A ++ B.
add_transforms(CTOpts, State) when is_list(CTOpts) ->
case readable(State) of
false ->
CTOpts;
Other when Other == true; Other == compact ->
ReadableTransform = [{parse_transform, cth_readable_transform}],
(CTOpts -- ReadableTransform) ++ ReadableTransform
end;
add_transforms({error, _} = Error, _State) -> Error.
readable(State) ->
{RawOpts, _} = rebar_state:command_parsed_args(State),
case proplists:get_value(readable, RawOpts) of
"true" -> true;
"false" -> false;
"compact" -> compact;
undefined -> rebar_state:get(State, ct_readable, compact)
end.
fails_fast(State) ->
{RawOpts, _} = rebar_state:command_parsed_args(State),
proplists:get_value(fail_fast, RawOpts) == true.
test_dirs(State, Apps, Opts) ->
case proplists:get_value(spec, Opts) of
undefined ->
case {proplists:get_value(suite, Opts), proplists:get_value(dir, Opts)} of
{undefined, undefined} ->
{ok, rebar_state:project_apps(State, Apps)};
{Suites, undefined} -> set_compile_dirs(State, Apps, {suite, Suites});
{undefined, Dirs} -> set_compile_dirs(State, Apps, {dir, Dirs});
{Suites, Dir} when is_integer(hd(Dir)) ->
set_compile_dirs(State, Apps, join(Suites, Dir));
{Suites, [Dir]} when is_integer(hd(Dir)) ->
set_compile_dirs(State, Apps, join(Suites, Dir));
{_Suites, _Dirs} -> {error, "Only a single directory may be specified when specifying suites"}
end;
Spec when is_integer(hd(Spec)) ->
spec_test_dirs(State, Apps, [Spec]);
Specs ->
spec_test_dirs(State, Apps, Specs)
end.
spec_test_dirs(State, Apps, Specs0) ->
case get_dirs_from_specs(Specs0) of
{ok,{Specs,SuiteDirs}} ->
{State1,Apps1} = set_compile_dirs1(State, Apps, {dir, SuiteDirs}),
{State2,Apps2} = set_compile_dirs1(State1, Apps1, {spec, Specs}),
[maybe_copy_spec(State2,Apps2,S) || S <- Specs],
{ok, rebar_state:project_apps(State2, Apps2)};
Error ->
Error
end.
join(Suite, Dir) when is_integer(hd(Suite)) ->
{suite, [filename:join([Dir, Suite])]};
join(Suites, Dir) ->
{suite, lists:map(fun(S) -> filename:join([Dir, S]) end, Suites)}.
set_compile_dirs(State, Apps, What) ->
{NewState,NewApps} = set_compile_dirs1(State, Apps, What),
{ok, rebar_state:project_apps(NewState, NewApps)}.
set_compile_dirs1(State, Apps, {dir, Dir}) when is_integer(hd(Dir)) ->
%% single directory
%% insert `Dir` into an app if relative, or the base state if not
%% app relative but relative to the root or not at all if outside
%% project scope
maybe_inject_test_dir(State, [], Apps, Dir);
set_compile_dirs1(State, Apps, {dir, Dirs}) ->
%% multiple directories
F = fun(Dir, {S, A}) -> maybe_inject_test_dir(S, [], A, Dir) end,
lists:foldl(F, {State, Apps}, Dirs);
set_compile_dirs1(State, Apps, {Type, Files}) when Type==spec; Type==suite ->
%% specs or suites with dir component
Dirs = find_file_dirs(Files),
F = fun(Dir, {S, A}) -> maybe_inject_test_dir(S, [], A, Dir) end,
lists:foldl(F, {State, Apps}, Dirs).
find_file_dirs(Files) ->
AllDirs = lists:map(fun(F) -> filename:dirname(filename:absname(F)) end, Files),
%% eliminate duplicates
lists:usort(AllDirs).
maybe_inject_test_dir(State, AppAcc, [App|Rest], Dir) ->
case rebar_file_utils:path_from_ancestor(Dir, rebar_app_info:dir(App)) of
{ok, []} ->
%% normal operation involves copying the entire directory a
%% suite exists in but if the suite is in the app root directory
%% the current compiler tries to compile all subdirs including priv
%% instead copy only files ending in `.erl' and directories
%% ending in `_SUITE_data' into the `_build/PROFILE/lib/APP' dir
ok = copy_bare_suites(Dir, rebar_app_info:out_dir(App)),
Opts = inject_test_dir(rebar_state:opts(State), rebar_app_info:out_dir(App)),
{rebar_state:opts(State, Opts), AppAcc ++ [App]};
{ok, Path} ->
Opts = inject_test_dir(rebar_app_info:opts(App), Path),
{State, AppAcc ++ [rebar_app_info:opts(App, Opts)] ++ Rest};
{error, badparent} ->
maybe_inject_test_dir(State, AppAcc ++ [App], Rest, Dir)
end;
maybe_inject_test_dir(State, AppAcc, [], Dir) ->
case rebar_file_utils:path_from_ancestor(Dir, rebar_state:dir(State)) of
{ok, []} ->
%% normal operation involves copying the entire directory a
%% suite exists in but if the suite is in the root directory
%% that results in a loop as we copy `_build' into itself
%% instead copy only files ending in `.erl' and directories
%% ending in `_SUITE_data' in the `_build/PROFILE/extras' dir
ExtrasDir = filename:join([rebar_dir:base_dir(State), "extras"]),
ok = copy_bare_suites(Dir, ExtrasDir),
Opts = inject_test_dir(rebar_state:opts(State), ExtrasDir),
{rebar_state:opts(State, Opts), AppAcc};
{ok, Path} ->
Opts = inject_test_dir(rebar_state:opts(State), Path),
{rebar_state:opts(State, Opts), AppAcc};
{error, badparent} ->
{State, AppAcc}
end.
copy_bare_suites(From, To) ->
filelib:ensure_dir(filename:join([To, "dummy.txt"])),
SrcFiles = rebar_utils:find_files(From, ".*\\.[e|h]rl\$", false),
DataDirs = lists:filter(fun filelib:is_dir/1,
filelib:wildcard(filename:join([From, "*_SUITE_data"]))),
ok = rebar_file_utils:cp_r(SrcFiles, To),
rebar_file_utils:cp_r(DataDirs, To).
maybe_copy_spec(State, [App|Apps], Spec) ->
case rebar_file_utils:path_from_ancestor(filename:dirname(Spec), rebar_app_info:dir(App)) of
{ok, []} ->
ok = rebar_file_utils:cp_r([Spec],rebar_app_info:out_dir(App));
{ok,_} ->
ok;
{error,badparent} ->
maybe_copy_spec(State, Apps, Spec)
end;
maybe_copy_spec(State, [], Spec) ->
case rebar_file_utils:path_from_ancestor(filename:dirname(Spec), rebar_state:dir(State)) of
{ok, []} ->
ExtrasDir = filename:join([rebar_dir:base_dir(State), "extras"]),
ok = rebar_file_utils:cp_r([Spec],ExtrasDir);
_R ->
ok
end.
inject_test_dir(Opts, Dir) ->
%% append specified test targets to app defined `extra_src_dirs`
ExtraSrcDirs = rebar_opts:get(Opts, extra_src_dirs, []),
rebar_opts:set(Opts, extra_src_dirs, ExtraSrcDirs ++ [Dir]).
get_dirs_from_specs(Specs) ->
case get_tests_from_specs(Specs) of
{ok,Tests} ->
{SpecLists,NodeRunSkipLists} = lists:unzip(Tests),
SpecList = lists:append(SpecLists),
NodeRunSkipList = lists:append(NodeRunSkipLists),
RunList = lists:append([R || {_,R,_} <- NodeRunSkipList]),
DirList = [element(1,R) || R <- RunList],
{ok,{SpecList,DirList}};
{error,Reason} ->
{error,{?MODULE,{error_reading_testspec,Reason}}}
end.
get_tests_from_specs(Specs) ->
_ = ct_testspec:module_info(), % make sure ct_testspec is loaded
case erlang:function_exported(ct_testspec,get_tests,1) of
true ->
ct_testspec:get_tests(Specs);
false ->
case ct_testspec:collect_tests_from_file(Specs,true) of
Tests when is_list(Tests) ->
{ok,[{S,ct_testspec:prepare_tests(R)} || {S,R} <- Tests]};
Error ->
Error
end
end.
translate_paths(State, Opts) ->
case proplists:get_value(spec, Opts) of
undefined ->
case {proplists:get_value(suite, Opts), proplists:get_value(dir, Opts)} of
{_Suites, undefined} -> translate_paths(State, suite, Opts, []);
{undefined, _Dirs} -> translate_paths(State, dir, Opts, []);
%% both dirs and suites are defined, only translate dir paths
_ -> translate_paths(State, dir, Opts, [])
end;
_Specs ->
translate_paths(State, spec, Opts, [])
end.
translate_paths(_State, _Type, [], Acc) -> lists:reverse(Acc);
translate_paths(State, Type, [{Type, Val}|Rest], Acc) when is_integer(hd(Val)) ->
%% single file or dir
translate_paths(State, Type, [{Type, [Val]}|Rest], Acc);
translate_paths(State, Type, [{Type, Files}|Rest], Acc) ->
Apps = rebar_state:project_apps(State),
New = {Type, lists:map(fun(File) -> translate(State, Apps, File) end, Files)},
translate_paths(State, Type, Rest, [New|Acc]);
translate_paths(State, Type, [Test|Rest], Acc) ->
translate_paths(State, Type, Rest, [Test|Acc]).
translate(State, [App|Rest], Path) ->
case rebar_file_utils:path_from_ancestor(Path, rebar_app_info:dir(App)) of
{ok, P} -> filename:join([rebar_app_info:out_dir(App), P]);
{error, badparent} -> translate(State, Rest, Path)
end;
translate(State, [], Path) ->
case rebar_file_utils:path_from_ancestor(Path, rebar_state:dir(State)) of
{ok, P} -> filename:join([rebar_dir:base_dir(State), "extras", P]);
%% not relative, leave as is
{error, badparent} -> Path
end.
-spec handle_keep_logs(file:filename(), pos_integer()) -> ok.
handle_keep_logs(LogDir, N) ->
case file:list_dir(LogDir) of
{ok, Filenames} ->
Dirs = lists:filter(fun(File) ->
filelib:is_dir(filename:join([LogDir, File]))
end, Filenames) -- ["last"], %% we ignore the symlink as we later handle it
case Dirs of
%% first time running the tests, there are no logs to delete
[] -> ok;
%% during the next run we would crash because of keep_logs
_ when length(Dirs) >= N ->
SortedDirs = lists:reverse(lists:sort(Dirs)),
%% sort the log dirs and keep the N - 1 newest
{_Keep, Discard} = lists:split(N - 1, SortedDirs),
?DEBUG("Removing the following directories because keep_logs is in ct_opts: ~p", [Discard]),
[rebar_file_utils:rm_rf(filename:join([LogDir, Dir])) || Dir <- Discard],
ok;
%% we still dont have enough log run directories as to crash
_ -> ok
end;
_ -> ok
end.
setup_logdir(State, Opts) ->
Logdir = case proplists:get_value(logdir, Opts) of
undefined -> filename:join([rebar_dir:base_dir(State), "logs"]);
Dir -> Dir
end,
filelib:ensure_dir(filename:join([Logdir, "dummy.beam"])),
case proplists:get_value(keep_logs, Opts) of
all -> ok;
undefined -> ok;
N -> handle_keep_logs(Logdir, N)
end,
[{logdir, Logdir}|lists:keydelete(logdir, 1, Opts)].
turn_off_auto_compile(Opts) ->
[{auto_compile, false}|lists:keydelete(auto_compile, 1, Opts)].
run_test_verbose(Opts) -> handle_results(ct:run_test(Opts)).
run_test_quiet(Opts) ->
Pid = self(),
Ref = erlang:make_ref(),
LogDir = proplists:get_value(logdir, Opts),
{_, Monitor} = erlang:spawn_monitor(fun() ->
{ok, F} = file:open(filename:join([LogDir, "ct.latest.log"]),
[write]),
true = group_leader(F, self()),
Pid ! {Ref, ct:run_test(Opts)}
end),
receive
{Ref, Result} -> handle_quiet_results(Opts, Result);
{'DOWN', Monitor, _, _, Reason} -> handle_results(?PRV_ERROR(Reason))
end.
handle_results(Results) when is_list(Results) ->
Result = lists:foldl(fun sum_results/2, {0, 0, {0,0}}, Results),
handle_results(Result);
handle_results({_, Failed, {_, AutoSkipped}})
when Failed > 0 orelse AutoSkipped > 0 ->
?PRV_ERROR({failures_running_tests, {Failed, AutoSkipped}});
handle_results({error, Reason}) ->
?PRV_ERROR({error_running_tests, Reason});
handle_results(_) ->
ok.
sum_results({Passed, Failed, {UserSkipped, AutoSkipped}},
{Passed2, Failed2, {UserSkipped2, AutoSkipped2}}) ->
{Passed+Passed2, Failed+Failed2,
{UserSkipped+UserSkipped2, AutoSkipped+AutoSkipped2}};
sum_results(_, {error, Reason}) ->
{error, Reason};
sum_results(Unknown, _) ->
{error, Unknown}.
handle_quiet_results(_, {error, _} = Result) ->
handle_results(Result);
handle_quiet_results(CTOpts, Results) when is_list(Results) ->
_ = [format_result(Result) || Result <- Results],
case handle_results(Results) of
?PRV_ERROR({failures_running_tests, _}) = Error ->
LogDir = proplists:get_value(logdir, CTOpts),
Index = filename:join([LogDir, "index.html"]),
?CONSOLE("Results written to ~p.", [Index]),
Error;
Other ->
Other
end;
handle_quiet_results(CTOpts, Result) ->
handle_quiet_results(CTOpts, [Result]).
format_result({Passed, 0, {0, 0}}) ->
?CONSOLE("All ~p tests passed.", [Passed]);
format_result({Passed, Failed, Skipped}) ->
Format = [format_failed(Failed), format_skipped(Skipped),
format_passed(Passed)],
?CONSOLE("~ts", [Format]);
format_result(_Unknown) ->
%% Happens when CT itself encounters a bug
ok.
format_failed(0) ->
[];
format_failed(Failed) ->
io_lib:format("Failed ~p tests. ", [Failed]).
format_passed(Passed) ->
io_lib:format("Passed ~p tests. ", [Passed]).
format_skipped({0, 0}) ->
[];
format_skipped({User, Auto}) ->
io_lib:format("Skipped ~p (~p, ~p) tests. ", [User+Auto, User, Auto]).
maybe_cover_compile(State) ->
{RawOpts, _} = rebar_state:command_parsed_args(State),
State1 = case proplists:get_value(cover, RawOpts, false) of
true -> rebar_state:set(State, cover_enabled, true);
false -> State
end,
rebar_prv_cover:maybe_cover_compile(State1).
maybe_write_coverdata(State) ->
{RawOpts, _} = rebar_state:command_parsed_args(State),
State1 = case proplists:get_value(cover, RawOpts, false) of
true -> rebar_state:set(State, cover_enabled, true);
false -> State
end,
Name = proplists:get_value(cover_export_name, RawOpts, ?PROVIDER),
rebar_prv_cover:maybe_write_coverdata(State1, Name).
ct_opts(_State) ->
[{dir, undefined, "dir", string, help(dir)}, %% comma-separated list
{suite, undefined, "suite", string, help(suite)}, %% comma-separated list
{group, undefined, "group", string, help(group)}, %% comma-separated list
{testcase, undefined, "case", string, help(testcase)}, %% comma-separated list
{label, undefined, "label", string, help(label)}, %% String
{config, undefined, "config", string, help(config)}, %% comma-separated list
{spec, undefined, "spec", string, help(spec)}, %% comma-separated list
{join_specs, undefined, "join_specs", boolean, help(join_specs)},
{allow_user_terms, undefined, "allow_user_terms", boolean, help(allow_user_terms)}, %% Bool
{logdir, undefined, "logdir", string, help(logdir)}, %% dir
{logopts, undefined, "logopts", string, help(logopts)}, %% comma-separated list
{verbosity, undefined, "verbosity", integer, help(verbosity)}, %% Integer
{cover, $c, "cover", {boolean, false}, help(cover)},
{cover_export_name, undefined, "cover_export_name", string, help(cover_export_name)},
{repeat, undefined, "repeat", integer, help(repeat)}, %% integer
{duration, undefined, "duration", string, help(duration)}, % format: HHMMSS
{until, undefined, "until", string, help(until)}, %% format: YYMoMoDD[HHMMSS]
{force_stop, undefined, "force_stop", string, help(force_stop)}, %% String
{basic_html, undefined, "basic_html", boolean, help(basic_html)}, %% Boolean
{stylesheet, undefined, "stylesheet", string, help(stylesheet)}, %% String
{decrypt_key, undefined, "decrypt_key", string, help(decrypt_key)}, %% String
{decrypt_file, undefined, "decrypt_file", string, help(decrypt_file)}, %% String
{abort_if_missing_suites, undefined, "abort_if_missing_suites", {boolean, true}, help(abort_if_missing_suites)}, %% Boolean
{multiply_timetraps, undefined, "multiply_timetraps", integer, help(multiple_timetraps)}, %% Integer
{scale_timetraps, undefined, "scale_timetraps", boolean, help(scale_timetraps)},
{create_priv_dir, undefined, "create_priv_dir", string, help(create_priv_dir)},
{include, undefined, "include", string, help(include)},
{readable, undefined, "readable", string, help(readable)},
{verbose, $v, "verbose", boolean, help(verbose)},
{name, undefined, "name", atom, help(name)},
{sname, undefined, "sname", atom, help(sname)},
{setcookie, undefined, "setcookie", atom, help(setcookie)},
{sys_config, undefined, "sys_config", string, help(sys_config)}, %% comma-separated list
{compile_only, undefined, "compile_only", boolean, help(compile_only)},
{retry, undefined, "retry", boolean, help(retry)},
{fail_fast, undefined, "fail_fast", {boolean, false}, help(fail_fast)}
].
help(compile_only) ->
"Compile modules in the project with the test configuration but do not run the tests";
help(dir) ->
"List of additional directories containing test suites";
help(suite) ->
"List of test suites to run";
help(group) ->
"List of test groups to run";
help(testcase) ->
"List of test cases to run";
help(label) ->
"Test label";
help(config) ->
"List of config files";
help(spec) ->
"List of test specifications";
help(join_specs) ->
"Merge all test specifications and perform a single test run";
help(sys_config) ->
"List of application config files";
help(allow_user_terms) ->
"Allow user defined config values in config files";
help(logdir) ->
"Log folder";
help(logopts) ->
"Options for common test logging";
help(verbosity) ->
"Verbosity";
help(cover) ->
"Generate cover data";
help(cover_export_name) ->
"Base name of the coverdata file to write";
help(repeat) ->
"How often to repeat tests";
help(duration) ->
"Max runtime (format: HHMMSS)";
help(until) ->
"Run until (format: HHMMSS)";
help(force_stop) ->
"Force stop on test timeout (true | false | skip_rest)";
help(basic_html) ->
"Show basic HTML";
help(stylesheet) ->
"CSS stylesheet to apply to html output";
help(decrypt_key) ->
"Path to key for decrypting config";
help(decrypt_file) ->
"Path to file containing key for decrypting config";
help(abort_if_missing_suites) ->
"Abort if suites are missing";
help(multiply_timetraps) ->
"Multiply timetraps";
help(scale_timetraps) ->
"Scale timetraps";
help(create_priv_dir) ->
"Create priv dir (auto_per_run | auto_per_tc | manual_per_tc)";
help(include) ->
"Directories containing additional include files";
help(readable) ->
"Shows test case names and only displays logs to shell on failures (true | compact | false)";
help(verbose) ->
"Verbose output";
help(name) ->
"Gives a long name to the node";
help(sname) ->
"Gives a short name to the node";
help(setcookie) ->
"Sets the cookie if the node is distributed";
help(retry) ->
"Experimental feature. If any specification for previously failing test is found, runs them.";
help(fail_fast) ->
"Experimental feature. If any test fails, the run is aborted. Since common test does not "
"support this natively, we abort the rebar3 run on a failure. This May break CT's disk logging and "
"other rebar3 features.";
help(_) ->
"".