diff options
Diffstat (limited to 'actionpack/lib/action_dispatch/testing')
10 files changed, 1560 insertions, 0 deletions
diff --git a/actionpack/lib/action_dispatch/testing/assertions.rb b/actionpack/lib/action_dispatch/testing/assertions.rb new file mode 100644 index 0000000000..226baf9ad0 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions.rb @@ -0,0 +1,18 @@ +module ActionDispatch + module Assertions + autoload :DomAssertions, 'action_dispatch/testing/assertions/dom' + autoload :ResponseAssertions, 'action_dispatch/testing/assertions/response' + autoload :RoutingAssertions, 'action_dispatch/testing/assertions/routing' + autoload :SelectorAssertions, 'action_dispatch/testing/assertions/selector' + autoload :TagAssertions, 'action_dispatch/testing/assertions/tag' + + extend ActiveSupport::Concern + + include DomAssertions + include ResponseAssertions + include RoutingAssertions + include SelectorAssertions + include TagAssertions + end +end + diff --git a/actionpack/lib/action_dispatch/testing/assertions/dom.rb b/actionpack/lib/action_dispatch/testing/assertions/dom.rb new file mode 100644 index 0000000000..241a39393a --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/dom.rb @@ -0,0 +1,27 @@ +require 'action_view/vendor/html-scanner' + +module ActionDispatch + module Assertions + module DomAssertions + # \Test two HTML strings for equivalency (e.g., identical up to reordering of attributes) + # + # # assert that the referenced method generates the appropriate HTML string + # assert_dom_equal '<a href="http://www.example.com">Apples</a>', link_to("Apples", "http://www.example.com") + def assert_dom_equal(expected, actual, message = nil) + expected_dom = HTML::Document.new(expected).root + actual_dom = HTML::Document.new(actual).root + assert_equal expected_dom, actual_dom, message + end + + # The negated form of +assert_dom_equivalent+. + # + # # assert that the referenced method does not generate the specified HTML string + # assert_dom_not_equal '<a href="http://www.example.com">Apples</a>', link_to("Oranges", "http://www.example.com") + def assert_dom_not_equal(expected, actual, message = nil) + expected_dom = HTML::Document.new(expected).root + actual_dom = HTML::Document.new(actual).root + assert_not_equal expected_dom, actual_dom, message + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/response.rb b/actionpack/lib/action_dispatch/testing/assertions/response.rb new file mode 100644 index 0000000000..13a72220b3 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb @@ -0,0 +1,82 @@ + +module ActionDispatch + module Assertions + # A small suite of assertions that test responses from \Rails applications. + module ResponseAssertions + # Asserts that the response is one of the following types: + # + # * <tt>:success</tt> - Status code was in the 200-299 range + # * <tt>:redirect</tt> - Status code was in the 300-399 range + # * <tt>:missing</tt> - Status code was 404 + # * <tt>:error</tt> - Status code was in the 500-599 range + # + # You can also pass an explicit status number like <tt>assert_response(501)</tt> + # or its symbolic equivalent <tt>assert_response(:not_implemented)</tt>. + # See Rack::Utils::SYMBOL_TO_STATUS_CODE for a full list. + # + # # assert that the response was a redirection + # assert_response :redirect + # + # # assert that the response code was status code 401 (unauthorized) + # assert_response 401 + def assert_response(type, message = nil) + message ||= "Expected response to be a <#{type}>, but was <#{@response.response_code}>" + + if Symbol === type + if [:success, :missing, :redirect, :error].include?(type) + assert @response.send("#{type}?"), message + else + code = Rack::Utils::SYMBOL_TO_STATUS_CODE[type] + if code.nil? + raise ArgumentError, "Invalid response type :#{type}" + end + assert_equal code, @response.response_code, message + end + else + assert_equal type, @response.response_code, message + end + end + + # Assert that the redirection options passed in match those of the redirect called in the latest action. + # This match can be partial, such that <tt>assert_redirected_to(controller: "weblog")</tt> will also + # match the redirection of <tt>redirect_to(controller: "weblog", action: "show")</tt> and so on. + # + # # assert that the redirection was to the "index" action on the WeblogController + # assert_redirected_to controller: "weblog", action: "index" + # + # # assert that the redirection was to the named route login_url + # assert_redirected_to login_url + # + # # assert that the redirection was to the url for @customer + # assert_redirected_to @customer + # + # # asserts that the redirection matches the regular expression + # assert_redirected_to %r(\Ahttp://example.org) + def assert_redirected_to(options = {}, message=nil) + assert_response(:redirect, message) + return true if options === @response.location + + redirect_is = normalize_argument_to_redirection(@response.location) + redirect_expected = normalize_argument_to_redirection(options) + + message ||= "Expected response to be a redirect to <#{redirect_expected}> but was a redirect to <#{redirect_is}>" + assert_operator redirect_expected, :===, redirect_is, message + end + + private + # Proxy to to_param if the object will respond to it. + def parameterize(value) + value.respond_to?(:to_param) ? value.to_param : value + end + + def normalize_argument_to_redirection(fragment) + if Regexp === fragment + fragment + else + handle = @controller || ActionController::Redirecting + handle._compute_redirect_to_location(@request, fragment) + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/routing.rb b/actionpack/lib/action_dispatch/testing/assertions/routing.rb new file mode 100644 index 0000000000..2cf38a9c2d --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/routing.rb @@ -0,0 +1,218 @@ +require 'uri' +require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/core_ext/string/access' +require 'action_controller/metal/exceptions' + +module ActionDispatch + module Assertions + # Suite of assertions to test routes generated by \Rails and the handling of requests made to them. + module RoutingAssertions + # Asserts that the routing of the given +path+ was handled correctly and that the parsed options (given in the +expected_options+ hash) + # match +path+. Basically, it asserts that \Rails recognizes the route given by +expected_options+. + # + # Pass a hash in the second argument (+path+) to specify the request method. This is useful for routes + # requiring a specific HTTP method. The hash should contain a :path with the incoming request path + # and a :method containing the required HTTP verb. + # + # # assert that POSTing to /items will call the create action on ItemsController + # assert_recognizes({controller: 'items', action: 'create'}, {path: 'items', method: :post}) + # + # You can also pass in +extras+ with a hash containing URL parameters that would normally be in the query string. This can be used + # to assert that values in the query string string will end up in the params hash correctly. To test query strings you must use the + # extras argument, appending the query string on the path directly will not work. For example: + # + # # assert that a path of '/items/list/1?view=print' returns the correct options + # assert_recognizes({controller: 'items', action: 'list', id: '1', view: 'print'}, 'items/list/1', { view: "print" }) + # + # The +message+ parameter allows you to pass in an error message that is displayed upon failure. + # + # # Check the default route (i.e., the index action) + # assert_recognizes({controller: 'items', action: 'index'}, 'items') + # + # # Test a specific action + # assert_recognizes({controller: 'items', action: 'list'}, 'items/list') + # + # # Test an action with a parameter + # assert_recognizes({controller: 'items', action: 'destroy', id: '1'}, 'items/destroy/1') + # + # # Test a custom route + # assert_recognizes({controller: 'items', action: 'show', id: '1'}, 'view/item1') + def assert_recognizes(expected_options, path, extras={}, msg=nil) + request = recognized_request_for(path, extras) + + expected_options = expected_options.clone + + expected_options.stringify_keys! + + msg = message(msg, "") { + sprintf("The recognized options <%s> did not match <%s>, difference:", + request.path_parameters, expected_options) + } + + assert_equal(expected_options, request.path_parameters, msg) + end + + # Asserts that the provided options can be used to generate the provided path. This is the inverse of +assert_recognizes+. + # The +extras+ parameter is used to tell the request the names and values of additional request parameters that would be in + # a query string. The +message+ parameter allows you to specify a custom error message for assertion failures. + # + # The +defaults+ parameter is unused. + # + # # Asserts that the default action is generated for a route with no action + # assert_generates "/items", controller: "items", action: "index" + # + # # Tests that the list action is properly routed + # assert_generates "/items/list", controller: "items", action: "list" + # + # # Tests the generation of a route with a parameter + # assert_generates "/items/list/1", { controller: "items", action: "list", id: "1" } + # + # # Asserts that the generated route gives us our custom route + # assert_generates "changesets/12", { controller: 'scm', action: 'show_diff', revision: "12" } + def assert_generates(expected_path, options, defaults={}, extras = {}, message=nil) + if expected_path =~ %r{://} + fail_on(URI::InvalidURIError) do + uri = URI.parse(expected_path) + expected_path = uri.path.to_s.empty? ? "/" : uri.path + end + else + expected_path = "/#{expected_path}" unless expected_path.first == '/' + end + # Load routes.rb if it hasn't been loaded. + + generated_path, extra_keys = @routes.generate_extras(options, defaults) + found_extras = options.reject { |k, _| ! extra_keys.include? k } + + msg = message || sprintf("found extras <%s>, not <%s>", found_extras, extras) + assert_equal(extras, found_extras, msg) + + msg = message || sprintf("The generated path <%s> did not match <%s>", generated_path, + expected_path) + assert_equal(expected_path, generated_path, msg) + end + + # Asserts that path and options match both ways; in other words, it verifies that <tt>path</tt> generates + # <tt>options</tt> and then that <tt>options</tt> generates <tt>path</tt>. This essentially combines +assert_recognizes+ + # and +assert_generates+ into one step. + # + # The +extras+ hash allows you to specify options that would normally be provided as a query string to the action. The + # +message+ parameter allows you to specify a custom error message to display upon failure. + # + # # Assert a basic route: a controller with the default action (index) + # assert_routing '/home', controller: 'home', action: 'index' + # + # # Test a route generated with a specific controller, action, and parameter (id) + # assert_routing '/entries/show/23', controller: 'entries', action: 'show', id: 23 + # + # # Assert a basic route (controller + default action), with an error message if it fails + # assert_routing '/store', { controller: 'store', action: 'index' }, {}, {}, 'Route for store index not generated properly' + # + # # Tests a route, providing a defaults hash + # assert_routing 'controller/action/9', {id: "9", item: "square"}, {controller: "controller", action: "action"}, {}, {item: "square"} + # + # # Tests a route with a HTTP method + # assert_routing({ method: 'put', path: '/product/321' }, { controller: "product", action: "update", id: "321" }) + def assert_routing(path, options, defaults={}, extras={}, message=nil) + assert_recognizes(options, path, extras, message) + + controller, default_controller = options[:controller], defaults[:controller] + if controller && controller.include?(?/) && default_controller && default_controller.include?(?/) + options[:controller] = "/#{controller}" + end + + generate_options = options.dup.delete_if{ |k, _| defaults.key?(k) } + assert_generates(path.is_a?(Hash) ? path[:path] : path, generate_options, defaults, extras, message) + end + + # A helper to make it easier to test different route configurations. + # This method temporarily replaces @routes + # with a new RouteSet instance. + # + # The new instance is yielded to the passed block. Typically the block + # will create some routes using <tt>set.draw { match ... }</tt>: + # + # with_routing do |set| + # set.draw do + # resources :users + # end + # assert_equal "/users", users_path + # end + # + def with_routing + old_routes, @routes = @routes, ActionDispatch::Routing::RouteSet.new + if defined?(@controller) && @controller + old_controller, @controller = @controller, @controller.clone + _routes = @routes + + # Unfortunately, there is currently an abstraction leak between AC::Base + # and AV::Base which requires having the URL helpers in both AC and AV. + # To do this safely at runtime for tests, we need to bump up the helper serial + # to that the old AV subclass isn't cached. + # + # TODO: Make this unnecessary + @controller.singleton_class.send(:include, _routes.url_helpers) + @controller.view_context_class = Class.new(@controller.view_context_class) do + include _routes.url_helpers + end + end + yield @routes + ensure + @routes = old_routes + if defined?(@controller) && @controller + @controller = old_controller + end + end + + # ROUTES TODO: These assertions should really work in an integration context + def method_missing(selector, *args, &block) + if defined?(@controller) && @controller && @routes && @routes.named_routes.route_defined?(selector) + @controller.send(selector, *args, &block) + else + super + end + end + + private + # Recognizes the route for a given path. + def recognized_request_for(path, extras = {}) + if path.is_a?(Hash) + method = path[:method] + path = path[:path] + else + method = :get + end + + # Assume given controller + request = ActionController::TestRequest.new + + if path =~ %r{://} + fail_on(URI::InvalidURIError) do + uri = URI.parse(path) + request.env["rack.url_scheme"] = uri.scheme || "http" + request.host = uri.host if uri.host + request.port = uri.port if uri.port + request.path = uri.path.to_s.empty? ? "/" : uri.path + end + else + path = "/#{path}" unless path.first == "/" + request.path = path + end + + request.request_method = method if method + + params = fail_on(ActionController::RoutingError) do + @routes.recognize_path(path, { :method => method, :extras => extras }) + end + request.path_parameters = params.with_indifferent_access + + request + end + + def fail_on(exception_class) + yield + rescue exception_class => e + raise Minitest::Assertion, e.message + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/selector.rb b/actionpack/lib/action_dispatch/testing/assertions/selector.rb new file mode 100644 index 0000000000..12023e6f77 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/selector.rb @@ -0,0 +1,430 @@ +require 'action_view/vendor/html-scanner' +require 'active_support/core_ext/object/inclusion' + +#-- +# Copyright (c) 2006 Assaf Arkin (http://labnotes.org) +# Under MIT and/or CC By license. +#++ + +module ActionDispatch + module Assertions + NO_STRIP = %w{pre script style textarea} + + # Adds the +assert_select+ method for use in Rails functional + # test cases, which can be used to make assertions on the response HTML of a controller + # action. You can also call +assert_select+ within another +assert_select+ to + # make assertions on elements selected by the enclosing assertion. + # + # Use +css_select+ to select elements without making an assertions, either + # from the response HTML or elements selected by the enclosing assertion. + # + # In addition to HTML responses, you can make the following assertions: + # + # * +assert_select_encoded+ - Assertions on HTML encoded inside XML, for example for dealing with feed item descriptions. + # * +assert_select_email+ - Assertions on the HTML body of an e-mail. + # + # Also see HTML::Selector to learn how to use selectors. + module SelectorAssertions + # Select and return all matching elements. + # + # If called with a single argument, uses that argument as a selector + # to match all elements of the current page. Returns an empty array + # if no match is found. + # + # If called with two arguments, uses the first argument as the base + # element and the second argument as the selector. Attempts to match the + # base element and any of its children. Returns an empty array if no + # match is found. + # + # The selector may be a CSS selector expression (String), an expression + # with substitution values (Array) or an HTML::Selector object. + # + # # Selects all div tags + # divs = css_select("div") + # + # # Selects all paragraph tags and does something interesting + # pars = css_select("p") + # pars.each do |par| + # # Do something fun with paragraphs here... + # end + # + # # Selects all list items in unordered lists + # items = css_select("ul>li") + # + # # Selects all form tags and then all inputs inside the form + # forms = css_select("form") + # forms.each do |form| + # inputs = css_select(form, "input") + # ... + # end + def css_select(*args) + # See assert_select to understand what's going on here. + arg = args.shift + + if arg.is_a?(HTML::Node) + root = arg + arg = args.shift + elsif arg == nil + raise ArgumentError, "First argument is either selector or element to select, but nil found. Perhaps you called assert_select with an element that does not exist?" + elsif defined?(@selected) && @selected + matches = [] + + @selected.each do |selected| + subset = css_select(selected, HTML::Selector.new(arg.dup, args.dup)) + subset.each do |match| + matches << match unless matches.any? { |m| m.equal?(match) } + end + end + + return matches + else + root = response_from_page + end + + case arg + when String + selector = HTML::Selector.new(arg, args) + when Array + selector = HTML::Selector.new(*arg) + when HTML::Selector + selector = arg + else raise ArgumentError, "Expecting a selector as the first argument" + end + + selector.select(root) + end + + # An assertion that selects elements and makes one or more equality tests. + # + # If the first argument is an element, selects all matching elements + # starting from (and including) that element and all its children in + # depth-first order. + # + # If no element if specified, calling +assert_select+ selects from the + # response HTML unless +assert_select+ is called from within an +assert_select+ block. + # + # When called with a block +assert_select+ passes an array of selected elements + # to the block. Calling +assert_select+ from the block, with no element specified, + # runs the assertion on the complete set of elements selected by the enclosing assertion. + # Alternatively the array may be iterated through so that +assert_select+ can be called + # separately for each element. + # + # + # ==== Example + # If the response contains two ordered lists, each with four list elements then: + # assert_select "ol" do |elements| + # elements.each do |element| + # assert_select element, "li", 4 + # end + # end + # + # will pass, as will: + # assert_select "ol" do + # assert_select "li", 8 + # end + # + # The selector may be a CSS selector expression (String), an expression + # with substitution values, or an HTML::Selector object. + # + # === Equality Tests + # + # The equality test may be one of the following: + # * <tt>true</tt> - Assertion is true if at least one element selected. + # * <tt>false</tt> - Assertion is true if no element selected. + # * <tt>String/Regexp</tt> - Assertion is true if the text value of at least + # one element matches the string or regular expression. + # * <tt>Integer</tt> - Assertion is true if exactly that number of + # elements are selected. + # * <tt>Range</tt> - Assertion is true if the number of selected + # elements fit the range. + # If no equality test specified, the assertion is true if at least one + # element selected. + # + # To perform more than one equality tests, use a hash with the following keys: + # * <tt>:text</tt> - Narrow the selection to elements that have this text + # value (string or regexp). + # * <tt>:html</tt> - Narrow the selection to elements that have this HTML + # content (string or regexp). + # * <tt>:count</tt> - Assertion is true if the number of selected elements + # is equal to this value. + # * <tt>:minimum</tt> - Assertion is true if the number of selected + # elements is at least this value. + # * <tt>:maximum</tt> - Assertion is true if the number of selected + # elements is at most this value. + # + # If the method is called with a block, once all equality tests are + # evaluated the block is called with an array of all matched elements. + # + # # At least one form element + # assert_select "form" + # + # # Form element includes four input fields + # assert_select "form input", 4 + # + # # Page title is "Welcome" + # assert_select "title", "Welcome" + # + # # Page title is "Welcome" and there is only one title element + # assert_select "title", {count: 1, text: "Welcome"}, + # "Wrong title or more than one title element" + # + # # Page contains no forms + # assert_select "form", false, "This page must contain no forms" + # + # # Test the content and style + # assert_select "body div.header ul.menu" + # + # # Use substitution values + # assert_select "ol>li#?", /item-\d+/ + # + # # All input fields in the form have a name + # assert_select "form input" do + # assert_select "[name=?]", /.+/ # Not empty + # end + def assert_select(*args, &block) + # Start with optional element followed by mandatory selector. + arg = args.shift + @selected ||= nil + + if arg.is_a?(HTML::Node) + # First argument is a node (tag or text, but also HTML root), + # so we know what we're selecting from. + root = arg + arg = args.shift + elsif arg == nil + # This usually happens when passing a node/element that + # happens to be nil. + raise ArgumentError, "First argument is either selector or element to select, but nil found. Perhaps you called assert_select with an element that does not exist?" + elsif @selected + root = HTML::Node.new(nil) + root.children.concat @selected + else + # Otherwise just operate on the response document. + root = response_from_page + end + + # First or second argument is the selector: string and we pass + # all remaining arguments. Array and we pass the argument. Also + # accepts selector itself. + case arg + when String + selector = HTML::Selector.new(arg, args) + when Array + selector = HTML::Selector.new(*arg) + when HTML::Selector + selector = arg + else raise ArgumentError, "Expecting a selector as the first argument" + end + + # Next argument is used for equality tests. + equals = {} + case arg = args.shift + when Hash + equals = arg + when String, Regexp + equals[:text] = arg + when Integer + equals[:count] = arg + when Range + equals[:minimum] = arg.begin + equals[:maximum] = arg.end + when FalseClass + equals[:count] = 0 + when NilClass, TrueClass + equals[:minimum] = 1 + else raise ArgumentError, "I don't understand what you're trying to match" + end + + # By default we're looking for at least one match. + if equals[:count] + equals[:minimum] = equals[:maximum] = equals[:count] + else + equals[:minimum] = 1 unless equals[:minimum] + end + + # Last argument is the message we use if the assertion fails. + message = args.shift + #- message = "No match made with selector #{selector.inspect}" unless message + if args.shift + raise ArgumentError, "Not expecting that last argument, you either have too many arguments, or they're the wrong type" + end + + matches = selector.select(root) + # If text/html, narrow down to those elements that match it. + content_mismatch = nil + if match_with = equals[:text] + matches.delete_if do |match| + text = "" + stack = match.children.reverse + while node = stack.pop + if node.tag? + stack.concat node.children.reverse + else + content = node.content + text << content + end + end + text.strip! unless NO_STRIP.include?(match.name) + text.sub!(/\A\n/, '') if match.name == "textarea" + unless match_with.is_a?(Regexp) ? (text =~ match_with) : (text == match_with.to_s) + content_mismatch ||= sprintf("<%s> expected but was\n<%s>", match_with, text) + true + end + end + elsif match_with = equals[:html] + matches.delete_if do |match| + html = match.children.map(&:to_s).join + html.strip! unless NO_STRIP.include?(match.name) + unless match_with.is_a?(Regexp) ? (html =~ match_with) : (html == match_with.to_s) + content_mismatch ||= sprintf("<%s> expected but was\n<%s>", match_with, html) + true + end + end + end + # Expecting foo found bar element only if found zero, not if + # found one but expecting two. + message ||= content_mismatch if matches.empty? + # Test minimum/maximum occurrence. + min, max, count = equals[:minimum], equals[:maximum], equals[:count] + + # FIXME: minitest provides messaging when we use assert_operator, + # so is this custom message really needed? + message = message || %(Expected #{count_description(min, max, count)} matching "#{selector.to_s}", found #{matches.size}) + if count + assert_equal count, matches.size, message + else + assert_operator matches.size, :>=, min, message if min + assert_operator matches.size, :<=, max, message if max + end + + # If a block is given call that block. Set @selected to allow + # nested assert_select, which can be nested several levels deep. + if block_given? && !matches.empty? + begin + in_scope, @selected = @selected, matches + yield matches + ensure + @selected = in_scope + end + end + + # Returns all matches elements. + matches + end + + def count_description(min, max, count) #:nodoc: + pluralize = lambda {|word, quantity| word << (quantity == 1 ? '' : 's')} + + if min && max && (max != min) + "between #{min} and #{max} elements" + elsif min && max && max == min && count + "exactly #{count} #{pluralize['element', min]}" + elsif min && !(min == 1 && max == 1) + "at least #{min} #{pluralize['element', min]}" + elsif max + "at most #{max} #{pluralize['element', max]}" + end + end + + # Extracts the content of an element, treats it as encoded HTML and runs + # nested assertion on it. + # + # You typically call this method within another assertion to operate on + # all currently selected elements. You can also pass an element or array + # of elements. + # + # The content of each element is un-encoded, and wrapped in the root + # element +encoded+. It then calls the block with all un-encoded elements. + # + # # Selects all bold tags from within the title of an Atom feed's entries (perhaps to nab a section name prefix) + # assert_select "feed[xmlns='http://www.w3.org/2005/Atom']" do + # # Select each entry item and then the title item + # assert_select "entry>title" do + # # Run assertions on the encoded title elements + # assert_select_encoded do + # assert_select "b" + # end + # end + # end + # + # + # # Selects all paragraph tags from within the description of an RSS feed + # assert_select "rss[version=2.0]" do + # # Select description element of each feed item. + # assert_select "channel>item>description" do + # # Run assertions on the encoded elements. + # assert_select_encoded do + # assert_select "p" + # end + # end + # end + def assert_select_encoded(element = nil, &block) + case element + when Array + elements = element + when HTML::Node + elements = [element] + when nil + unless elements = @selected + raise ArgumentError, "First argument is optional, but must be called from a nested assert_select" + end + else + raise ArgumentError, "Argument is optional, and may be node or array of nodes" + end + + fix_content = lambda do |node| + # Gets around a bug in the Rails 1.1 HTML parser. + node.content.gsub(/<!\[CDATA\[(.*)(\]\]>)?/m) { Rack::Utils.escapeHTML($1) } + end + + selected = elements.map do |elem| + text = elem.children.select{ |c| not c.tag? }.map{ |c| fix_content[c] }.join + root = HTML::Document.new(CGI.unescapeHTML("<encoded>#{text}</encoded>")).root + css_select(root, "encoded:root", &block)[0] + end + + begin + old_selected, @selected = @selected, selected + assert_select ":root", &block + ensure + @selected = old_selected + end + end + + # Extracts the body of an email and runs nested assertions on it. + # + # You must enable deliveries for this assertion to work, use: + # ActionMailer::Base.perform_deliveries = true + # + # assert_select_email do + # assert_select "h1", "Email alert" + # end + # + # assert_select_email do + # items = assert_select "ol>li" + # items.each do + # # Work with items here... + # end + # end + def assert_select_email(&block) + deliveries = ActionMailer::Base.deliveries + assert !deliveries.empty?, "No e-mail in delivery list" + + deliveries.each do |delivery| + (delivery.parts.empty? ? [delivery] : delivery.parts).each do |part| + if part["Content-Type"].to_s =~ /^text\/html\W/ + root = HTML::Document.new(part.body.to_s).root + assert_select root, ":root", &block + end + end + end + end + + protected + # +assert_select+ and +css_select+ call this to obtain the content in the HTML page. + def response_from_page + html_document.root + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/tag.rb b/actionpack/lib/action_dispatch/testing/assertions/tag.rb new file mode 100644 index 0000000000..e5fe30ba82 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/tag.rb @@ -0,0 +1,135 @@ +require 'action_view/vendor/html-scanner' + +module ActionDispatch + module Assertions + # Pair of assertions to testing elements in the HTML output of the response. + module TagAssertions + # Asserts that there is a tag/node/element in the body of the response + # that meets all of the given conditions. The +conditions+ parameter must + # be a hash of any of the following keys (all are optional): + # + # * <tt>:tag</tt>: the node type must match the corresponding value + # * <tt>:attributes</tt>: a hash. The node's attributes must match the + # corresponding values in the hash. + # * <tt>:parent</tt>: a hash. The node's parent must match the + # corresponding hash. + # * <tt>:child</tt>: a hash. At least one of the node's immediate children + # must meet the criteria described by the hash. + # * <tt>:ancestor</tt>: a hash. At least one of the node's ancestors must + # meet the criteria described by the hash. + # * <tt>:descendant</tt>: a hash. At least one of the node's descendants + # must meet the criteria described by the hash. + # * <tt>:sibling</tt>: a hash. At least one of the node's siblings must + # meet the criteria described by the hash. + # * <tt>:after</tt>: a hash. The node must be after any sibling meeting + # the criteria described by the hash, and at least one sibling must match. + # * <tt>:before</tt>: a hash. The node must be before any sibling meeting + # the criteria described by the hash, and at least one sibling must match. + # * <tt>:children</tt>: a hash, for counting children of a node. Accepts + # the keys: + # * <tt>:count</tt>: either a number or a range which must equal (or + # include) the number of children that match. + # * <tt>:less_than</tt>: the number of matching children must be less + # than this number. + # * <tt>:greater_than</tt>: the number of matching children must be + # greater than this number. + # * <tt>:only</tt>: another hash consisting of the keys to use + # to match on the children, and only matching children will be + # counted. + # * <tt>:content</tt>: the textual content of the node must match the + # given value. This will not match HTML tags in the body of a + # tag--only text. + # + # Conditions are matched using the following algorithm: + # + # * if the condition is a string, it must be a substring of the value. + # * if the condition is a regexp, it must match the value. + # * if the condition is a number, the value must match number.to_s. + # * if the condition is +true+, the value must not be +nil+. + # * if the condition is +false+ or +nil+, the value must be +nil+. + # + # # Assert that there is a "span" tag + # assert_tag tag: "span" + # + # # Assert that there is a "span" tag with id="x" + # assert_tag tag: "span", attributes: { id: "x" } + # + # # Assert that there is a "span" tag using the short-hand + # assert_tag :span + # + # # Assert that there is a "span" tag with id="x" using the short-hand + # assert_tag :span, attributes: { id: "x" } + # + # # Assert that there is a "span" inside of a "div" + # assert_tag tag: "span", parent: { tag: "div" } + # + # # Assert that there is a "span" somewhere inside a table + # assert_tag tag: "span", ancestor: { tag: "table" } + # + # # Assert that there is a "span" with at least one "em" child + # assert_tag tag: "span", child: { tag: "em" } + # + # # Assert that there is a "span" containing a (possibly nested) + # # "strong" tag. + # assert_tag tag: "span", descendant: { tag: "strong" } + # + # # Assert that there is a "span" containing between 2 and 4 "em" tags + # # as immediate children + # assert_tag tag: "span", + # children: { count: 2..4, only: { tag: "em" } } + # + # # Get funky: assert that there is a "div", with an "ul" ancestor + # # and an "li" parent (with "class" = "enum"), and containing a + # # "span" descendant that contains text matching /hello world/ + # assert_tag tag: "div", + # ancestor: { tag: "ul" }, + # parent: { tag: "li", + # attributes: { class: "enum" } }, + # descendant: { tag: "span", + # child: /hello world/ } + # + # <b>Please note</b>: +assert_tag+ and +assert_no_tag+ only work + # with well-formed XHTML. They recognize a few tags as implicitly self-closing + # (like br and hr and such) but will not work correctly with tags + # that allow optional closing tags (p, li, td). <em>You must explicitly + # close all of your tags to use these assertions.</em> + def assert_tag(*opts) + opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first + tag = find_tag(opts) + assert tag, "expected tag, but no tag found matching #{opts.inspect} in:\n#{@response.body.inspect}" + end + + # Identical to +assert_tag+, but asserts that a matching tag does _not_ + # exist. (See +assert_tag+ for a full discussion of the syntax.) + # + # # Assert that there is not a "div" containing a "p" + # assert_no_tag tag: "div", descendant: { tag: "p" } + # + # # Assert that an unordered list is empty + # assert_no_tag tag: "ul", descendant: { tag: "li" } + # + # # Assert that there is not a "p" tag with between 1 to 3 "img" tags + # # as immediate children + # assert_no_tag tag: "p", + # children: { count: 1..3, only: { tag: "img" } } + def assert_no_tag(*opts) + opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first + tag = find_tag(opts) + assert !tag, "expected no tag, but found tag matching #{opts.inspect} in:\n#{@response.body.inspect}" + end + + def find_tag(conditions) + html_document.find(conditions) + end + + def find_all_tag(conditions) + html_document.find_all(conditions) + end + + def html_document + xml = @response.content_type =~ /xml$/ + @html_document ||= HTML::Document.new(@response.body, false, xml) + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb new file mode 100644 index 0000000000..192ccdb9d5 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -0,0 +1,499 @@ +require 'stringio' +require 'uri' +require 'active_support/core_ext/kernel/singleton_class' +require 'active_support/core_ext/object/try' +require 'rack/test' +require 'minitest' + +module ActionDispatch + module Integration #:nodoc: + module RequestHelpers + # Performs a GET request with the given parameters. + # + # - +path+: The URI (as a String) on which you want to perform a GET + # request. + # - +parameters+: The HTTP parameters that you want to pass. This may + # be +nil+, + # a Hash, or a String that is appropriately encoded + # (<tt>application/x-www-form-urlencoded</tt> or + # <tt>multipart/form-data</tt>). + # - +headers_or_env+: Additional headers to pass, as a Hash. The headers will be + # merged into the Rack env hash. + # + # This method returns a Response object, which one can use to + # inspect the details of the response. Furthermore, if this method was + # called from an ActionDispatch::IntegrationTest object, then that + # object's <tt>@response</tt> instance variable will point to the same + # response object. + # + # You can also perform POST, PATCH, PUT, DELETE, and HEAD requests with + # +#post+, +#patch+, +#put+, +#delete+, and +#head+. + def get(path, parameters = nil, headers_or_env = nil) + process :get, path, parameters, headers_or_env + end + + # Performs a POST request with the given parameters. See +#get+ for more + # details. + def post(path, parameters = nil, headers_or_env = nil) + process :post, path, parameters, headers_or_env + end + + # Performs a PATCH request with the given parameters. See +#get+ for more + # details. + def patch(path, parameters = nil, headers_or_env = nil) + process :patch, path, parameters, headers_or_env + end + + # Performs a PUT request with the given parameters. See +#get+ for more + # details. + def put(path, parameters = nil, headers_or_env = nil) + process :put, path, parameters, headers_or_env + end + + # Performs a DELETE request with the given parameters. See +#get+ for + # more details. + def delete(path, parameters = nil, headers_or_env = nil) + process :delete, path, parameters, headers_or_env + end + + # Performs a HEAD request with the given parameters. See +#get+ for more + # details. + def head(path, parameters = nil, headers_or_env = nil) + process :head, path, parameters, headers_or_env + end + + # Performs an XMLHttpRequest request with the given parameters, mirroring + # a request from the Prototype library. + # + # The request_method is +:get+, +:post+, +:patch+, +:put+, +:delete+ or + # +:head+; the parameters are +nil+, a hash, or a url-encoded or multipart + # string; the headers are a hash. + def xml_http_request(request_method, path, parameters = nil, headers_or_env = nil) + headers_or_env ||= {} + headers_or_env['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' + headers_or_env['HTTP_ACCEPT'] ||= [Mime::JS, Mime::HTML, Mime::XML, 'text/xml', Mime::ALL].join(', ') + process(request_method, path, parameters, headers_or_env) + end + alias xhr :xml_http_request + + # Follow a single redirect response. If the last response was not a + # redirect, an exception will be raised. Otherwise, the redirect is + # performed on the location header. + def follow_redirect! + raise "not a redirect! #{status} #{status_message}" unless redirect? + get(response.location) + status + end + + # Performs a request using the specified method, following any subsequent + # redirect. Note that the redirects are followed until the response is + # not a redirect--this means you may run into an infinite loop if your + # redirect loops back to itself. + def request_via_redirect(http_method, path, parameters = nil, headers_or_env = nil) + process(http_method, path, parameters, headers_or_env) + follow_redirect! while redirect? + status + end + + # Performs a GET request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def get_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:get, path, parameters, headers_or_env) + end + + # Performs a POST request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def post_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:post, path, parameters, headers_or_env) + end + + # Performs a PATCH request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def patch_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:patch, path, parameters, headers_or_env) + end + + # Performs a PUT request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def put_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:put, path, parameters, headers_or_env) + end + + # Performs a DELETE request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def delete_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:delete, path, parameters, headers_or_env) + end + end + + # An instance of this class represents a set of requests and responses + # performed sequentially by a test process. Because you can instantiate + # multiple sessions and run them side-by-side, you can also mimic (to some + # limited extent) multiple simultaneous users interacting with your system. + # + # Typically, you will instantiate a new session using + # IntegrationTest#open_session, rather than instantiating + # Integration::Session directly. + class Session + DEFAULT_HOST = "www.example.com" + + include Minitest::Assertions + include TestProcess, RequestHelpers, Assertions + + %w( status status_message headers body redirect? ).each do |method| + delegate method, :to => :response, :allow_nil => true + end + + %w( path ).each do |method| + delegate method, :to => :request, :allow_nil => true + end + + # The hostname used in the last request. + def host + @host || DEFAULT_HOST + end + attr_writer :host + + # The remote_addr used in the last request. + attr_accessor :remote_addr + + # The Accept header to send. + attr_accessor :accept + + # A map of the cookies returned by the last response, and which will be + # sent with the next request. + def cookies + _mock_session.cookie_jar + end + + # A reference to the controller instance used by the last request. + attr_reader :controller + + # A reference to the request instance used by the last request. + attr_reader :request + + # A reference to the response instance used by the last request. + attr_reader :response + + # A running counter of the number of requests processed. + attr_accessor :request_count + + include ActionDispatch::Routing::UrlFor + + # Create and initialize a new Session instance. + def initialize(app) + super() + @app = app + + # If the app is a Rails app, make url_helpers available on the session + # This makes app.url_for and app.foo_path available in the console + if app.respond_to?(:routes) + singleton_class.class_eval do + include app.routes.url_helpers + include app.routes.mounted_helpers + end + end + + reset! + end + + def url_options + @url_options ||= default_url_options.dup.tap do |url_options| + url_options.reverse_merge!(controller.url_options) if controller + + if @app.respond_to?(:routes) + url_options.reverse_merge!(@app.routes.default_url_options) + end + + url_options.reverse_merge!(:host => host, :protocol => https? ? "https" : "http") + end + end + + # Resets the instance. This can be used to reset the state information + # in an existing session instance, so it can be used from a clean-slate + # condition. + # + # session.reset! + def reset! + @https = false + @controller = @request = @response = nil + @_mock_session = nil + @request_count = 0 + @url_options = nil + + self.host = DEFAULT_HOST + self.remote_addr = "127.0.0.1" + self.accept = "text/xml,application/xml,application/xhtml+xml," + + "text/html;q=0.9,text/plain;q=0.8,image/png," + + "*/*;q=0.5" + + unless defined? @named_routes_configured + # the helpers are made protected by default--we make them public for + # easier access during testing and troubleshooting. + @named_routes_configured = true + end + end + + # Specify whether or not the session should mimic a secure HTTPS request. + # + # session.https! + # session.https!(false) + def https!(flag = true) + @https = flag + end + + # Returns +true+ if the session is mimicking a secure HTTPS request. + # + # if session.https? + # ... + # end + def https? + @https + end + + # Set the host name to use in the next request. + # + # session.host! "www.example.com" + alias :host! :host= + + private + def _mock_session + @_mock_session ||= Rack::MockSession.new(@app, host) + end + + # Performs the actual request. + def process(method, path, parameters = nil, headers_or_env = nil) + if path =~ %r{://} + location = URI.parse(path) + https! URI::HTTPS === location if location.scheme + host! "#{location.host}:#{location.port}" if location.host + path = location.query ? "#{location.path}?#{location.query}" : location.path + end + + hostname, port = host.split(':') + + env = { + :method => method, + :params => parameters, + + "SERVER_NAME" => hostname, + "SERVER_PORT" => port || (https? ? "443" : "80"), + "HTTPS" => https? ? "on" : "off", + "rack.url_scheme" => https? ? "https" : "http", + + "REQUEST_URI" => path, + "HTTP_HOST" => host, + "REMOTE_ADDR" => remote_addr, + "CONTENT_TYPE" => "application/x-www-form-urlencoded", + "HTTP_ACCEPT" => accept + } + # this modifies the passed env directly + Http::Headers.new(env).merge!(headers_or_env || {}) + + session = Rack::Test::Session.new(_mock_session) + + # NOTE: rack-test v0.5 doesn't build a default uri correctly + # Make sure requested path is always a full uri + session.request(build_full_uri(path, env), env) + + @request_count += 1 + @request = ActionDispatch::Request.new(session.last_request.env) + response = _mock_session.last_response + @response = ActionDispatch::TestResponse.new(response.status, response.headers, response.body) + @html_document = nil + @url_options = nil + + @controller = session.last_request.env['action_controller.instance'] + + return response.status + end + + def build_full_uri(path, env) + "#{env['rack.url_scheme']}://#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{path}" + end + end + + module Runner + include ActionDispatch::Assertions + + def app + @app ||= nil + end + + # Reset the current session. This is useful for testing multiple sessions + # in a single test case. + def reset! + @integration_session = Integration::Session.new(app) + end + + %w(get post patch put head delete cookies assigns + xml_http_request xhr get_via_redirect post_via_redirect).each do |method| + define_method(method) do |*args| + reset! unless integration_session + reset_template_assertion + # reset the html_document variable, but only for new get/post calls + @html_document = nil unless method == 'cookies' || method == 'assigns' + integration_session.__send__(method, *args).tap do + copy_session_variables! + end + end + end + + # Open a new session instance. If a block is given, the new session is + # yielded to the block before being returned. + # + # session = open_session do |sess| + # sess.extend(CustomAssertions) + # end + # + # By default, a single session is automatically created for you, but you + # can use this method to open multiple sessions that ought to be tested + # simultaneously. + def open_session + dup.tap do |session| + yield session if block_given? + end + end + + # Copy the instance variables from the current session instance into the + # test instance. + def copy_session_variables! #:nodoc: + return unless integration_session + %w(controller response request).each do |var| + instance_variable_set("@#{var}", @integration_session.__send__(var)) + end + end + + def default_url_options + reset! unless integration_session + integration_session.default_url_options + end + + def default_url_options=(options) + reset! unless integration_session + integration_session.default_url_options = options + end + + def respond_to?(method, include_private = false) + integration_session.respond_to?(method, include_private) || super + end + + # Delegate unhandled messages to the current session instance. + def method_missing(sym, *args, &block) + reset! unless integration_session + if integration_session.respond_to?(sym) + integration_session.__send__(sym, *args, &block).tap do + copy_session_variables! + end + else + super + end + end + + private + def integration_session + @integration_session ||= nil + end + end + end + + # An integration test spans multiple controllers and actions, + # tying them all together to ensure they work together as expected. It tests + # more completely than either unit or functional tests do, exercising the + # entire stack, from the dispatcher to the database. + # + # At its simplest, you simply extend <tt>IntegrationTest</tt> and write your tests + # using the get/post methods: + # + # require "test_helper" + # + # class ExampleTest < ActionDispatch::IntegrationTest + # fixtures :people + # + # def test_login + # # get the login page + # get "/login" + # assert_equal 200, status + # + # # post the login and follow through to the home page + # post "/login", username: people(:jamis).username, + # password: people(:jamis).password + # follow_redirect! + # assert_equal 200, status + # assert_equal "/home", path + # end + # end + # + # However, you can also have multiple session instances open per test, and + # even extend those instances with assertions and methods to create a very + # powerful testing DSL that is specific for your application. You can even + # reference any named routes you happen to have defined. + # + # require "test_helper" + # + # class AdvancedTest < ActionDispatch::IntegrationTest + # fixtures :people, :rooms + # + # def test_login_and_speak + # jamis, david = login(:jamis), login(:david) + # room = rooms(:office) + # + # jamis.enter(room) + # jamis.speak(room, "anybody home?") + # + # david.enter(room) + # david.speak(room, "hello!") + # end + # + # private + # + # module CustomAssertions + # def enter(room) + # # reference a named route, for maximum internal consistency! + # get(room_url(id: room.id)) + # assert(...) + # ... + # end + # + # def speak(room, message) + # xml_http_request "/say/#{room.id}", message: message + # assert(...) + # ... + # end + # end + # + # def login(who) + # open_session do |sess| + # sess.extend(CustomAssertions) + # who = people(who) + # sess.post "/login", username: who.username, + # password: who.password + # assert(...) + # end + # end + # end + class IntegrationTest < ActiveSupport::TestCase + include Integration::Runner + include ActionController::TemplateAssertions + include ActionDispatch::Routing::UrlFor + + @@app = nil + + def self.app + @@app || ActionDispatch.test_app + end + + def self.app=(app) + @@app = app + end + + def app + super || self.class.app + end + + def url_options + reset! unless integration_session + integration_session.url_options + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/test_process.rb b/actionpack/lib/action_dispatch/testing/test_process.rb new file mode 100644 index 0000000000..630e6a9b78 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_process.rb @@ -0,0 +1,44 @@ +require 'action_dispatch/middleware/cookies' +require 'action_dispatch/middleware/flash' +require 'active_support/core_ext/hash/indifferent_access' + +module ActionDispatch + module TestProcess + def assigns(key = nil) + assigns = {}.with_indifferent_access + @controller.view_assigns.each { |k, v| assigns.regular_writer(k, v) } + key.nil? ? assigns : assigns[key] + end + + def session + @request.session + end + + def flash + @request.flash + end + + def cookies + @request.cookie_jar + end + + def redirect_to_url + @response.redirect_url + end + + # Shortcut for <tt>Rack::Test::UploadedFile.new(File.join(ActionController::TestCase.fixture_path, path), type)</tt>: + # + # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png') + # + # To upload binary files on Windows, pass <tt>:binary</tt> as the last parameter. + # This will not affect other platforms: + # + # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png', :binary) + def fixture_file_upload(path, mime_type = nil, binary = false) + if self.class.respond_to?(:fixture_path) && self.class.fixture_path + path = File.join(self.class.fixture_path, path) + end + Rack::Test::UploadedFile.new(path, mime_type, binary) + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/test_request.rb b/actionpack/lib/action_dispatch/testing/test_request.rb new file mode 100644 index 0000000000..de3dc5f924 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_request.rb @@ -0,0 +1,78 @@ +require 'active_support/core_ext/hash/indifferent_access' +require 'rack/utils' + +module ActionDispatch + class TestRequest < Request + DEFAULT_ENV = Rack::MockRequest.env_for('/', + 'HTTP_HOST' => 'test.host', + 'REMOTE_ADDR' => '0.0.0.0', + 'HTTP_USER_AGENT' => 'Rails Testing' + ) + + def self.new(env = {}) + super + end + + def initialize(env = {}) + env = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application + super(default_env.merge(env)) + end + + def request_method=(method) + @env['REQUEST_METHOD'] = method.to_s.upcase + end + + def host=(host) + @env['HTTP_HOST'] = host + end + + def port=(number) + @env['SERVER_PORT'] = number.to_i + end + + def request_uri=(uri) + @env['REQUEST_URI'] = uri + end + + def path=(path) + @env['PATH_INFO'] = path + end + + def action=(action_name) + path_parameters[:action] = action_name.to_s + end + + def if_modified_since=(last_modified) + @env['HTTP_IF_MODIFIED_SINCE'] = last_modified + end + + def if_none_match=(etag) + @env['HTTP_IF_NONE_MATCH'] = etag + end + + def remote_addr=(addr) + @env['REMOTE_ADDR'] = addr + end + + def user_agent=(user_agent) + @env['HTTP_USER_AGENT'] = user_agent + end + + def accept=(mime_types) + @env.delete('action_dispatch.request.accepts') + @env['HTTP_ACCEPT'] = Array(mime_types).collect { |mime_type| mime_type.to_s }.join(",") + end + + alias :rack_cookies :cookies + + def cookies + @cookies ||= {}.with_indifferent_access + end + + private + + def default_env + DEFAULT_ENV + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/test_response.rb b/actionpack/lib/action_dispatch/testing/test_response.rb new file mode 100644 index 0000000000..82039e72e7 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_response.rb @@ -0,0 +1,29 @@ +module ActionDispatch + # Integration test methods such as ActionDispatch::Integration::Session#get + # and ActionDispatch::Integration::Session#post return objects of class + # TestResponse, which represent the HTTP response results of the requested + # controller actions. + # + # See Response for more information on controller response objects. + class TestResponse < Response + def self.from_response(response) + new.tap do |resp| + resp.status = response.status + resp.headers = response.headers + resp.body = response.body + end + end + + # Was the response successful? + alias_method :success?, :successful? + + # Was the URL not found? + alias_method :missing?, :not_found? + + # Were we redirected? + alias_method :redirect?, :redirection? + + # Was there a server-side error? + alias_method :error?, :server_error? + end +end |