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
@rogerkk
Copy link

rogerkk commented Dec 22, 2022

Thanks for the useful script!

I wonder if the "not" on line 179 might be a mistake? It seems like this log statement is saying the opposite of what is actually happening. 🤔

@arielj
Copy link
Author

arielj commented Dec 22, 2022

hey @rogerkk, that not is correct there, it's saying that the request was not explicitly intercepted by the developer (it was intercepted by the code as a fallback, but it's just responding with an empty string so result of that request can be unexpected), it's a log for the person running the tests to see they have to explicitly add an interception for that url

Maybe it would be better to phrase it like External request not intercepted: .... Intercepting with empty response. for clarity

when an interception is defined by the developer, that message is not displayed

@rogerkk
Copy link

rogerkk commented Dec 23, 2022

Aha, then I get the intention. Thanks!

Your rephrasing of the log message makes it clearer! Perhaps it could be even clearer with something like External request not intercepted by explicit rule: ... Intercepting with empty response. 🤷‍♂️

@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