summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorAdam Kocoloski <adam@cloudant.com>2010-08-11 15:41:54 -0400
committerAdam Kocoloski <adam@cloudant.com>2010-08-11 17:39:37 -0400
commitf3d13cdfeeaf1f4c0dd938bdee455b0812678eb0 (patch)
tree092fc4acf2b5fb7bef48ffee260de2fa4db6446a /apps
parent8138d70bb8c1de954c46b417ccd22964b6432965 (diff)
move etap to rebar layout and add simple .app template
Diffstat (limited to 'apps')
-rw-r--r--apps/etap/src/etap.app.src6
-rw-r--r--apps/etap/src/etap.erl416
-rw-r--r--apps/etap/src/etap_application.erl72
-rw-r--r--apps/etap/src/etap_can.erl79
-rw-r--r--apps/etap/src/etap_exception.erl66
-rw-r--r--apps/etap/src/etap_process.erl42
-rw-r--r--apps/etap/src/etap_report.erl343
-rw-r--r--apps/etap/src/etap_request.erl89
-rw-r--r--apps/etap/src/etap_string.erl47
-rw-r--r--apps/etap/src/etap_web.erl65
10 files changed, 1225 insertions, 0 deletions
diff --git a/apps/etap/src/etap.app.src b/apps/etap/src/etap.app.src
new file mode 100644
index 00000000..fe6af267
--- /dev/null
+++ b/apps/etap/src/etap.app.src
@@ -0,0 +1,6 @@
+{application, etap, [
+ {description, "TAP compliant testing library"},
+ {vsn, "unknown"},
+ {registered, []},
+ {applications, [kernel, stdlib]}
+]}.
diff --git a/apps/etap/src/etap.erl b/apps/etap/src/etap.erl
new file mode 100644
index 00000000..5ad5dba3
--- /dev/null
+++ b/apps/etap/src/etap.erl
@@ -0,0 +1,416 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @author Nick Gerakines <nick@gerakines.net> [http://socklabs.com/]
+%% @author Jeremy Wall <jeremy@marzhillstudios.com>
+%% @version 0.3.4
+%% @copyright 2007-2008 Jeremy Wall, 2008-2009 Nick Gerakines
+%% @reference http://testanything.org/wiki/index.php/Main_Page
+%% @reference http://en.wikipedia.org/wiki/Test_Anything_Protocol
+%% @todo Finish implementing the skip directive.
+%% @todo Document the messages handled by this receive loop.
+%% @todo Explain in documentation why we use a process to handle test input.
+%% @doc etap is a TAP testing module for Erlang components and applications.
+%% This module allows developers to test their software using the TAP method.
+%%
+%% <blockquote cite="http://en.wikipedia.org/wiki/Test_Anything_Protocol"><p>
+%% TAP, the Test Anything Protocol, is a simple text-based interface between
+%% testing modules in a test harness. TAP started life as part of the test
+%% harness for Perl but now has implementations in C/C++, Python, PHP, Perl
+%% and probably others by the time you read this.
+%% </p></blockquote>
+%%
+%% The testing process begins by defining a plan using etap:plan/1, running
+%% a number of etap tests and then calling eta:end_tests/0. Please refer to
+%% the Erlang modules in the t directory of this project for example tests.
+-module(etap).
+-export([
+ ensure_test_server/0, start_etap_server/0, test_server/1,
+ diag/1, diag/2, plan/1, end_tests/0, not_ok/2, ok/2, is/3, isnt/3,
+ any/3, none/3, fun_is/3, is_greater/3, skip/1, skip/2,
+ ensure_coverage_starts/0, ensure_coverage_ends/0, coverage_report/0,
+ datetime/1, skip/3, bail/0, bail/1
+]).
+-record(test_state, {planned = 0, count = 0, pass = 0, fail = 0, skip = 0, skip_reason = ""}).
+-vsn("0.3.4").
+
+%% @spec plan(N) -> Result
+%% N = unknown | skip | {skip, string()} | integer()
+%% Result = ok
+%% @doc Create a test plan and boot strap the test server.
+plan(unknown) ->
+ ensure_coverage_starts(),
+ ensure_test_server(),
+ etap_server ! {self(), plan, unknown},
+ ok;
+plan(skip) ->
+ io:format("1..0 # skip~n");
+plan({skip, Reason}) ->
+ io:format("1..0 # skip ~s~n", [Reason]);
+plan(N) when is_integer(N), N > 0 ->
+ ensure_coverage_starts(),
+ ensure_test_server(),
+ etap_server ! {self(), plan, N},
+ ok.
+
+%% @spec end_tests() -> ok
+%% @doc End the current test plan and output test results.
+%% @todo This should probably be done in the test_server process.
+end_tests() ->
+ ensure_coverage_ends(),
+ etap_server ! {self(), state},
+ State = receive X -> X end,
+ if
+ State#test_state.planned == -1 ->
+ io:format("1..~p~n", [State#test_state.count]);
+ true ->
+ ok
+ end,
+ case whereis(etap_server) of
+ undefined -> ok;
+ _ -> etap_server ! done, ok
+ end.
+
+%% @private
+ensure_coverage_starts() ->
+ case os:getenv("COVER") of
+ false -> ok;
+ _ ->
+ BeamDir = case os:getenv("COVER_BIN") of false -> "ebin"; X -> X end,
+ cover:compile_beam_directory(BeamDir)
+ end.
+
+%% @private
+%% @doc Attempts to write out any collected coverage data to the cover/
+%% directory. This function should not be called externally, but it could be.
+ensure_coverage_ends() ->
+ case os:getenv("COVER") of
+ false -> ok;
+ _ ->
+ filelib:ensure_dir("cover/"),
+ Name = lists:flatten([
+ io_lib:format("~.16b", [X]) || X <- binary_to_list(erlang:md5(
+ term_to_binary({make_ref(), now()})
+ ))
+ ]),
+ cover:export("cover/" ++ Name ++ ".coverdata")
+ end.
+
+%% @spec coverage_report() -> ok
+%% @doc Use the cover module's covreage report builder to create code coverage
+%% reports from recently created coverdata files.
+coverage_report() ->
+ [cover:import(File) || File <- filelib:wildcard("cover/*.coverdata")],
+ lists:foreach(
+ fun(Mod) ->
+ cover:analyse_to_file(Mod, atom_to_list(Mod) ++ "_coverage.txt", [])
+ end,
+ cover:imported_modules()
+ ),
+ ok.
+
+bail() ->
+ bail("").
+
+bail(Reason) ->
+ etap_server ! {self(), diag, "Bail out! " ++ Reason},
+ ensure_coverage_ends(),
+ etap_server ! done, ok,
+ ok.
+
+
+%% @spec diag(S) -> ok
+%% S = string()
+%% @doc Print a debug/status message related to the test suite.
+diag(S) -> etap_server ! {self(), diag, "# " ++ S}, ok.
+
+%% @spec diag(Format, Data) -> ok
+%% Format = atom() | string() | binary()
+%% Data = [term()]
+%% UnicodeList = [Unicode]
+%% Unicode = int()
+%% @doc Print a debug/status message related to the test suite.
+%% Function arguments are passed through io_lib:format/2.
+diag(Format, Data) -> diag(io_lib:format(Format, Data)).
+
+%% @spec ok(Expr, Desc) -> Result
+%% Expr = true | false
+%% Desc = string()
+%% Result = true | false
+%% @doc Assert that a statement is true.
+ok(Expr, Desc) -> mk_tap(Expr == true, Desc).
+
+%% @spec not_ok(Expr, Desc) -> Result
+%% Expr = true | false
+%% Desc = string()
+%% Result = true | false
+%% @doc Assert that a statement is false.
+not_ok(Expr, Desc) -> mk_tap(Expr == false, Desc).
+
+%% @spec is(Got, Expected, Desc) -> Result
+%% Got = any()
+%% Expected = any()
+%% Desc = string()
+%% Result = true | false
+%% @doc Assert that two values are the same.
+is(Got, Expected, Desc) ->
+ case mk_tap(Got == Expected, Desc) of
+ false ->
+ etap_server ! {self(), diag, " ---"},
+ etap_server ! {self(), diag, io_lib:format(" description: ~p", [Desc])},
+ etap_server ! {self(), diag, io_lib:format(" found: ~p", [Got])},
+ etap_server ! {self(), diag, io_lib:format(" wanted: ~p", [Expected])},
+ etap_server ! {self(), diag, " ..."},
+ false;
+ true -> true
+ end.
+
+%% @spec isnt(Got, Expected, Desc) -> Result
+%% Got = any()
+%% Expected = any()
+%% Desc = string()
+%% Result = true | false
+%% @doc Assert that two values are not the same.
+isnt(Got, Expected, Desc) -> mk_tap(Got /= Expected, Desc).
+
+%% @spec is_greater(ValueA, ValueB, Desc) -> Result
+%% ValueA = number()
+%% ValueB = number()
+%% Desc = string()
+%% Result = true | false
+%% @doc Assert that an integer is greater than another.
+is_greater(ValueA, ValueB, Desc) when is_integer(ValueA), is_integer(ValueB) ->
+ mk_tap(ValueA > ValueB, Desc).
+
+%% @spec any(Got, Items, Desc) -> Result
+%% Got = any()
+%% Items = [any()]
+%% Desc = string()
+%% Result = true | false
+%% @doc Assert that an item is in a list.
+any(Got, Items, Desc) ->
+ is(lists:member(Got, Items), true, Desc).
+
+%% @spec none(Got, Items, Desc) -> Result
+%% Got = any()
+%% Items = [any()]
+%% Desc = string()
+%% Result = true | false
+%% @doc Assert that an item is not in a list.
+none(Got, Items, Desc) ->
+ is(lists:member(Got, Items), false, Desc).
+
+%% @spec fun_is(Fun, Expected, Desc) -> Result
+%% Fun = function()
+%% Expected = any()
+%% Desc = string()
+%% Result = true | false
+%% @doc Use an anonymous function to assert a pattern match.
+fun_is(Fun, Expected, Desc) when is_function(Fun) ->
+ is(Fun(Expected), true, Desc).
+
+%% @equiv skip(TestFun, "")
+skip(TestFun) when is_function(TestFun) ->
+ skip(TestFun, "").
+
+%% @spec skip(TestFun, Reason) -> ok
+%% TestFun = function()
+%% Reason = string()
+%% @doc Skip a test.
+skip(TestFun, Reason) when is_function(TestFun), is_list(Reason) ->
+ begin_skip(Reason),
+ catch TestFun(),
+ end_skip(),
+ ok.
+
+%% @spec skip(Q, TestFun, Reason) -> ok
+%% Q = true | false | function()
+%% TestFun = function()
+%% Reason = string()
+%% @doc Skips a test conditionally. The first argument to this function can
+%% either be the 'true' or 'false' atoms or a function that returns 'true' or
+%% 'false'.
+skip(QFun, TestFun, Reason) when is_function(QFun), is_function(TestFun), is_list(Reason) ->
+ case QFun() of
+ true -> begin_skip(Reason), TestFun(), end_skip();
+ _ -> TestFun()
+ end,
+ ok;
+
+skip(Q, TestFun, Reason) when is_function(TestFun), is_list(Reason), Q == true ->
+ begin_skip(Reason),
+ TestFun(),
+ end_skip(),
+ ok;
+
+skip(_, TestFun, Reason) when is_function(TestFun), is_list(Reason) ->
+ TestFun(),
+ ok.
+
+%% @private
+begin_skip(Reason) ->
+ etap_server ! {self(), begin_skip, Reason}.
+
+%% @private
+end_skip() ->
+ etap_server ! {self(), end_skip}.
+
+% ---
+% Internal / Private functions
+
+%% @private
+%% @doc Start the etap_server process if it is not running already.
+ensure_test_server() ->
+ case whereis(etap_server) of
+ undefined ->
+ proc_lib:start(?MODULE, start_etap_server,[]);
+ _ ->
+ diag("The test server is already running.")
+ end.
+
+%% @private
+%% @doc Start the etap_server loop and register itself as the etap_server
+%% process.
+start_etap_server() ->
+ catch register(etap_server, self()),
+ proc_lib:init_ack(ok),
+ etap:test_server(#test_state{
+ planned = 0,
+ count = 0,
+ pass = 0,
+ fail = 0,
+ skip = 0,
+ skip_reason = ""
+ }).
+
+
+%% @private
+%% @doc The main etap_server receive/run loop. The etap_server receive loop
+%% responds to seven messages apperatining to failure or passing of tests.
+%% It is also used to initiate the testing process with the {_, plan, _}
+%% message that clears the current test state.
+test_server(State) ->
+ NewState = receive
+ {_From, plan, unknown} ->
+ io:format("# Current time local ~s~n", [datetime(erlang:localtime())]),
+ io:format("# Using etap version ~p~n", [ proplists:get_value(vsn, proplists:get_value(attributes, etap:module_info())) ]),
+ State#test_state{
+ planned = -1,
+ count = 0,
+ pass = 0,
+ fail = 0,
+ skip = 0,
+ skip_reason = ""
+ };
+ {_From, plan, N} ->
+ io:format("# Current time local ~s~n", [datetime(erlang:localtime())]),
+ io:format("# Using etap version ~p~n", [ proplists:get_value(vsn, proplists:get_value(attributes, etap:module_info())) ]),
+ io:format("1..~p~n", [N]),
+ State#test_state{
+ planned = N,
+ count = 0,
+ pass = 0,
+ fail = 0,
+ skip = 0,
+ skip_reason = ""
+ };
+ {_From, begin_skip, Reason} ->
+ State#test_state{
+ skip = 1,
+ skip_reason = Reason
+ };
+ {_From, end_skip} ->
+ State#test_state{
+ skip = 0,
+ skip_reason = ""
+ };
+ {_From, pass, Desc} ->
+ FullMessage = skip_diag(
+ " - " ++ Desc,
+ State#test_state.skip,
+ State#test_state.skip_reason
+ ),
+ io:format("ok ~p ~s~n", [State#test_state.count + 1, FullMessage]),
+ State#test_state{
+ count = State#test_state.count + 1,
+ pass = State#test_state.pass + 1
+ };
+
+ {_From, fail, Desc} ->
+ FullMessage = skip_diag(
+ " - " ++ Desc,
+ State#test_state.skip,
+ State#test_state.skip_reason
+ ),
+ io:format("not ok ~p ~s~n", [State#test_state.count + 1, FullMessage]),
+ State#test_state{
+ count = State#test_state.count + 1,
+ fail = State#test_state.fail + 1
+ };
+ {From, state} ->
+ From ! State,
+ State;
+ {_From, diag, Message} ->
+ io:format("~s~n", [Message]),
+ State;
+ {From, count} ->
+ From ! State#test_state.count,
+ State;
+ {From, is_skip} ->
+ From ! State#test_state.skip,
+ State;
+ done ->
+ exit(normal)
+ end,
+ test_server(NewState).
+
+%% @private
+%% @doc Process the result of a test and send it to the etap_server process.
+mk_tap(Result, Desc) ->
+ IsSkip = lib:sendw(etap_server, is_skip),
+ case [IsSkip, Result] of
+ [_, true] ->
+ etap_server ! {self(), pass, Desc},
+ true;
+ [1, _] ->
+ etap_server ! {self(), pass, Desc},
+ true;
+ _ ->
+ etap_server ! {self(), fail, Desc},
+ false
+ end.
+
+%% @private
+%% @doc Format a date/time string.
+datetime(DateTime) ->
+ {{Year, Month, Day}, {Hour, Min, Sec}} = DateTime,
+ io_lib:format("~4.10.0B-~2.10.0B-~2.10.0B ~2.10.0B:~2.10.0B:~2.10.0B", [Year, Month, Day, Hour, Min, Sec]).
+
+%% @private
+%% @doc Craft an output message taking skip/todo into consideration.
+skip_diag(Message, 0, _) ->
+ Message;
+skip_diag(_Message, 1, "") ->
+ " # SKIP";
+skip_diag(_Message, 1, Reason) ->
+ " # SKIP : " ++ Reason.
diff --git a/apps/etap/src/etap_application.erl b/apps/etap/src/etap_application.erl
new file mode 100644
index 00000000..98b52751
--- /dev/null
+++ b/apps/etap/src/etap_application.erl
@@ -0,0 +1,72 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @author Nick Gerakines <nick@gerakines.net> [http://socklabs.com/]
+%% @copyright 2008 Nick Gerakines
+%% @reference http://testanything.org/wiki/index.php/Main_Page
+%% @reference http://en.wikipedia.org/wiki/Test_Anything_Protocol
+%% @todo Explain in documentation why we use a process to handle test input.
+%% @todo Add test to verify the number of members in a pg2 group.
+%% @doc Provide test functionality to the application and related behaviors.
+-module(etap_application).
+-export([
+ start_ok/2, ensure_loaded/3, load_ok/2,
+ pg2_group_exists/2, pg2_group_doesntexist/2
+]).
+
+%% @spec load_ok(string(), string()) -> true | false
+%% @doc Assert that an application can be loaded successfully.
+load_ok(AppName, Desc) ->
+ etap:ok(application:load(AppName) == ok, Desc).
+
+%% @spec start_ok(string(), string()) -> true | false
+%% @doc Assert that an application can be started successfully.
+start_ok(AppName, Desc) ->
+ etap:ok(application:start(AppName) == ok, Desc).
+
+%% @spec ensure_loaded(string(), string(), string()) -> true | false
+%% @doc Assert that an application has been loaded successfully.
+ensure_loaded(AppName, AppVsn, Desc) ->
+ etap:any(
+ fun(Match) -> case Match of {AppName, _, AppVsn} -> true; _ -> false end end,
+ application:loaded_applications(),
+ Desc
+ ).
+
+%% @spec pg2_group_exists(string(), string()) -> true | false
+%% @doc Assert that a pg2 group exists.
+pg2_group_exists(GroupName, Desc) ->
+ etap:any(
+ fun(Match) -> Match == GroupName end,
+ pg2:which_groups(),
+ Desc
+ ).
+
+%% @spec pg2_group_doesntexist(string(), string()) -> true | false
+%% @doc Assert that a pg2 group does not exists.
+pg2_group_doesntexist(GroupName, Desc) ->
+ etap:none(
+ fun(Match) -> Match == GroupName end,
+ pg2:which_groups(),
+ Desc
+ ).
diff --git a/apps/etap/src/etap_can.erl b/apps/etap/src/etap_can.erl
new file mode 100644
index 00000000..552b7174
--- /dev/null
+++ b/apps/etap/src/etap_can.erl
@@ -0,0 +1,79 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @reference http://testanything.org/wiki/index.php/Main_Page
+%% @reference http://en.wikipedia.org/wiki/Test_Anything_Protocol
+%% @doc Provide test functionality modules
+-module(etap_can).
+
+-export([
+ loaded_ok/2, can_ok/2, can_ok/3,
+ has_attrib/2, is_attrib/3, is_behaviour/2
+]).
+
+%% @spec loaded_ok(atom(), string()) -> true | false
+%% @doc Assert that a module has been loaded successfully.
+loaded_ok(M, Desc) when is_atom(M) ->
+ etap:fun_is(fun({module, _}) -> true; (_) -> false end, code:load_file(M), Desc).
+
+%% @spec can_ok(atom(), atom()) -> true | false
+%% @doc Assert that a module exports a given function.
+can_ok(M, F) when is_atom(M), is_atom(F) ->
+ Matches = [X || {X, _} <- M:module_info(exports), X == F],
+ etap:ok(Matches > 0, lists:concat([M, " can ", F])).
+
+%% @spec can_ok(atom(), atom(), integer()) -> true | false
+%% @doc Assert that a module exports a given function with a given arity.
+can_ok(M, F, A) when is_atom(M); is_atom(F), is_number(A) ->
+ Matches = [X || X <- M:module_info(exports), X == {F, A}],
+ etap:ok(Matches > 0, lists:concat([M, " can ", F, "/", A])).
+
+%% @spec has_attrib(M, A) -> true | false
+%% M = atom()
+%% A = atom()
+%% @doc Asserts that a module has a given attribute.
+has_attrib(M, A) when is_atom(M), is_atom(A) ->
+ etap:isnt(
+ proplists:get_value(A, M:module_info(attributes), 'asdlkjasdlkads'),
+ 'asdlkjasdlkads',
+ lists:concat([M, " has attribute ", A])
+ ).
+
+%% @spec has_attrib(M, A. V) -> true | false
+%% M = atom()
+%% A = atom()
+%% V = any()
+%% @doc Asserts that a module has a given attribute with a given value.
+is_attrib(M, A, V) when is_atom(M) andalso is_atom(A) ->
+ etap:is(
+ proplists:get_value(A, M:module_info(attributes)),
+ [V],
+ lists:concat([M, "'s ", A, " is ", V])
+ ).
+
+%% @spec is_behavior(M, B) -> true | false
+%% M = atom()
+%% B = atom()
+%% @doc Asserts that a given module has a specific behavior.
+is_behaviour(M, B) when is_atom(M) andalso is_atom(B) ->
+ is_attrib(M, behaviour, B).
diff --git a/apps/etap/src/etap_exception.erl b/apps/etap/src/etap_exception.erl
new file mode 100644
index 00000000..ba660727
--- /dev/null
+++ b/apps/etap/src/etap_exception.erl
@@ -0,0 +1,66 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @reference http://testanything.org/wiki/index.php/Main_Page
+%% @reference http://en.wikipedia.org/wiki/Test_Anything_Protocol
+%% @doc Adds exception based testing to the etap suite.
+-module(etap_exception).
+
+-export([dies_ok/2, lives_ok/2, throws_ok/3]).
+
+% ---
+% External / Public functions
+
+%% @doc Assert that an exception is raised when running a given function.
+dies_ok(F, Desc) ->
+ case (catch F()) of
+ {'EXIT', _} -> etap:ok(true, Desc);
+ _ -> etap:ok(false, Desc)
+ end.
+
+%% @doc Assert that an exception is not raised when running a given function.
+lives_ok(F, Desc) ->
+ etap:is(try_this(F), success, Desc).
+
+%% @doc Assert that the exception thrown by a function matches the given exception.
+throws_ok(F, Exception, Desc) ->
+ try F() of
+ _ -> etap:ok(nok, Desc)
+ catch
+ _:E ->
+ etap:is(E, Exception, Desc)
+ end.
+
+% ---
+% Internal / Private functions
+
+%% @private
+%% @doc Run a function and catch any exceptions.
+try_this(F) when is_function(F, 0) ->
+ try F() of
+ _ -> success
+ catch
+ throw:E -> {throw, E};
+ error:E -> {error, E};
+ exit:E -> {exit, E}
+ end.
diff --git a/apps/etap/src/etap_process.erl b/apps/etap/src/etap_process.erl
new file mode 100644
index 00000000..69f5ba00
--- /dev/null
+++ b/apps/etap/src/etap_process.erl
@@ -0,0 +1,42 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @doc Adds process/pid testing to the etap suite.
+-module(etap_process).
+
+-export([is_pid/2, is_alive/2, is_mfa/3]).
+
+% ---
+% External / Public functions
+
+%% @doc Assert that a given variable is a pid.
+is_pid(Pid, Desc) when is_pid(Pid) -> etap:ok(true, Desc);
+is_pid(_, Desc) -> etap:ok(false, Desc).
+
+%% @doc Assert that a given process/pid is alive.
+is_alive(Pid, Desc) ->
+ etap:ok(erlang:is_process_alive(Pid), Desc).
+
+%% @doc Assert that the current function of a pid is a given {M, F, A} tuple.
+is_mfa(Pid, MFA, Desc) ->
+ etap:is({current_function, MFA}, erlang:process_info(Pid, current_function), Desc).
diff --git a/apps/etap/src/etap_report.erl b/apps/etap/src/etap_report.erl
new file mode 100644
index 00000000..6d692fb6
--- /dev/null
+++ b/apps/etap/src/etap_report.erl
@@ -0,0 +1,343 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @doc A module for creating nice looking code coverage reports.
+-module(etap_report).
+-export([create/0]).
+
+%% @spec create() -> ok
+%% @doc Create html code coverage reports for each module that code coverage
+%% data exists for.
+create() ->
+ [cover:import(File) || File <- filelib:wildcard("cover/*.coverdata")],
+ Modules = lists:foldl(
+ fun(Module, Acc) ->
+ [{Module, file_report(Module)} | Acc]
+ end,
+ [],
+ cover:imported_modules()
+ ),
+ index(Modules).
+
+%% @private
+index(Modules) ->
+ {ok, IndexFD} = file:open("cover/index.html", [write]),
+ io:format(IndexFD, "<html><head><style>
+ table.percent_graph { height: 12px; border:1px solid #E2E6EF; empty-cells: show; }
+ table.percent_graph td.covered { height: 10px; background: #00f000; }
+ table.percent_graph td.uncovered { height: 10px; background: #e00000; }
+ .odd { background-color: #ddd; }
+ .even { background-color: #fff; }
+ </style></head>", []),
+ io:format(IndexFD, "<body>", []),
+ lists:foldl(
+ fun({Module, {Good, Bad, Source}}, LastRow) ->
+ case {Good + Bad, Source} of
+ {0, _} -> LastRow;
+ {_, none} -> LastRow;
+ _ ->
+ CovPer = round((Good / (Good + Bad)) * 100),
+ UnCovPer = round((Bad / (Good + Bad)) * 100),
+ RowClass = case LastRow of 1 -> "odd"; _ -> "even" end,
+ io:format(IndexFD, "<div class=\"~s\">", [RowClass]),
+ io:format(IndexFD, "<a href=\"~s\">~s</a>", [atom_to_list(Module) ++ "_report.html", atom_to_list(Module)]),
+ io:format(IndexFD, "
+ <table cellspacing='0' cellpadding='0' align='right'>
+ <tr>
+ <td><tt>~p%</tt>&nbsp;</td><td>
+ <table cellspacing='0' class='percent_graph' cellpadding='0' width='100'>
+ <tr><td class='covered' width='~p' /><td class='uncovered' width='~p' /></tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ ", [CovPer, CovPer, UnCovPer]),
+ io:format(IndexFD, "</div>", []),
+ case LastRow of
+ 1 -> 0;
+ 0 -> 1
+ end
+ end
+ end,
+ 0,
+ lists:sort(Modules)
+ ),
+ {TotalGood, TotalBad} = lists:foldl(
+ fun({_, {Good, Bad, Source}}, {TGood, TBad}) ->
+ case Source of none -> {TGood, TBad}; _ -> {TGood + Good, TBad + Bad} end
+ end,
+ {0, 0},
+ Modules
+ ),
+ io:format(IndexFD, "<p>Generated on ~s.</p>~n", [etap:datetime({date(), time()})]),
+ case TotalGood + TotalBad of
+ 0 -> ok;
+ _ ->
+ TotalCovPer = round((TotalGood / (TotalGood + TotalBad)) * 100),
+ TotalUnCovPer = round((TotalBad / (TotalGood + TotalBad)) * 100),
+ io:format(IndexFD, "<div>", []),
+ io:format(IndexFD, "Total
+ <table cellspacing='0' cellpadding='0' align='right'>
+ <tr>
+ <td><tt>~p%</tt>&nbsp;</td><td>
+ <table cellspacing='0' class='percent_graph' cellpadding='0' width='100'>
+ <tr><td class='covered' width='~p' /><td class='uncovered' width='~p' /></tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ ", [TotalCovPer, TotalCovPer, TotalUnCovPer]),
+ io:format(IndexFD, "</div>", [])
+ end,
+ io:format(IndexFD, "</body></html>", []),
+ file:close(IndexFD),
+ ok.
+
+%% @private
+file_report(Module) ->
+ {ok, Data} = cover:analyse(Module, calls, line),
+ Source = find_source(Module),
+ {Good, Bad} = collect_coverage(Data, {0, 0}),
+ case {Source, Good + Bad} of
+ {none, _} -> ok;
+ {_, 0} -> ok;
+ _ ->
+ {ok, SourceFD} = file:open(Source, [read]),
+ {ok, WriteFD} = file:open("cover/" ++ atom_to_list(Module) ++ "_report.html", [write]),
+ io:format(WriteFD, "~s", [header(Module, Good, Bad)]),
+ output_lines(Data, WriteFD, SourceFD, 1),
+ io:format(WriteFD, "~s", [footer()]),
+ file:close(WriteFD),
+ file:close(SourceFD),
+ ok
+ end,
+ {Good, Bad, Source}.
+
+%% @private
+collect_coverage([], Acc) -> Acc;
+collect_coverage([{{_, _}, 0} | Data], {Good, Bad}) ->
+ collect_coverage(Data, {Good, Bad + 1});
+collect_coverage([_ | Data], {Good, Bad}) ->
+ collect_coverage(Data, {Good + 1, Bad}).
+
+%% @private
+output_lines(Data, WriteFD, SourceFD, LineNumber) ->
+ {Match, NextData} = datas_match(Data, LineNumber),
+ case io:get_line(SourceFD, '') of
+ eof -> ok;
+ Line = "%% @todo" ++ _ ->
+ io:format(WriteFD, "~s", [out_line(LineNumber, highlight, Line)]),
+ output_lines(NextData, WriteFD, SourceFD, LineNumber + 1);
+ Line = "% " ++ _ ->
+ io:format(WriteFD, "~s", [out_line(LineNumber, none, Line)]),
+ output_lines(NextData, WriteFD, SourceFD, LineNumber + 1);
+ Line ->
+ case Match of
+ {true, CC} ->
+ io:format(WriteFD, "~s", [out_line(LineNumber, CC, Line)]),
+ output_lines(NextData, WriteFD, SourceFD, LineNumber + 1);
+ false ->
+ io:format(WriteFD, "~s", [out_line(LineNumber, none, Line)]),
+ output_lines(NextData, WriteFD, SourceFD, LineNumber + 1)
+ end
+ end.
+
+%% @private
+out_line(Number, none, Line) ->
+ PadNu = string:right(integer_to_list(Number), 5, $.),
+ io_lib:format("<span class=\"marked\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]);
+out_line(Number, highlight, Line) ->
+ PadNu = string:right(integer_to_list(Number), 5, $.),
+ io_lib:format("<span class=\"highlight\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]);
+out_line(Number, 0, Line) ->
+ PadNu = string:right(integer_to_list(Number), 5, $.),
+ io_lib:format("<span class=\"uncovered\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]);
+out_line(Number, _, Line) ->
+ PadNu = string:right(integer_to_list(Number), 5, $.),
+ io_lib:format("<span class=\"covered\"><a name=\"line~p\"></a>~s ~s</span>", [Number, PadNu, Line]).
+
+%% @private
+datas_match([], _) -> {false, []};
+datas_match([{{_, Line}, CC} | Datas], LineNumber) when Line == LineNumber -> {{true, CC}, Datas};
+datas_match(Data, _) -> {false, Data}.
+
+%% @private
+find_source(Module) when is_atom(Module) ->
+ Root = filename:rootname(Module),
+ Dir = filename:dirname(Root),
+ XDir = case os:getenv("SRC") of false -> "src"; X -> X end,
+ find_source([
+ filename:join([Dir, Root ++ ".erl"]),
+ filename:join([Dir, "..", "src", Root ++ ".erl"]),
+ filename:join([Dir, "src", Root ++ ".erl"]),
+ filename:join([Dir, "elibs", Root ++ ".erl"]),
+ filename:join([Dir, "..", "elibs", Root ++ ".erl"]),
+ filename:join([Dir, XDir, Root ++ ".erl"])
+ ]);
+find_source([]) -> none;
+find_source([Test | Tests]) ->
+ case filelib:is_file(Test) of
+ true -> Test;
+ false -> find_source(Tests)
+ end.
+
+%% @private
+header(Module, Good, Bad) ->
+ io:format("Good ~p~n", [Good]),
+ io:format("Bad ~p~n", [Bad]),
+ CovPer = round((Good / (Good + Bad)) * 100),
+ UnCovPer = round((Bad / (Good + Bad)) * 100),
+ io:format("CovPer ~p~n", [CovPer]),
+ io_lib:format("<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">
+ <html lang='en' xml:lang='en' xmlns='http://www.w3.org/1999/xhtml'>
+ <head>
+ <title>~s - C0 code coverage information</title>
+ <style type='text/css'>body { background-color: rgb(240, 240, 245); }</style>
+ <style type='text/css'>span.marked0 {
+ background-color: rgb(185, 210, 200);
+ display: block;
+ }
+ span.marked { display: block; background-color: #ffffff; }
+ span.highlight { display: block; background-color: #fff9d7; }
+ span.covered { display: block; background-color: #f7f7f7 ; }
+ span.uncovered { display: block; background-color: #ffebe8 ; }
+ span.overview {
+ border-bottom: 1px solid #E2E6EF;
+ }
+ div.overview {
+ border-bottom: 1px solid #E2E6EF;
+ }
+ body {
+ font-family: verdana, arial, helvetica;
+ }
+ div.footer {
+ font-size: 68%;
+ margin-top: 1.5em;
+ }
+ h1, h2, h3, h4, h5, h6 {
+ margin-bottom: 0.5em;
+ }
+ h5 {
+ margin-top: 0.5em;
+ }
+ .hidden {
+ display: none;
+ }
+ div.separator {
+ height: 10px;
+ }
+ table.percent_graph {
+ height: 12px;
+ border: 1px solid #E2E6EF;
+ empty-cells: show;
+ }
+ table.percent_graph td.covered {
+ height: 10px;
+ background: #00f000;
+ }
+ table.percent_graph td.uncovered {
+ height: 10px;
+ background: #e00000;
+ }
+ table.percent_graph td.NA {
+ height: 10px;
+ background: #eaeaea;
+ }
+ table.report {
+ border-collapse: collapse;
+ width: 100%;
+ }
+ table.report td.heading {
+ background: #dcecff;
+ border: 1px solid #E2E6EF;
+ font-weight: bold;
+ text-align: center;
+ }
+ table.report td.heading:hover {
+ background: #c0ffc0;
+ }
+ table.report td.text {
+ border: 1px solid #E2E6EF;
+ }
+ table.report td.value {
+ text-align: right;
+ border: 1px solid #E2E6EF;
+ }
+ table.report tr.light {
+ background-color: rgb(240, 240, 245);
+ }
+ table.report tr.dark {
+ background-color: rgb(230, 230, 235);
+ }
+ </style>
+ </head>
+ <body>
+ <h3>C0 code coverage information</h3>
+ <p>Generated on ~s with <a href='http://github.com/ngerakines/etap'>etap 0.3.4</a>.
+ </p>
+ <table class='report'>
+ <thead>
+ <tr>
+ <td class='heading'>Name</td>
+ <td class='heading'>Total lines</td>
+ <td class='heading'>Lines of code</td>
+ <td class='heading'>Total coverage</td>
+ <td class='heading'>Code coverage</td>
+ </tr>
+ </thead>
+ <tbody>
+ <tr class='light'>
+
+ <td>
+ <a href='~s'>~s</a>
+ </td>
+ <td class='value'>
+ <tt>??</tt>
+ </td>
+ <td class='value'>
+ <tt>??</tt>
+ </td>
+ <td class='value'>
+ <tt>??</tt>
+ </td>
+ <td>
+ <table cellspacing='0' cellpadding='0' align='right'>
+ <tr>
+ <td><tt>~p%</tt>&nbsp;</td><td>
+ <table cellspacing='0' class='percent_graph' cellpadding='0' width='100'>
+ <tr><td class='covered' width='~p' /><td class='uncovered' width='~p' /></tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </tbody>
+ </table><pre>", [Module, etap:datetime({date(), time()}), atom_to_list(Module) ++ "_report.html", Module, CovPer, CovPer, UnCovPer]).
+
+%% @private
+footer() ->
+ "</pre><hr /><p>Generated using <a href='http://github.com/ngerakines/etap'>etap 0.3.4</a>.</p>
+ </body>
+ </html>
+ ".
diff --git a/apps/etap/src/etap_request.erl b/apps/etap/src/etap_request.erl
new file mode 100644
index 00000000..9fd23aca
--- /dev/null
+++ b/apps/etap/src/etap_request.erl
@@ -0,0 +1,89 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @doc Provides test functionality against a specific web request. Many of
+%% the exported methods can be used to build your own more complex tests.
+-module(etap_request, [Method, Url, InHeaders, InBody, Status, OutHeaders, OutBody]).
+
+-export([status_is/2]).
+
+-export([
+ method/0, url/0, status/0, status_code/0, status_line/0, rheaders/0,
+ has_rheader/1, rheader/1, rbody/0, header_is/3, body_is/2,
+ body_has_string/2
+]).
+
+% ---
+% Tests
+
+%% @doc Assert that response status code is the given status code.
+status_is(Code, Desc) ->
+ etap:is(status_code(), Code, Desc).
+
+header_is(Name, Value, Desc) ->
+ etap:is(rheader(Name), Value, Desc).
+
+body_is(Value, Desc) ->
+ etap:is(rbody(), Value, Desc).
+
+body_has_string(String, Desc) when is_list(OutBody), is_list(String) ->
+ etap_string:contains_ok(OutBody, String, Desc).
+
+% ---
+% Accessor functions
+
+%% @doc Access a request's method.
+method() -> Method.
+
+%% @doc Access a request's URL.
+url() -> Url.
+
+%% @doc Access a request's status.
+status() -> Status.
+
+%% @doc Access a request's status code.
+status_code() ->
+ {_, Code, _} = Status,
+ Code.
+
+%% @doc Access a request's status line.
+status_line() ->
+ {_, _, Line} = Status,
+ Line.
+
+%% @doc Access a request's headers.
+rheaders() -> OutHeaders.
+
+%% @doc Dertermine if a specific request header exists.
+has_rheader(Key) ->
+ lists:keymember(Key, 1, OutHeaders).
+
+%% @doc Return a specific request header.
+rheader(Key) ->
+ case lists:keysearch(Key, 1, OutHeaders) of
+ false -> undefined;
+ {value, {Key, Value}} -> Value
+ end.
+
+%% @doc Access the request's body.
+rbody() -> OutBody.
diff --git a/apps/etap/src/etap_string.erl b/apps/etap/src/etap_string.erl
new file mode 100644
index 00000000..67aa3d54
--- /dev/null
+++ b/apps/etap/src/etap_string.erl
@@ -0,0 +1,47 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @author Nick Gerakines <nick@gerakines.net> [http://socklabs.com/]
+%% @copyright 2008 Nick Gerakines
+%% @doc Provide testing functionality for strings.
+-module(etap_string).
+
+-export([contains_ok/3, is_before/4]).
+
+%% @spec contains_ok(string(), string(), string()) -> true | false
+%% @doc Assert that a string is contained in another string.
+contains_ok(Source, String, Desc) ->
+ etap:isnt(
+ string:str(Source, String),
+ 0,
+ Desc
+ ).
+
+%% @spec is_before(string(), string(), string(), string()) -> true | false
+%% @doc Assert that a string comes before another string within a larger body.
+is_before(Source, StringA, StringB, Desc) ->
+ etap:is_greater(
+ string:str(Source, StringB),
+ string:str(Source, StringA),
+ Desc
+ ).
diff --git a/apps/etap/src/etap_web.erl b/apps/etap/src/etap_web.erl
new file mode 100644
index 00000000..fb7aee16
--- /dev/null
+++ b/apps/etap/src/etap_web.erl
@@ -0,0 +1,65 @@
+%% Copyright (c) 2008-2009 Nick Gerakines <nick@gerakines.net>
+%%
+%% Permission is hereby granted, free of charge, to any person
+%% obtaining a copy of this software and associated documentation
+%% files (the "Software"), to deal in the Software without
+%% restriction, including without limitation the rights to use,
+%% copy, modify, merge, publish, distribute, sublicense, and/or sell
+%% copies of the Software, and to permit persons to whom the
+%% Software is furnished to do so, subject to the following
+%% conditions:
+%%
+%% The above copyright notice and this permission notice shall be
+%% included in all copies or substantial portions of the Software.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+%% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+%% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+%% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+%% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+%% OTHER DEALINGS IN THE SOFTWARE.
+%%
+%% @author Nick Gerakines <nick@gerakines.net> [http://socklabs.com/]
+%% @copyright 2008 Nick Gerakines
+%% @todo Support cookies.
+%% @doc Provide testing functionality for web requests.
+-module(etap_web).
+
+-export([simple_200/2, simple_404/2, build_request/4]).
+
+%% @doc Fetch a url and verify that it returned a 200 status.
+simple_200(Url, Desc) ->
+ Request = build_request(get, Url, [], []),
+ Request:status_is(200, Desc).
+
+%% @doc Fetch a url and verify that it returned a 404 status.
+simple_404(Url, Desc) ->
+ Request = build_request(get, Url, [], []),
+ Request:status_is(404, Desc).
+
+%% @doc Create and return a request structure.
+build_request(Method, Url, Headers, Body)
+ when Method==options;Method==get;Method==head;Method==delete;Method==trace ->
+ try http:request(Method, {Url, Headers}, [{autoredirect, false}], []) of
+ {ok, {OutStatus, OutHeaders, OutBody}} ->
+ etap_request:new(Method, Url, Headers, Body, OutStatus, OutHeaders, OutBody);
+ _ -> error
+ catch
+ _:_ -> error
+ end;
+
+%% @doc Create and return a request structure.
+build_request(Method, Url, Headers, Body) when Method == post; Method == put ->
+ ContentType = case lists:keysearch("Content-Type", 1, Headers) of
+ {value, {"Content-Type", X}} -> X;
+ _ -> []
+ end,
+ try http:request(Method, {Url, Headers, ContentType, Body}, [{autoredirect, false}], []) of
+ {ok, {OutStatus, OutHeaders, OutBody}} ->
+ etap_request:new(Method, Url, Headers, Body, OutStatus, OutHeaders, OutBody);
+ _ -> error
+ catch
+ _:_ -> error
+ end.