Skip to content

Instantly share code, notes, and snippets.

@arielj
Last active January 5, 2024 08:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save arielj/4f52b6fcdb8035babcd3f6dac8c2dc77 to your computer and use it in GitHub Desktop.
Save arielj/4f52b6fcdb8035babcd3f6dac8c2dc77 to your computer and use it in GitHub Desktop.
Interceptor module to use in Rails apps to intercept browser requests
# RSpec:
#
# Put this file in spec/support/interceptor.rb
#
# Require the file in your rails_helper.rb:
#
# require_relative "support/interceptor"
#
# Include the module and add the callbacks in your RSpec config in rails_helper.rb
#
# RSpec.configure do |config|
# config.include(Interceptor, type: :system)
# config.before(:each, type: :system) do
# driven_by Capybara.javascript_driver # this is required because of https://github.com/rspec/rspec-rails/issues/2550
# start_intercepting
# end
#
# config.after(:each, type: :system) do
# stop_intercepting
# end
#
#
# MiniTest:
#
# Put this file in test/support/interceptor.rb
#
# Require the file in your test_helper.rb:
#
# require_relative "support/interceptor"
#
# Include the module and add the callbacks in your test/application_system_test_case.rb file
#
# class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
# driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
# include Interceptor
#
# def after_setup
# start_intercepting
# super
# end
#
# def before_teardown
# stop_intercepting
# super
# end
# end
#
# How to use:
#
# Call the `intercept` method in any system spec with a url, response and an http method (optional, defaults to "GET")
#
# test "something" do
# intercept("some_url.com", "fixed response")
# visit root_url
#
# # assert something that depends on the intercepted request
# end
#
#
# - You can configure default interceptions that should apply to all tests by overriding the `default_interceptions` method
# - You can configure the allowed requests by overriding the `allowed_requests` method (defaults to any request to the Rails app)
module Interceptor
# Add an interception hash for a given url, http method, and response
# @url can be a regexp or a string
# @method can be a string or a symbol, an can be uppercase or lowercase
def intercept(url, response = "", method = :any)
@interceptions << {url: url, method: method, response: response}
end
def start_intercepting
# ignore if the driver is RackTest
return unless page.driver.browser.respond_to?(:intercept)
# only attach the intercept callback once to the browser
@interceptions = default_interceptions
return if @intercepting
page.driver.browser.intercept do |request, &continue|
url = request.url
method = request.method
if (interception = response_for(url, method))
# set mocked body if there's an interception for the url and method
continue.call(request) do |response|
response.body = interception[:response]
end
elsif allowed_request?(url, method)
# leave request untouched if allowed
continue.call(request)
else
# intercept any external request with an empty response and print some logs
continue.call(request) do |response|
log_request(url, method)
response.body = ""
end
end
end
@intercepting = true
end
def stop_intercepting
return unless @intercepting
# remove the callback, cleanup
clear_devtools_intercepts
@intercepting = false
# some requests may finish after the test is done if we let them go through untouched
sleep(0.2)
end
# Override this method to define default interceptions that should apply to all tests
# Each element of the array should be a hash with `url`, `response` and `method` key, like
# the hash added by the `intercept` method
#
# For example:
# - [{url: "https://external.api.com", response: ""}, {url: another_domain, response: fixed_response, method: :get}]
def default_interceptions
[]
end
# Override this method to add more allowed requests that shouldn't be intercepted
#
# Elements of this array can be:
# - a string
# - a regexp
# - a hash with `url` and `method` keys where:
# - url can be a string or a regexp
# - method can be `:any`, can be omitted (same as setting `:any`), or can be an
# http method as symbol or string and lowercase or uppercase
#
# For example, these are valid elements for the array:
# - "https://allowed.domain.com"
# - {url: "https://allowed.domain.com", method: "GET"} (or {url: /allowed\.domain\.com/, method: :get})
# - {url: /allowed\.domain\.com/, method: :any} (or {url: /allowed\.domain\.com/} or /allowed\.domain\.com/)
#
# NOTE that you probably always want at least the Capybara.server_host url in this array
def allowed_requests
[%r{http://#{Capybara.server_host}}]
end
private
# check if the given request url and http method pair is allowed by any rule
def allowed_request?(url, method = "GET")
allowed_requests.any? do |allowed|
allowed_url = allowed.is_a?(Hash) ? allowed[:url] : allowed
matches_url = url.match?(allowed_url)
allowed_method = allowed.is_a?(Hash) ? allowed[:method] : :any
allowed_method ||= :any
matches_method = allowed_method == :any || method == allowed_method.to_s.upcase
matches_url && matches_method
end
end
# find the interception hash for a given url and http method pair
def response_for(url, method = "GET")
@interceptions.find do |interception|
matches_url = url.match?(interception[:url])
intercepted_method = interception[:method] || :any
matches_method = intercepted_method == :any || method == intercepted_method.to_s.upcase
matches_url && matches_method
end
end
# clears the devtools callback for the interceptions
def clear_devtools_intercepts
callbacks = page.driver.browser.devtools.callbacks
if callbacks.has_key?("Fetch.requestPaused")
callbacks.delete("Fetch.requestPaused")
end
end
def log_request(url, method)
message = "External JavaScript request not intercepted: #{method} #{url}"
puts message
Rails.logger.warn message
end
end
@stephannv
Copy link

Thanks @arielj for this.

I noticed this made our tests slower, so my idea was enable this just for specific tests. I had to make some tweaks:

  • clear_devtools_intercepts method. Basically reverting what page.driver.browser.intercept method does.
def clear_devtools_intercepts
  callbacks = page.driver.browser.devtools.callbacks

  if callbacks.has_key?("Fetch.requestPaused")
    callbacks.delete("Fetch.requestPaused")
  end

  if callbacks.has_key?("Network.loadingFailed")
    callbacks.delete("Network.loadingFailed")
  end

  page.driver.browser.devtools.network.set_cache_disabled(cache_disabled: false)
  page.driver.browser.devtools.network.disable
  page.driver.browser.devtools.fetch.disable
end
  • Because errors on Github Actions I added response code 200 and CORS headers on interception code:
...
if (interception = response_for(url, method))
  # set mocked body if there's an interception for the url and method
  continue.call(request) do |response|
    response.code = 200
    response.headers["Access-Control-Allow-Origin"] = "*"
    response.body = interception[:response]
  end
elsif ...
  • Added metadata filtering to specs
# on your spec config file
config.before(:each, intercept_js_requests: true, type: :system) do
  start_intercepting
end

config.after(:each, intercept_js_requests: true, type: :system) do
  stop_intercepting
end

# your test
it "foo", intercept_js_requests: true do
  intercept(...)
end

Full version here: https://gist.github.com/stephannv/b82ea7efb85b79fec37d3ea58abdced7

@cestbalez
Copy link

Thanks for the script @arielj. I was wondering if anyone's had any issues with this since chrome v120. We've been getting the following error since the chrome upgrade

Failure/Error: @interceptions << { url: url, method: method, response: response }

     NoMethodError:
       undefined method `<<' for nil:NilClass

           @interceptions << { url: url, method: method, response: response }
                          ^^
     # ./spec/support/interceptor.rb:68:in `intercept'
     # ./spec/features_helper.rb:28:in `block (2 levels) in <top (required)>'

It appears that the guard clause on line 73 is triggered, meaning that the driver is not responding to intercept. Downgrading to v119 works, but it's not really a solution. I haven't succeeded in finding out what has changed here, e.g. a new way of calling intercept, but have anyone else seen this problem and found a solution?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment