rspec_power 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +503 -0
- data/Rakefile +20 -0
- data/lib/rspec_power/benchmark.rb +74 -0
- data/lib/rspec_power/ci.rb +21 -0
- data/lib/rspec_power/db_dump.rb +144 -0
- data/lib/rspec_power/engine.rb +5 -0
- data/lib/rspec_power/env.rb +30 -0
- data/lib/rspec_power/i18n.rb +21 -0
- data/lib/rspec_power/logging.rb +112 -0
- data/lib/rspec_power/performance.rb +32 -0
- data/lib/rspec_power/request_dump.rb +125 -0
- data/lib/rspec_power/sql.rb +105 -0
- data/lib/rspec_power/time.rb +37 -0
- data/lib/rspec_power/version.rb +3 -0
- data/lib/rspec_power.rb +61 -0
- metadata +127 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module RSpecPower
|
|
9
|
+
module DbDumpHelpers
|
|
10
|
+
DEFAULT_EXCLUDED_TABLES = %w[schema_migrations ar_internal_metadata].freeze
|
|
11
|
+
|
|
12
|
+
def dump_database_on_failure(example, options = {})
|
|
13
|
+
return unless defined?(::ActiveRecord)
|
|
14
|
+
|
|
15
|
+
connection = ::ActiveRecord::Base.connection
|
|
16
|
+
|
|
17
|
+
base_dir = resolve_base_dir(options[:dir])
|
|
18
|
+
spec_label = sanitize_label(example.full_description)
|
|
19
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
20
|
+
|
|
21
|
+
tables = resolve_tables(connection, options)
|
|
22
|
+
non_empty_tables = tables.select { |t| table_has_rows?(connection, t) }
|
|
23
|
+
|
|
24
|
+
return nil if non_empty_tables.empty?
|
|
25
|
+
|
|
26
|
+
out_dir = File.join(base_dir, "#{timestamp}_#{spec_label}")
|
|
27
|
+
FileUtils.mkdir_p(out_dir)
|
|
28
|
+
|
|
29
|
+
non_empty_tables.each do |table|
|
|
30
|
+
csv_path = File.join(out_dir, "#{table}.csv")
|
|
31
|
+
dump_table_to_csv(connection, table, csv_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
write_metadata(out_dir, example, non_empty_tables)
|
|
35
|
+
|
|
36
|
+
puts "[rspec_power] DB dump written to: #{out_dir}"
|
|
37
|
+
puts "[rspec_power] Tables: #{non_empty_tables.join(", ")}" unless non_empty_tables.empty?
|
|
38
|
+
|
|
39
|
+
out_dir
|
|
40
|
+
rescue => e
|
|
41
|
+
warn "[rspec_power] Failed to dump DB on failure: #{e.class}: #{e.message}"
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def resolve_base_dir(custom_dir)
|
|
48
|
+
return custom_dir if custom_dir && !custom_dir.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
base = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
51
|
+
Rails.root.join("tmp", "rspec_power", "db_failures").to_s
|
|
52
|
+
else
|
|
53
|
+
File.join(Dir.pwd, "tmp", "rspec_power", "db_failures")
|
|
54
|
+
end
|
|
55
|
+
FileUtils.mkdir_p(base)
|
|
56
|
+
base
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_tables(connection, options)
|
|
60
|
+
specified = Array(options[:tables] || options[:only]).map(&:to_s)
|
|
61
|
+
excluded = Array(options[:except] || options[:exclude]).map(&:to_s)
|
|
62
|
+
|
|
63
|
+
all = connection.tables - DEFAULT_EXCLUDED_TABLES
|
|
64
|
+
list = specified.empty? ? all : (all & specified)
|
|
65
|
+
list - excluded
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def table_has_rows?(connection, table)
|
|
69
|
+
qt = connection.quote_table_name(table)
|
|
70
|
+
sql = "SELECT 1 FROM #{qt} LIMIT 1"
|
|
71
|
+
!connection.select_value(sql).nil?
|
|
72
|
+
rescue
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def dump_table_to_csv(connection, table, csv_path)
|
|
77
|
+
qt = connection.quote_table_name(table)
|
|
78
|
+
primary_key = safe_primary_key(connection, table)
|
|
79
|
+
|
|
80
|
+
sql = if primary_key
|
|
81
|
+
qc = connection.quote_column_name(primary_key)
|
|
82
|
+
"SELECT * FROM #{qt} ORDER BY #{qc} ASC"
|
|
83
|
+
else
|
|
84
|
+
"SELECT * FROM #{qt}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
result = connection.exec_query(sql)
|
|
88
|
+
|
|
89
|
+
CSV.open(csv_path, "w") do |csv|
|
|
90
|
+
csv << result.columns
|
|
91
|
+
result.rows.each { |row| csv << row }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def safe_primary_key(connection, table)
|
|
96
|
+
connection.primary_key(table)
|
|
97
|
+
rescue
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sanitize_label(label)
|
|
102
|
+
label.to_s.gsub(/[^a-zA-Z0-9\-_]+/, "_")[0, 120]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def write_metadata(out_dir, example, tables)
|
|
106
|
+
meta = {
|
|
107
|
+
"spec" => example.full_description,
|
|
108
|
+
"id" => example.id,
|
|
109
|
+
"file_path" => example.metadata[:file_path],
|
|
110
|
+
"exception" => example.exception&.message,
|
|
111
|
+
"created_at" => Time.now.utc.iso8601,
|
|
112
|
+
"tables" => tables
|
|
113
|
+
}
|
|
114
|
+
File.write(File.join(out_dir, "metadata.json"), JSON.pretty_generate(meta))
|
|
115
|
+
rescue
|
|
116
|
+
# Fallback to plain text if JSON isn't available for some reason
|
|
117
|
+
File.write(
|
|
118
|
+
File.join(out_dir, "metadata.txt"),
|
|
119
|
+
[
|
|
120
|
+
"spec: #{example.full_description}",
|
|
121
|
+
"id: #{example.id}",
|
|
122
|
+
"file_path: #{example.metadata[:file_path]}",
|
|
123
|
+
"exception: #{example.exception&.message}",
|
|
124
|
+
"created_at: #{Time.now.utc.iso8601}",
|
|
125
|
+
"tables: #{tables.join(', ')}"
|
|
126
|
+
].join("\n")
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
RSpec.shared_context "rspec_power::db_dump:on_fail" do
|
|
133
|
+
include RSpecPower::DbDumpHelpers
|
|
134
|
+
|
|
135
|
+
after(:each) do |example|
|
|
136
|
+
dump_meta = example.metadata[:with_dump_db_on_fail]
|
|
137
|
+
dump_meta = example.metadata[:dump_db_on_fail] if dump_meta.nil?
|
|
138
|
+
next unless dump_meta
|
|
139
|
+
next unless example.exception
|
|
140
|
+
|
|
141
|
+
options = dump_meta.is_a?(Hash) ? dump_meta : {}
|
|
142
|
+
dump_database_on_failure(example, options)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RSpecPower
|
|
2
|
+
module EnvHelpers
|
|
3
|
+
def with_test_env(overrides = {})
|
|
4
|
+
old_values = overrides.each_with_object({}) do |(key, _), memo|
|
|
5
|
+
memo[key] = ENV.key?(key) ? ENV[key] : :__undefined__
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# apply overrides (stringify keys just in case)
|
|
9
|
+
overrides.each { |k, v| ENV[k.to_s] = v }
|
|
10
|
+
|
|
11
|
+
yield
|
|
12
|
+
ensure
|
|
13
|
+
# restore old values
|
|
14
|
+
old_values.each do |key, val|
|
|
15
|
+
if val == :__undefined__
|
|
16
|
+
ENV.delete(key)
|
|
17
|
+
else
|
|
18
|
+
ENV[key] = val
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
RSpec.shared_context "rspec_power::env:override" do
|
|
26
|
+
around(:each) do |example|
|
|
27
|
+
overrides = example.metadata[:with_env] || {}
|
|
28
|
+
with_test_env(overrides) { example.run }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module RSpecPower
|
|
2
|
+
module I18nHelpers
|
|
3
|
+
def with_locale(locale)
|
|
4
|
+
old = I18n.locale
|
|
5
|
+
I18n.locale = locale
|
|
6
|
+
yield
|
|
7
|
+
ensure
|
|
8
|
+
I18n.locale = old
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RSpec.shared_context "rspec_power::i18n:dynamic" do
|
|
14
|
+
around(:each) do |example|
|
|
15
|
+
if example.metadata.key?(:with_locale)
|
|
16
|
+
with_locale(example.metadata[:with_locale]) { example.run }
|
|
17
|
+
else
|
|
18
|
+
example.run
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "active_support/tagged_logging" if defined?(ActiveSupport::TaggedLogging)
|
|
3
|
+
require "active_support/logger" if defined?(ActiveSupport::Logger)
|
|
4
|
+
require "active_record" if defined?(ActiveRecord)
|
|
5
|
+
require "active_support/log_subscriber" if defined?(ActiveSupport::LogSubscriber)
|
|
6
|
+
|
|
7
|
+
module RSpecPower
|
|
8
|
+
module LoggingHelpers
|
|
9
|
+
class << self
|
|
10
|
+
def logger=(new_logger)
|
|
11
|
+
@logger = new_logger
|
|
12
|
+
global_loggables.each { |l| l.logger = new_logger }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def logger
|
|
16
|
+
return @logger if defined?(@logger)
|
|
17
|
+
@logger = if defined?(ActiveSupport::TaggedLogging)
|
|
18
|
+
ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
|
|
19
|
+
elsif defined?(ActiveSupport::Logger)
|
|
20
|
+
ActiveSupport::Logger.new($stdout)
|
|
21
|
+
else
|
|
22
|
+
Logger.new($stdout)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def global_loggables
|
|
27
|
+
@global_loggables ||= []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def swap_logger(targets)
|
|
31
|
+
targets.map do |target|
|
|
32
|
+
old_logger = target.logger
|
|
33
|
+
begin
|
|
34
|
+
target.logger = logger
|
|
35
|
+
rescue NoMethodError
|
|
36
|
+
# Some classes expose .logger= only via class_attribute; try via Rails.logger
|
|
37
|
+
# but we still continue to next target to avoid halting.
|
|
38
|
+
end
|
|
39
|
+
old_logger
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def restore_logger(old_loggers, targets)
|
|
44
|
+
targets.each_with_index { |t, i| t.logger = old_loggers[i] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def all_loggables
|
|
48
|
+
base = [
|
|
49
|
+
(::Rails if defined?(::Rails)),
|
|
50
|
+
(::ActiveSupport::LogSubscriber if defined?(::ActiveSupport::LogSubscriber)),
|
|
51
|
+
(::ActiveRecord::Base if defined?(::ActiveRecord::Base)),
|
|
52
|
+
(::ActionController::Base if defined?(::ActionController::Base)),
|
|
53
|
+
(::ActiveJob::Base if defined?(::ActiveJob::Base)),
|
|
54
|
+
(::ActionView::Base if defined?(::ActionView::Base)),
|
|
55
|
+
(::ActionMailer::Base if defined?(::ActionMailer::Base)),
|
|
56
|
+
(::ActionCable if defined?(::ActionCable))
|
|
57
|
+
].compact
|
|
58
|
+
|
|
59
|
+
# Include all log subscribers for controller/action_view etc.
|
|
60
|
+
if defined?(::ActiveSupport::LogSubscriber)
|
|
61
|
+
ObjectSpace.each_object(Class).select { |c| c < ::ActiveSupport::LogSubscriber }.each do |subscriber|
|
|
62
|
+
base << subscriber if subscriber.respond_to?(:logger)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
base.select { |l| l.respond_to?(:logger) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ar_loggables
|
|
70
|
+
@ar_loggables ||= [
|
|
71
|
+
::ActiveRecord::Base,
|
|
72
|
+
::ActiveSupport::LogSubscriber
|
|
73
|
+
]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def with_logging
|
|
78
|
+
old = LoggingHelpers.swap_logger(LoggingHelpers.all_loggables)
|
|
79
|
+
yield
|
|
80
|
+
ensure
|
|
81
|
+
LoggingHelpers.restore_logger(old, LoggingHelpers.all_loggables)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def with_ar_logging
|
|
85
|
+
old = LoggingHelpers.swap_logger(LoggingHelpers.ar_loggables)
|
|
86
|
+
yield
|
|
87
|
+
ensure
|
|
88
|
+
LoggingHelpers.restore_logger(old, LoggingHelpers.ar_loggables)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
RSpec.shared_context "rspec_power::logging:verbose" do
|
|
94
|
+
around(:each) do |ex|
|
|
95
|
+
if ex.metadata[:with_log] == true || ex.metadata[:with_log] == :all ||
|
|
96
|
+
ex.metadata[:with_logs] == true || ex.metadata[:with_logs] == :all
|
|
97
|
+
with_logging(&ex)
|
|
98
|
+
else
|
|
99
|
+
ex.call
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
RSpec.shared_context "rspec_power::logging:active_record" do
|
|
105
|
+
around(:each) do |ex|
|
|
106
|
+
if ex.metadata[:with_log_ar]
|
|
107
|
+
with_ar_logging(&ex)
|
|
108
|
+
else
|
|
109
|
+
ex.call
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module RSpecPower
|
|
2
|
+
module PerformanceHelpers
|
|
3
|
+
# Ensures the given block completes within the specified duration (milliseconds).
|
|
4
|
+
# Raises RSpec::Expectations::ExpectationNotMetError if the threshold is exceeded.
|
|
5
|
+
def with_maximum_execution_time(max_duration_ms)
|
|
6
|
+
raise ArgumentError, "with_maximum_execution_time requires a block" unless block_given?
|
|
7
|
+
|
|
8
|
+
max_ms = max_duration_ms.to_f
|
|
9
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
yield
|
|
11
|
+
ensure
|
|
12
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
|
|
13
|
+
if elapsed_ms > max_ms
|
|
14
|
+
formatted_elapsed = format("%.3f", elapsed_ms)
|
|
15
|
+
formatted_limit = format("%.3f", max_ms)
|
|
16
|
+
raise RSpec::Expectations::ExpectationNotMetError,
|
|
17
|
+
"Execution time exceeded: #{formatted_elapsed}ms > #{formatted_limit}ms"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
RSpec.shared_context "rspec_power::performance:maximum_execution_time" do
|
|
24
|
+
around(:each) do |example|
|
|
25
|
+
threshold = example.metadata[:with_maximum_execution_time]
|
|
26
|
+
if threshold
|
|
27
|
+
with_maximum_execution_time(threshold) { example.run }
|
|
28
|
+
else
|
|
29
|
+
example.run
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
module RSpecPower
|
|
2
|
+
module RequestDumpHelpers
|
|
3
|
+
def dump_state_after_example(example, what_items)
|
|
4
|
+
items_to_dump = Array(what_items).map(&:to_sym)
|
|
5
|
+
|
|
6
|
+
puts "\n[rspec_power] Dump after example: #{example.full_description}"
|
|
7
|
+
|
|
8
|
+
if items_to_dump.include?(:session)
|
|
9
|
+
value = fetch_session_value
|
|
10
|
+
puts "[rspec_power] session: #{safe_inspect(value)}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
if items_to_dump.include?(:cookies)
|
|
14
|
+
value = fetch_cookies_value
|
|
15
|
+
puts "[rspec_power] cookies: #{safe_inspect(value)}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if items_to_dump.include?(:flash)
|
|
19
|
+
value = fetch_flash_value
|
|
20
|
+
puts "[rspec_power] flash: #{safe_inspect(value)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if items_to_dump.include?(:headers)
|
|
24
|
+
value = fetch_headers_value
|
|
25
|
+
puts "[rspec_power] headers: #{safe_inspect(value)}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def safe_inspect(value)
|
|
32
|
+
case value
|
|
33
|
+
when Hash
|
|
34
|
+
begin
|
|
35
|
+
value.transform_keys { |k| k.to_s }.inspect
|
|
36
|
+
rescue
|
|
37
|
+
value.inspect
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
value.inspect
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_session_value
|
|
45
|
+
if respond_to?(:session)
|
|
46
|
+
coerce_to_hash(session)
|
|
47
|
+
elsif defined?(request) && request.respond_to?(:session)
|
|
48
|
+
coerce_to_hash(request.session)
|
|
49
|
+
elsif defined?(controller) && controller.respond_to?(:session)
|
|
50
|
+
coerce_to_hash(controller.session)
|
|
51
|
+
else
|
|
52
|
+
:unavailable
|
|
53
|
+
end
|
|
54
|
+
rescue => e
|
|
55
|
+
"unavailable (#{e.class}: #{e.message})"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_cookies_value
|
|
59
|
+
if respond_to?(:cookies)
|
|
60
|
+
coerce_to_hash(cookies)
|
|
61
|
+
elsif defined?(request) && request.respond_to?(:cookie_jar)
|
|
62
|
+
coerce_to_hash(request.cookie_jar)
|
|
63
|
+
elsif defined?(response) && response.respond_to?(:cookies)
|
|
64
|
+
coerce_to_hash(response.cookies)
|
|
65
|
+
else
|
|
66
|
+
:unavailable
|
|
67
|
+
end
|
|
68
|
+
rescue => e
|
|
69
|
+
"unavailable (#{e.class}: #{e.message})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fetch_flash_value
|
|
73
|
+
if respond_to?(:flash)
|
|
74
|
+
coerce_to_hash(flash)
|
|
75
|
+
elsif defined?(controller) && controller.respond_to?(:flash)
|
|
76
|
+
coerce_to_hash(controller.flash)
|
|
77
|
+
else
|
|
78
|
+
:unavailable
|
|
79
|
+
end
|
|
80
|
+
rescue => e
|
|
81
|
+
"unavailable (#{e.class}: #{e.message})"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fetch_headers_value
|
|
85
|
+
if defined?(request) && request.respond_to?(:headers)
|
|
86
|
+
coerce_to_hash(request.headers)
|
|
87
|
+
elsif defined?(response) && response.respond_to?(:request) && response.request.respond_to?(:headers)
|
|
88
|
+
coerce_to_hash(response.request.headers)
|
|
89
|
+
elsif defined?(controller) && controller.respond_to?(:request) && controller.request.respond_to?(:headers)
|
|
90
|
+
coerce_to_hash(controller.request.headers)
|
|
91
|
+
else
|
|
92
|
+
:unavailable
|
|
93
|
+
end
|
|
94
|
+
rescue => e
|
|
95
|
+
"unavailable (#{e.class}: #{e.message})"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def coerce_to_hash(obj)
|
|
99
|
+
return {} if obj.nil?
|
|
100
|
+
return obj.to_hash if obj.respond_to?(:to_hash)
|
|
101
|
+
return obj.to_h if obj.respond_to?(:to_h)
|
|
102
|
+
if defined?(ActionDispatch::Request::Session) && obj.is_a?(ActionDispatch::Request::Session)
|
|
103
|
+
return obj.to_hash
|
|
104
|
+
end
|
|
105
|
+
obj.inspect
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
RSpec.shared_context "rspec_power::request_dump:after" do
|
|
111
|
+
include RSpecPower::RequestDumpHelpers
|
|
112
|
+
|
|
113
|
+
after(:each) do |example|
|
|
114
|
+
dump_meta = example.metadata[:with_request_dump]
|
|
115
|
+
next unless dump_meta
|
|
116
|
+
|
|
117
|
+
what = if dump_meta.is_a?(Hash) && dump_meta[:what]
|
|
118
|
+
dump_meta[:what]
|
|
119
|
+
else
|
|
120
|
+
[ :session, :cookies, :flash, :headers ]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
dump_state_after_example(example, what)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module RSpecPower
|
|
2
|
+
module ActiveRecordHelpers
|
|
3
|
+
# Fails the example if any SQL is executed within the provided block.
|
|
4
|
+
# Ignores cached, schema, and transaction events to avoid false positives.
|
|
5
|
+
def expect_no_sql
|
|
6
|
+
raise ArgumentError, "expect_no_sql requires a block" unless block_given?
|
|
7
|
+
|
|
8
|
+
executed_sql_statements = capture_executed_sql { yield }
|
|
9
|
+
|
|
10
|
+
if executed_sql_statements.any?
|
|
11
|
+
message = "Expected no SQL to be executed, but #{executed_sql_statements.length} statement(s) were run.\n" \
|
|
12
|
+
+ executed_sql_statements.map { |sql| " - #{sql}" }.join("\n")
|
|
13
|
+
raise RSpec::Expectations::ExpectationNotMetError, message
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Passes only if at least one SQL statement is executed within the block.
|
|
18
|
+
# Ignores cached, schema, and transaction events to avoid false positives.
|
|
19
|
+
def expect_sql
|
|
20
|
+
raise ArgumentError, "expect_sql requires a block" unless block_given?
|
|
21
|
+
|
|
22
|
+
executed_sql_statements = capture_executed_sql { yield }
|
|
23
|
+
|
|
24
|
+
if executed_sql_statements.empty?
|
|
25
|
+
raise RSpec::Expectations::ExpectationNotMetError,
|
|
26
|
+
"Expected some SQL to be executed, but none was run"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Subscribes to ActiveRecord SQL notifications and captures relevant statements
|
|
33
|
+
# executed within the given block. Returns an Array of SQL strings.
|
|
34
|
+
def capture_executed_sql
|
|
35
|
+
raise ArgumentError, "capture_executed_sql requires a block" unless block_given?
|
|
36
|
+
|
|
37
|
+
executed_sql_statements = []
|
|
38
|
+
|
|
39
|
+
notification_callback = lambda do |_name, _started, _finished, _unique_id, payload|
|
|
40
|
+
event_name = payload[:name].to_s
|
|
41
|
+
is_cached_event = payload[:cached]
|
|
42
|
+
|
|
43
|
+
# Skip noise that doesn't represent user-level SQL
|
|
44
|
+
next if is_cached_event
|
|
45
|
+
next if event_name == "SCHEMA" || event_name == "TRANSACTION"
|
|
46
|
+
|
|
47
|
+
executed_sql_statements << payload[:sql]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
ActiveSupport::Notifications.subscribed(notification_callback, "sql.active_record") do
|
|
51
|
+
yield
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
executed_sql_statements
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
RSpec.shared_context "rspec_power::sql:none" do
|
|
60
|
+
around(:each) do |example|
|
|
61
|
+
executed_sql = []
|
|
62
|
+
|
|
63
|
+
callback = lambda do |_name, _started, _finished, _unique_id, payload|
|
|
64
|
+
event_name = payload[:name].to_s
|
|
65
|
+
cached = payload[:cached]
|
|
66
|
+
next if cached
|
|
67
|
+
next if event_name == "SCHEMA" || event_name == "TRANSACTION"
|
|
68
|
+
|
|
69
|
+
executed_sql << payload[:sql]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
73
|
+
example.run
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if executed_sql.any?
|
|
77
|
+
message = "Expected no SQL to be executed, but #{executed_sql.length} statement(s) were run.\n" \
|
|
78
|
+
+ executed_sql.map { |sql| " - #{sql}" }.join("\n")
|
|
79
|
+
raise RSpec::Expectations::ExpectationNotMetError, message
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
RSpec.shared_context "rspec_power::sql:must" do
|
|
85
|
+
around(:each) do |example|
|
|
86
|
+
executed_sql = []
|
|
87
|
+
|
|
88
|
+
callback = lambda do |_name, _started, _finished, _unique_id, payload|
|
|
89
|
+
event_name = payload[:name].to_s
|
|
90
|
+
cached = payload[:cached]
|
|
91
|
+
next if cached
|
|
92
|
+
next if event_name == "SCHEMA" || event_name == "TRANSACTION"
|
|
93
|
+
|
|
94
|
+
executed_sql << payload[:sql]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
98
|
+
example.run
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if executed_sql.empty?
|
|
102
|
+
raise RSpec::Expectations::ExpectationNotMetError, "Expected some SQL to be executed, but none was run"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "active_support/testing/time_helpers"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module RSpecPower
|
|
5
|
+
module TimeHelpers
|
|
6
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
7
|
+
|
|
8
|
+
def with_time_zone(zone)
|
|
9
|
+
if defined?(ActiveSupport::TimeZone)
|
|
10
|
+
::Time.use_zone(zone) { yield }
|
|
11
|
+
else
|
|
12
|
+
yield
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RSpec.shared_context "rspec_power::time:freeze" do
|
|
19
|
+
around(:each) do |example|
|
|
20
|
+
if ts = example.metadata[:with_time_freeze]
|
|
21
|
+
ts = Time.parse(ts) if ts.is_a?(String)
|
|
22
|
+
travel_to(ts) { example.run }
|
|
23
|
+
else
|
|
24
|
+
example.run
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
RSpec.shared_context "rspec_power::time:zone" do
|
|
30
|
+
around(:each) do |example|
|
|
31
|
+
if zone = example.metadata[:with_time_zone]
|
|
32
|
+
with_time_zone(zone) { example.run }
|
|
33
|
+
else
|
|
34
|
+
example.run
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/rspec_power.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "rspec_power/version"
|
|
2
|
+
require "rspec_power/engine"
|
|
3
|
+
require "rspec_power/logging"
|
|
4
|
+
require "rspec_power/env"
|
|
5
|
+
require "rspec_power/i18n"
|
|
6
|
+
require "rspec_power/time"
|
|
7
|
+
require "rspec_power/request_dump"
|
|
8
|
+
require "rspec_power/db_dump"
|
|
9
|
+
require "rspec_power/ci"
|
|
10
|
+
require "rspec_power/sql"
|
|
11
|
+
require "rspec_power/performance"
|
|
12
|
+
require "rspec_power/benchmark"
|
|
13
|
+
|
|
14
|
+
RSpec.configure do |config|
|
|
15
|
+
# Logging
|
|
16
|
+
config.include RSpecPower::LoggingHelpers
|
|
17
|
+
config.include_context "rspec_power::logging:verbose", with_log: true
|
|
18
|
+
config.include_context "rspec_power::logging:verbose", with_logs: true
|
|
19
|
+
config.include_context "rspec_power::logging:active_record", with_log_ar: true
|
|
20
|
+
|
|
21
|
+
# Environment variable overrides
|
|
22
|
+
config.include RSpecPower::EnvHelpers
|
|
23
|
+
config.include_context "rspec_power::env:override", :with_env
|
|
24
|
+
|
|
25
|
+
# I18n
|
|
26
|
+
config.include RSpecPower::I18nHelpers
|
|
27
|
+
config.include_context "rspec_power::i18n:dynamic", :with_locale
|
|
28
|
+
|
|
29
|
+
# Time manipulation
|
|
30
|
+
config.include RSpecPower::TimeHelpers
|
|
31
|
+
config.include_context "rspec_power::time:freeze", :with_time_freeze
|
|
32
|
+
config.include_context "rspec_power::time:zone", :with_time_zone
|
|
33
|
+
|
|
34
|
+
# CI-only guards
|
|
35
|
+
# Prefer new tags with "with_" prefix; keep old ones for compatibility
|
|
36
|
+
config.include_context "rspec_power::ci:only", :with_ci_only
|
|
37
|
+
config.include_context "rspec_power::ci:skip", :with_skip_ci
|
|
38
|
+
config.include_context "rspec_power::ci:only", :ci_only
|
|
39
|
+
config.include_context "rspec_power::ci:skip", :skip_ci
|
|
40
|
+
|
|
41
|
+
# SQL guards
|
|
42
|
+
config.include RSpecPower::ActiveRecordHelpers
|
|
43
|
+
config.include_context "rspec_power::sql:none", :with_no_sql_queries
|
|
44
|
+
config.include_context "rspec_power::sql:must", :with_sql_queries
|
|
45
|
+
|
|
46
|
+
# Performance
|
|
47
|
+
config.include RSpecPower::PerformanceHelpers
|
|
48
|
+
config.include_context "rspec_power::performance:maximum_execution_time", :with_maximum_execution_time
|
|
49
|
+
|
|
50
|
+
# Benchmark
|
|
51
|
+
config.include_context "rspec_power::benchmark:run", :with_benchmark
|
|
52
|
+
|
|
53
|
+
# Request dump helpers (session/cookies/flash/headers)
|
|
54
|
+
config.include RSpecPower::RequestDumpHelpers
|
|
55
|
+
config.include_context "rspec_power::request_dump:after", :with_request_dump
|
|
56
|
+
|
|
57
|
+
# DB dump on failure
|
|
58
|
+
# Prefer :with_dump_db_on_fail; keep :dump_db_on_fail for backward compatibility
|
|
59
|
+
config.include_context "rspec_power::db_dump:on_fail", :with_dump_db_on_fail
|
|
60
|
+
config.include_context "rspec_power::db_dump:on_fail", :dump_db_on_fail
|
|
61
|
+
end
|