Skip to content

Instantly share code, notes, and snippets.

@stephaneliu
Last active April 4, 2023 08:01
Show Gist options
  • Save stephaneliu/19cdc81b7131e64c55a93c84e784566a to your computer and use it in GitHub Desktop.
Save stephaneliu/19cdc81b7131e64c55a93c84e784566a to your computer and use it in GitHub Desktop.
Custom RuboCop cop to prevent using VCR cassettes with record strategies that make live requests
# .rubocop.yml
require:
- ./lib/custom_cops/vcr/record_strategy.rb
# lib/custom_cops/custom_cop_base.rb
# frozen_string_literal: true
# See https://docs.rubocop.org/rubocop/1.23/development.html for more info on creating new [Rubo]cops
module CustomCops
class CustomCopBase < RuboCop::Cop::Base
end
end
# lib/custom_cops/vcr/record_strategy.rb
# frozen_string_literal: true
require_relative '../custom_cop_base'
module CustomCops
module Vcr
# Enforces the use of record strategies that do not make live requests
# `record: :once` - Only record if cassette does not exist (VCR Default).
# `record: :none` - Never record new interactions. Replay previous.
# `record: :record_on_error` - Prevent a cassette from being recorded when the code that uses cassette raises error.
# `record: all` - Record new interactions. Never replay previous.
# `record: :new_episodes` - Record new interactions. Replay previous.
#
# # Good
# VCR.use_cassette('document_download') do
# VCR.use_cassette('document_download', record: :once) do
# VCR.use_cassette('document_download', record: :none) do
#
# # Bad
# VCR.use_cassette('document_download', record: :all_episodes) do
# VCR.use_cassette('document_download', record: :new_episodes) do
# VCR.use_cassette('document_download', record: :record_on_error) do
class RecordStrategy < CustomCopBase
# View Abstract Syntax Tree (AST) using Parser (https://github.com/whitequark/parser)
#
# > gem install ruby_parser
# > ruby-parse -e "VCR.use_cassette('document_download', record: :all_episodes, match_requests_on: [:method, :host])"
# (send
# (const nil :VCR) :use_cassette
# (str "document_download")
# (kwargs
# (pair
# (sym :record)
# (sym :all_episodes))
# (pair
# (sym :match_requests_on)
# (array
# (sym :method)
# (sym :host)))))
#
# More info on interact with AST - https://docs.rubocop.org/rubocop/1.23/development.html
def_node_matcher :record_strategy, <<-PATTERN
(send (const nil? :VCR) :use_cassette (str ...) (hash <(pair (sym :record)(sym $_)) ... > ...) ...)
# | | | |
# | | | Hash pair is not order dependent
# | | |
# | | Any order - https://docs.rubocop.org/rubocop-ast/node_pattern.html#for-match-in-any-order
# | |
# | Any number of nodes (wildcard) - https://docs.rubocop.org/rubocop-ast/node_pattern.html#for-several-subsequent-nodes
# |
# Predicate methods - https://docs.rubocop.org/rubocop-ast/node_pattern.html#predicate-methods
PATTERN
STRATEGY = %i[once none].freeze
MSG = "Only use `record: :once` or `record: :none` for VCR record strategy"
# Optimization: don't call `on_send` unless method name is in this list
# https://thoughtbot.com/blog/rubocop-custom-cops-for-custom-needs
RESTRICT_ON_SEND = %i[use_cassette].freeze
def on_send(node)
record_strategy(node) do |strategy|
next if STRATEGY.include?(strategy.to_sym)
add_offense(node)
end
end
end
end
end
# spec/lib/custom_cops/vcr/record-strategy_spec.rb
# frozen_string_literal: true
require 'rails_helper'
require 'rubocop'
require_relative '../../../../lib/custom_cops/vcr/record_strategy'
RSpec.describe CustomCops::Vcr::RecordStrategy do
let(:cop) do
config = RuboCop::Config.new({ described_class.badge.to_s => {} }, "/")
described_class.new(config)
end
let(:investigation_report) do
RuboCop::Cop::Commissioner.new([cop]).investigate(cop.parse(source))
end
context 'when use_cassette does not have record option' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test') do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
context 'with other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo]) do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
end
end
context 'when use_cassette `record: :once`' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', record: :once) do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
context 'when `record: :once` is before other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', record: :once, match_requests_on: [:host, :method, :static_photo]) do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
end
context 'when `record: :once` is after other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo], record: :once) do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
end
end
context 'when use_cassette `record: :none`' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', record: :none) do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
context 'when `record: :none` is before other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', record: :none, match_requests_on: [:host, :method, :static_photo]) do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
end
context 'when `record: :none` is after other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo], record: :none) do
# ...
end
RUBY
end
it 'does not record an offense' do
expect(investigation_report.offenses).to be_blank
end
end
end
context 'when use_cassette `record :all_episodes`' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', record: :all_episodes) do
# ...
end
RUBY
end
it 'records an offense' do
expect(investigation_report.offenses).to be_present
expect(investigation_report.offenses.first.message).to eq(described_class::MSG)
end
context 'when `record: :all_episodes` is before other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', record: :episodes, match_requests_on: [:host, :method, :static_photo]) do
# ...
end
RUBY
end
it 'records an offense' do
expect(investigation_report.offenses).to be_present
expect(investigation_report.offenses.first.message).to eq(described_class::MSG)
end
end
context 'when `record: :all_episodes` is after other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', match_requests_on: [:host, :method, :static_photo], record: :episodes) do
# ...
end
RUBY
end
it 'records an offense' do
expect(investigation_report.offenses).to be_present
expect(investigation_report.offenses.first.message).to eq(described_class::MSG)
end
end
end
context 'when use_cassette `record :unknown_strategy`' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test', record: :unknown_strategy) do
# ...
end
RUBY
end
it 'records an offense' do
expect(investigation_report.offenses).to be_present
expect(investigation_report.offenses.first.message).to eq(described_class::MSG)
end
context 'when `record: :unknown_strategy` is before other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test',
record: :unknown_strategy,
match_requests_on: [:host, :method, :static_photo]) do
# ...
end
RUBY
end
it 'records an offense' do
expect(investigation_report.offenses).to be_present
expect(investigation_report.offenses.first.message).to eq(described_class::MSG)
end
end
context 'when `record: :unknown_strategy episodes` is after other options' do
let(:source) do
<<~RUBY
VCR.use_cassette('external_test',
match_requests_on: [:host, :method, :static_photo],
record: :unknown_strategy) do
# ...
end
RUBY
end
it 'records an offense' do
expect(investigation_report.offenses).to be_present
expect(investigation_report.offenses.first.message).to eq(described_class::MSG)
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment