%% -*- erlang-indent-level: 4;indent-tabs-mode: nil -*- %% ex: ts=4 sw=4 et -module(rebar_prv_eunit). -behaviour(provider). -export([init/1, do/1, format_error/1]). %% exported solely for tests -export([prepare_tests/1, eunit_opts/1, validate_tests/2]). -include("rebar.hrl"). -include_lib("providers/include/providers.hrl"). -define(PROVIDER, eunit). %% we need to modify app_info state before compile -define(DEPS, [lock]). -define(DEFAULT_TEST_REGEX, "^(?!\\._).*\\.erl\$"). %% =================================================================== %% 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 eunit"}, {short_desc, "Run EUnit Tests."}, {desc, "Run EUnit Tests."}, {opts, eunit_opts(State)}, {profiles, [test]}]), State1 = rebar_state:add_provider(State, Provider), {ok, State1}. -spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}. do(State) -> Tests = prepare_tests(State), %% inject `eunit_first_files`, `eunit_compile_opts` and any %% directories required by tests into the applications NewState = inject_eunit_state(State, Tests), case compile(NewState) of %% successfully compiled apps {ok, S} -> do(S, Tests); Error -> Error end. do(State, Tests) -> ?INFO("Performing EUnit tests...", []), setup_name(State), rebar_paths:set_paths([deps, plugins], State), %% Run eunit provider prehooks Providers = rebar_state:providers(State), Cwd = rebar_dir:get_cwd(), rebar_hooks:run_project_and_app_hooks(Cwd, pre, ?PROVIDER, Providers, State), case validate_tests(State, Tests) of {ok, T} -> case run_tests(State, T) of {ok, State1} -> %% Run eunit provider posthooks rebar_hooks:run_project_and_app_hooks(Cwd, post, ?PROVIDER, Providers, State1), rebar_paths:set_paths([plugins, deps], State), {ok, State1}; Error -> rebar_paths:set_paths([plugins, deps], State), Error end; Error -> rebar_paths:set_paths([plugins, deps], State), Error end. run_tests(State, Tests) -> T = translate_paths(State, Tests), EUnitOpts = resolve_eunit_opts(State), ?DEBUG("finding tests in:~n\t{eunit_tests, ~p}.", [T]), ?DEBUG("with options:~n\t{eunit_opts, ~p}.", [EUnitOpts]), apply_sys_config(State), try eunit:test(T, EUnitOpts) of Result -> ok = maybe_write_coverdata(State), case handle_results(Result) of {error, Reason} -> ?PRV_ERROR(Reason); ok -> {ok, State} end catch error:badarg -> ?PRV_ERROR({error, badarg}) end. -spec format_error(any()) -> iolist(). format_error(unknown_error) -> io_lib:format("Error running tests", []); format_error({error_running_tests, Reason}) -> io_lib:format("Error running tests: ~p", [Reason]); format_error({eunit_test_errors, Errors}) -> io_lib:format(lists:concat(["Error Running EUnit Tests:"] ++ lists:map(fun(Error) -> "~n " ++ Error end, Errors)), []); format_error({badconfig, {Msg, {Value, Key}}}) -> io_lib:format(Msg, [Value, Key]); format_error({generator, Value}) -> io_lib:format("Generator ~p has an invalid format", [Value]); format_error({error, Error}) -> format_error({error_running_tests, Error}). %% =================================================================== %% Internal functions %% =================================================================== setup_name(State) -> {Long, Short, Opts} = rebar_dist_utils:find_options(State), rebar_dist_utils:either(Long, Short, Opts). prepare_tests(State) -> %% parse and translate command line tests CmdTests = resolve_tests(State), CfgTests = cfg_tests(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, CmdTests, CfgTests). resolve_tests(State) -> {RawOpts, _} = rebar_state:command_parsed_args(State), Apps = resolve(app, application, RawOpts), Applications = resolve(application, RawOpts), Dirs = resolve(dir, RawOpts), Files = resolve(file, RawOpts), Modules = resolve(module, RawOpts), Suites = resolve(suite, module, RawOpts), Generator = resolve(generator, RawOpts), Apps ++ Applications ++ Dirs ++ Files ++ Modules ++ Suites ++ Generator. resolve(Flag, RawOpts) -> resolve(Flag, Flag, RawOpts). resolve(Flag, EUnitKey, RawOpts) -> case proplists:get_value(Flag, RawOpts) of undefined -> []; Args -> normalize(EUnitKey, rebar_string:lexemes(Args, [$,])) end. normalize(generator, Args) -> lists:flatmap(fun(Value) -> normalize_(generator, Value) end, Args); normalize(EUnitKey, Args) -> lists:map(fun(Arg) -> normalize_(EUnitKey, Arg) end, Args). normalize_(generator, Value) -> case string:tokens(Value, [$:]) of [Module0, Functions] -> Module = list_to_atom(Module0), lists:map(fun(F) -> {generator, Module, list_to_atom(F)} end, string:tokens(Functions, [$;])); _ -> ?PRV_ERROR({generator, Value}) end; normalize_(Key, Value) when Key == dir; Key == file -> {Key, Value}; normalize_(Key, Value) -> {Key, list_to_atom(Value)}. cfg_tests(State) -> case rebar_state:get(State, eunit_tests, []) of Tests when is_list(Tests) -> lists:map(fun({app, App}) -> {application, App}; (T) -> T end, Tests); Wrong -> %% probably a single non list term ?PRV_ERROR({badconfig, {"Value `~p' of option `~p' must be a list", {Wrong, eunit_tests}}}) end. select_tests(_State, _ProjectApps, _, {error, _} = Error) -> Error; select_tests(State, ProjectApps, [], []) -> {ok, default_tests(State, ProjectApps)}; select_tests(_State, _ProjectApps, [], Tests) -> {ok, Tests}; select_tests(_State, _ProjectApps, Tests, _) -> {ok, Tests}. default_tests(State, Apps) -> %% use `{application, App}` for each app in project AppTests = set_apps(Apps), %% additional test modules in `test` dir of each app ModTests = set_modules(Apps, State), AppTests ++ ModTests. set_apps(Apps) -> set_apps(Apps, []). set_apps([], Acc) -> Acc; set_apps([App|Rest], Acc) -> AppName = list_to_atom(binary_to_list(rebar_app_info:name(App))), set_apps(Rest, [{application, AppName}|Acc]). set_modules(Apps, State) -> set_modules(Apps, State, {[], []}). set_modules([], State, {AppAcc, TestAcc}) -> Regex = rebar_state:get(State, eunit_test_regex, ?DEFAULT_TEST_REGEX), BareTestDir = [filename:join([rebar_state:dir(State), "test"])], TestSrc = gather_src(BareTestDir, Regex), dedupe_tests({AppAcc, TestAcc ++ TestSrc}); set_modules([App|Rest], State, {AppAcc, TestAcc}) -> F = fun(Dir) -> filename:join([rebar_app_info:dir(App), Dir]) end, AppDirs = lists:map(F, rebar_dir:src_dirs(rebar_app_info:opts(App), ["src"])), Regex = rebar_state:get(State, eunit_test_regex, ?DEFAULT_TEST_REGEX), AppSrc = gather_src(AppDirs, Regex), TestDirs = [filename:join([rebar_app_info:dir(App), "test"])], TestSrc = gather_src(TestDirs, Regex), set_modules(Rest, State, {AppSrc ++ AppAcc, TestSrc ++ TestAcc}). gather_src(Dirs, Regex) -> gather_src(Dirs, Regex, []). gather_src([], _Regex, Srcs) -> Srcs; gather_src([Dir|Rest], Regex, Srcs) -> gather_src(Rest, Regex, Srcs ++ rebar_utils:find_files(Dir, Regex, true)). dedupe_tests({AppMods, TestMods}) -> UniqueTestMods = lists:usort(TestMods) -- AppMods, %% for each modules in TestMods create a test if there is not a module %% in AppMods that will trigger it F = fun(TestMod) -> M = filename:rootname(filename:basename(TestMod)), MatchesTest = fun(AppMod) -> filename:rootname(filename:basename(AppMod)) ++ "_tests" == M end, case lists:any(MatchesTest, AppMods) of false -> {true, {module, list_to_atom(M)}}; true -> false end end, rebar_utils:filtermap(F, UniqueTestMods). inject_eunit_state(State, {ok, Tests}) -> Apps = rebar_state:project_apps(State), case inject_eunit_state(State, Apps, []) of {ok, {NewState, ModdedApps}} -> test_dirs(NewState, ModdedApps, Tests); {error, _} = Error -> Error end; inject_eunit_state(_State, Error) -> Error. inject_eunit_state(State, [App|Rest], Acc) -> case inject(rebar_app_info:opts(App)) of {error, _} = Error -> Error; NewOpts -> NewApp = rebar_app_info:opts(App, NewOpts), inject_eunit_state(State, Rest, [NewApp|Acc]) end; inject_eunit_state(State, [], Acc) -> case inject(rebar_state:opts(State)) 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) -> erl_opts(Opts). erl_opts(Opts) -> %% append `eunit_compile_opts` to app defined `erl_opts` ErlOpts = opts(Opts, erl_opts, []), EUnitOpts = opts(Opts, eunit_compile_opts, []), case append(EUnitOpts, ErlOpts) of {error, _} = Error -> Error; NewErlOpts -> first_files(rebar_opts:set(Opts, erl_opts, NewErlOpts)) end. first_files(Opts) -> %% append `eunit_first_files` to app defined `erl_first_files` FirstFiles = opts(Opts, erl_first_files, []), EUnitFirstFiles = opts(Opts, eunit_first_files, []), case append(EUnitFirstFiles, FirstFiles) of {error, _} = Error -> Error; NewFirstFiles -> eunit_macro(rebar_opts:set(Opts, erl_first_files, NewFirstFiles)) end. eunit_macro(Opts) -> ErlOpts = opts(Opts, erl_opts, []), NewOpts = safe_define_eunit_macro(ErlOpts), rebar_opts:set(Opts, erl_opts, NewOpts). safe_define_eunit_macro(Opts) -> %% defining a compile macro twice results in an exception so %% make sure 'EUNIT' is only defined once case test_defined(Opts) of true -> Opts; false -> [{d, 'EUNIT'}|Opts] end. test_defined([{d, 'EUNIT'}|_]) -> true; test_defined([{d, 'EUNIT', true}|_]) -> true; test_defined([_|Rest]) -> test_defined(Rest); test_defined([]) -> false. append({error, _} = Error, _) -> Error; append(_, {error, _} = Error) -> Error; append(A, B) -> A ++ B. test_dirs(State, Apps, []) -> rebar_state:project_apps(State, Apps); test_dirs(State, Apps, [{dir, Dir}|Rest]) -> %% 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 {NewState, NewApps} = maybe_inject_test_dir(State, [], Apps, Dir), test_dirs(NewState, NewApps, Rest); test_dirs(State, Apps, [{file, File}|Rest]) -> Dir = filename:dirname(File), {NewState, NewApps} = maybe_inject_test_dir(State, [], Apps, Dir), test_dirs(NewState, NewApps, Rest); test_dirs(State, Apps, [_|Rest]) -> test_dirs(State, Apps, Rest). maybe_inject_test_dir(State, AppAcc, [App|Rest], Dir) -> case rebar_file_utils:path_from_ancestor(Dir, rebar_app_info:dir(App)) of {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, Path} -> Opts = inject_test_dir(rebar_state:opts(State), Path), {rebar_state:opts(State, Opts), AppAcc}; {error, badparent} -> {State, AppAcc} 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]). compile({error, _} = Error) -> Error; compile(State) -> {ok, S} = rebar_prv_compile:do(State), ok = maybe_cover_compile(S), {ok, S}. validate_tests(State, {ok, Tests}) -> gather_tests(fun(Elem) -> validate(State, Elem) end, Tests, []); validate_tests(_State, Error) -> Error. gather_tests(_F, [], Acc) -> {ok, lists:reverse(Acc)}; gather_tests(F, [Test|Rest], Acc) -> case F(Test) of ok -> gather_tests(F, Rest, [Test|Acc]); %% failure mode, switch to gathering errors {error, Error} -> gather_errors(F, Rest, [Error]) end. gather_errors(_F, [], Acc) -> ?PRV_ERROR({eunit_test_errors, lists:reverse(Acc)}); gather_errors(F, [Test|Rest], Acc) -> case F(Test) of ok -> gather_errors(F, Rest, Acc); {error, Error} -> gather_errors(F, Rest, [Error|Acc]) end. validate(State, {application, App}) -> validate_app(State, App); validate(State, {dir, Dir}) -> validate_dir(State, Dir); validate(State, {file, File}) -> validate_file(State, File); validate(State, {module, Module}) -> validate_module(State, Module); validate(State, {suite, Module}) -> validate_module(State, Module); validate(State, {generator, Module, Function}) -> validate_generator(State, Module, Function); validate(State, Module) when is_atom(Module) -> validate_module(State, Module); validate(State, Path) when is_list(Path) -> case ec_file:is_dir(Path) of true -> validate(State, {dir, Path}); false -> validate(State, {file, Path}) end; %% unrecognized tests should be included. if they're invalid eunit will error %% and rebar.config may contain arbitrarily complex tests that are effectively %% unvalidatable validate(_State, _Test) -> ok. validate_app(State, AppName) -> ProjectApps = rebar_state:project_apps(State), validate_app(State, ProjectApps, AppName). validate_app(_State, [], AppName) -> {error, lists:concat(["Application `", AppName, "' not found in project."])}; validate_app(State, [App|Rest], AppName) -> case AppName == binary_to_atom(rebar_app_info:name(App), unicode) of true -> ok; false -> validate_app(State, Rest, AppName) end. validate_dir(State, Dir) -> case ec_file:is_dir(filename:join([rebar_state:dir(State), Dir])) of true -> ok; false -> {error, lists:concat(["Directory `", Dir, "' not found."])} end. validate_file(State, File) -> case ec_file:exists(filename:join([rebar_state:dir(State), File])) of true -> ok; false -> {error, lists:concat(["File `", File, "' not found."])} end. validate_module(_State, Module) -> case code:which(Module) of non_existing -> {error, lists:concat(["Module `", Module, "' not found in project."])}; _ -> ok end. validate_generator(State, Module, _Function) -> validate_module(State, Module). resolve_eunit_opts(State) -> {Opts, _} = rebar_state:command_parsed_args(State), EUnitOpts = rebar_state:get(State, eunit_opts, []), EUnitOpts1 = case proplists:get_value(verbose, Opts, false) of true -> set_verbose(EUnitOpts); false -> EUnitOpts end, EUnitOpts2 = case proplists:get_value(profile, Opts, false) of true -> set_profile(EUnitOpts1); false -> EUnitOpts1 end, IsVerbose = lists:member(verbose, EUnitOpts2), case proplists:get_value(eunit_formatters, Opts, not IsVerbose) of true -> custom_eunit_formatters(EUnitOpts2); false -> EUnitOpts2 end. custom_eunit_formatters(Opts) -> ReportOpts = custom_eunit_report_options(Opts), %% If `report` is already set then treat that like `eunit_formatters` is false case lists:keymember(report, 1, Opts) of true -> Opts; false -> [no_tty, {report, {eunit_progress, ReportOpts}} | Opts] end. custom_eunit_report_options(Opts) -> case lists:member(profile, Opts) of true -> [colored, profile]; false -> [colored] end. set_profile(Opts) -> %% if `profile` is already set don't set it again case lists:member(profile, Opts) of true -> Opts; false -> [profile] ++ Opts end. set_verbose(Opts) -> %% if `verbose` is already set don't set it again case lists:member(verbose, Opts) of true -> Opts; false -> [verbose] ++ Opts end. translate_paths(State, Tests) -> translate_paths(State, Tests, []). translate_paths(_State, [], Acc) -> lists:reverse(Acc); translate_paths(State, [{K, _} = Path|Rest], Acc) when K == file; K == dir -> Apps = rebar_state:project_apps(State), translate_paths(State, Rest, [translate(State, Apps, Path)|Acc]); translate_paths(State, [Test|Rest], Acc) -> translate_paths(State, Rest, [Test|Acc]). translate(State, [App|Rest], {dir, Dir}) -> case rebar_file_utils:path_from_ancestor(Dir, rebar_app_info:dir(App)) of {ok, Path} -> {dir, filename:join([rebar_app_info:out_dir(App), Path])}; {error, badparent} -> translate(State, Rest, {dir, Dir}) end; translate(State, [App|Rest], {file, FilePath}) -> Dir = filename:dirname(FilePath), File = filename:basename(FilePath), case rebar_file_utils:path_from_ancestor(Dir, rebar_app_info:dir(App)) of {ok, Path} -> {file, filename:join([rebar_app_info:out_dir(App), Path, File])}; {error, badparent} -> translate(State, Rest, {file, FilePath}) end; translate(State, [], {dir, Dir}) -> case rebar_file_utils:path_from_ancestor(Dir, rebar_state:dir(State)) of {ok, Path} -> {dir, filename:join([rebar_dir:base_dir(State), "extras", Path])}; %% not relative, leave as is {error, badparent} -> {dir, Dir} end; translate(State, [], {file, FilePath}) -> Dir = filename:dirname(FilePath), File = filename:basename(FilePath), case rebar_file_utils:path_from_ancestor(Dir, rebar_state:dir(State)) of {ok, Path} -> {file, filename:join([rebar_dir:base_dir(State), "extras", Path, File])}; %% not relative, leave as is {error, badparent} -> {file, FilePath} end. apply_sys_config(State) -> CfgSysCfg = case rebar_state:get(State, eunit_opts, []) of Opts when is_list(Opts) -> case proplists:get_value(sys_config, Opts, []) of [] -> []; L when is_list(hd(L)) -> L; S when is_list(S) -> [S] end; _ -> [] end, {RawOpts, _} = rebar_state:command_parsed_args(State), SysCfgs = rebar_string:lexemes( proplists:get_value(sys_config, RawOpts, ""), [$,] ) ++ CfgSysCfg, Configs = lists:flatmap( fun(Filename) -> rebar_file_utils:consult_config(State, Filename) end, SysCfgs ), %% 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]), ok. 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). handle_results(ok) -> ok; handle_results(error) -> {error, unknown_error}; handle_results({error, Reason}) -> {error, {error_running_tests, Reason}}. eunit_opts(_State) -> [{app, undefined, "app", string, help(app)}, {application, undefined, "application", string, help(app)}, {cover, $c, "cover", boolean, help(cover)}, {cover_export_name, undefined, "cover_export_name", string, help(cover_export_name)}, {profile, $p, "profile", boolean, help(profile)}, {dir, $d, "dir", string, help(dir)}, {file, $f, "file", string, help(file)}, {module, $m, "module", string, help(module)}, {suite, $s, "suite", string, help(module)}, {generator, $g, "generator", string, help(generator)}, {verbose, $v, "verbose", boolean, help(verbose)}, {name, undefined, "name", atom, help(name)}, {sname, undefined, "sname", atom, help(sname)}, {sys_config, undefined, "sys_config", string, help(sys_config)}, %% comma-separated list {setcookie, undefined, "setcookie", atom, help(setcookie)}]. help(app) -> "Comma separated list of application test suites to run. Equivalent to `[{application, App}]`."; help(cover) -> "Generate cover data. Defaults to false."; help(cover_export_name) -> "Base name of the coverdata file to write"; help(profile) -> "Show the slowest tests. Defaults to false."; help(dir) -> "Comma separated list of dirs to load tests from. Equivalent to `[{dir, Dir}]`."; help(file) -> "Comma separated list of files to load tests from. Equivalent to `[{file, File}]`."; help(module) -> "Comma separated list of modules to load tests from. Equivalent to `[{module, Module}]`."; help(generator) -> "Comma separated list of generators (the format is `module:function`) to load tests from. Equivalent to `[{generator, Module, Function}]`."; help(verbose) -> "Verbose output. Defaults to false."; 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(sys_config) -> "List of application config files".