|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +# This is a copy of the SnapshotUtils from Herb: |
| 4 | +# https://github.com/marcoroth/herb/blob/638e09f894e4473b661ec6d345d84f5c4d17aa74/test/snapshot_utils.rb |
| 5 | + |
| 6 | +require "fileutils" |
| 7 | +require "readline" |
| 8 | +require "digest" |
| 9 | +require "json" |
| 10 | + |
| 11 | +def ask?(prompt = "") |
| 12 | + Readline.readline("===> #{prompt}? (y/N) ", true).squeeze(" ").strip == "y" |
| 13 | +end |
| 14 | + |
| 15 | +module SnapshotUtils # rubocop:disable Metrics/ModuleLength |
| 16 | + def assert_compiled_snapshot(source, handler: ReActionView::Template::Handlers::ERB, virtual_path: "test", format: :html, locals: [], options: {}) # rubocop:disable Metrics/ParameterLists |
| 17 | + template = ActionView::Template.new( |
| 18 | + source, |
| 19 | + "test_template", |
| 20 | + handler, |
| 21 | + virtual_path: virtual_path, |
| 22 | + format: format, |
| 23 | + locals: locals |
| 24 | + ) |
| 25 | + |
| 26 | + compiled_source = template.handler.call(template, source) |
| 27 | + |
| 28 | + snapshot_key = JSON.generate({ |
| 29 | + source: source, |
| 30 | + handler: handler, |
| 31 | + virtual_path: virtual_path, |
| 32 | + options: options, |
| 33 | + locals: locals, |
| 34 | + format: format, |
| 35 | + }) |
| 36 | + |
| 37 | + assert_snapshot_matches(compiled_source, snapshot_key, mode: "compiled") |
| 38 | + |
| 39 | + compiled_source |
| 40 | + end |
| 41 | + |
| 42 | + def assert_evaluated_snapshot(source, ivars: {}, options: {}, handler: ReActionView::Template::Handlers::ERB, virtual_path: "test", format: :html, locals: []) # rubocop:disable Metrics/ParameterLists,Layout/LineLength |
| 43 | + template = ActionView::Template.new( |
| 44 | + source, |
| 45 | + "test_template", |
| 46 | + handler, |
| 47 | + virtual_path: virtual_path, |
| 48 | + format: format, |
| 49 | + locals: locals |
| 50 | + ) |
| 51 | + |
| 52 | + compiled_source = template.handler.call(template, source) |
| 53 | + |
| 54 | + lookup_context = ActionView::LookupContext.new([]) |
| 55 | + view_context = ActionView::Base.with_empty_template_cache.new(lookup_context, {}, nil) |
| 56 | + |
| 57 | + ivars.each do |key, value| |
| 58 | + view_context.instance_variable_set(:"@#{key}", value) |
| 59 | + end |
| 60 | + |
| 61 | + result = view_context.instance_eval(compiled_source).to_s |
| 62 | + |
| 63 | + snapshot_key = JSON.generate({ |
| 64 | + source: source, |
| 65 | + ivars: ivars, |
| 66 | + locals: locals, |
| 67 | + options: options, |
| 68 | + handler: handler, |
| 69 | + format: format, |
| 70 | + }) |
| 71 | + |
| 72 | + assert_snapshot_matches(result, snapshot_key, mode: "evaluated") |
| 73 | + |
| 74 | + { compiled: compiled_source, result: result } |
| 75 | + end |
| 76 | + |
| 77 | + def snapshot_changed?(content, source, options = {}) |
| 78 | + if snapshot_file(source, options).exist? |
| 79 | + previous_content = snapshot_file(source, options).read |
| 80 | + |
| 81 | + if previous_content == content |
| 82 | + puts "\n\nSnapshot for '#{class_name} #{name}' didn't change: \n#{snapshot_file(source, options)}\n" |
| 83 | + false |
| 84 | + else |
| 85 | + puts "\n\nSnapshot for '#{class_name} #{name}' changed:\n" |
| 86 | + |
| 87 | + puts Difftastic::Differ.new(color: :always).diff_strings(previous_content, content) |
| 88 | + puts "===============" |
| 89 | + true |
| 90 | + end |
| 91 | + else |
| 92 | + puts "\n\nSnapshot for '#{class_name} #{name}' doesn't exist at: \n#{snapshot_file(source, options)}\n" |
| 93 | + true |
| 94 | + end |
| 95 | + end |
| 96 | + |
| 97 | + def save_failures_to_snapshot(content, source, options = {}) |
| 98 | + return unless snapshot_changed?(content, source, options) |
| 99 | + |
| 100 | + puts "\n==== [ Input for '#{class_name} #{name}' ] =====" |
| 101 | + puts source |
| 102 | + puts "\n\n" |
| 103 | + |
| 104 | + if !ENV["FORCE_UPDATE_SNAPSHOTS"].nil? || |
| 105 | + ask?("Do you want to update (or create) the snapshot for '#{class_name} #{name}'?") |
| 106 | + |
| 107 | + puts "\nUpdating Snapshot for '#{class_name} #{name}' at: \n#{snapshot_file(source, options)}\n" |
| 108 | + |
| 109 | + FileUtils.mkdir_p(snapshot_file(source, options).dirname) |
| 110 | + snapshot_file(source, options).write(content) |
| 111 | + |
| 112 | + puts "\nSnapshot for '#{class_name} #{name}' written: \n#{snapshot_file(source, options)}\n" |
| 113 | + else |
| 114 | + puts "\nNot updating snapshot for '#{class_name} #{name}' at: \n#{snapshot_file(source, options)}.\n" |
| 115 | + end |
| 116 | + end |
| 117 | + |
| 118 | + def assert_snapshot_matches(actual, source, options = {}, mode: nil) |
| 119 | + snapshot_opts = options.dup |
| 120 | + snapshot_opts[:mode] = mode if mode |
| 121 | + |
| 122 | + assert snapshot_file(source, snapshot_opts).exist?, |
| 123 | + "Expected snapshot file to exist: \n#{snapshot_file(source, snapshot_opts).to_path}" |
| 124 | + |
| 125 | + assert_equal snapshot_file(source, snapshot_opts).read, actual |
| 126 | + rescue Minitest::Assertion => e |
| 127 | + save_failures_to_snapshot(actual, source, snapshot_opts) if ENV["UPDATE_SNAPSHOTS"] || ENV["FORCE_UPDATE_SNAPSHOTS"] |
| 128 | + |
| 129 | + raise unless snapshot_file(source, snapshot_opts).exist? |
| 130 | + |
| 131 | + if snapshot_file(source, snapshot_opts)&.read != actual |
| 132 | + puts |
| 133 | + |
| 134 | + divider = "=" * `tput cols`.strip.to_i |
| 135 | + |
| 136 | + flunk(<<~MESSAGE) |
| 137 | + \e[0m |
| 138 | + #{divider} |
| 139 | + #{Difftastic::Differ.new(color: :always).diff_strings(snapshot_file(source, snapshot_opts).read, actual)} |
| 140 | + \e[31m#{divider} |
| 141 | +
|
| 142 | + Snapshots for "#{class_name} #{name}" didn't match. |
| 143 | +
|
| 144 | + Run the test using UPDATE_SNAPSHOTS=true to update (or create) the snapshot file for "#{class_name} #{name}" |
| 145 | +
|
| 146 | + UPDATE_SNAPSHOTS=true mtest #{e.location} |
| 147 | +
|
| 148 | + #{divider} |
| 149 | + \e[0m |
| 150 | + MESSAGE |
| 151 | + end |
| 152 | + end |
| 153 | + |
| 154 | + def snapshot_file(source, options = {}) # rubocop:disable Metrics/MethodLength |
| 155 | + test_class_name = underscore(self.class.name) |
| 156 | + |
| 157 | + content_hash = Digest::MD5.hexdigest(source || "#{source.class}-#{source.inspect}") |
| 158 | + |
| 159 | + test_name = sanitize_name_for_filesystem(name) |
| 160 | + |
| 161 | + mode = options[:mode] |
| 162 | + mode_suffix = mode ? "_#{mode}" : "" |
| 163 | + |
| 164 | + opts_for_hash = options.except(:mode) |
| 165 | + |
| 166 | + if opts_for_hash && !opts_for_hash.empty? |
| 167 | + options_hash = Digest::MD5.hexdigest(opts_for_hash.inspect) |
| 168 | + expected_snapshot_filename = "#{test_name}#{mode_suffix}_#{content_hash}-#{options_hash}.txt" |
| 169 | + else |
| 170 | + expected_snapshot_filename = "#{test_name}#{mode_suffix}_#{content_hash}.txt" |
| 171 | + end |
| 172 | + |
| 173 | + base_path = Pathname.new("test/snapshots/") / test_class_name |
| 174 | + expected_snapshot_path = base_path / expected_snapshot_filename |
| 175 | + |
| 176 | + return expected_snapshot_path if expected_snapshot_path.exist? |
| 177 | + |
| 178 | + matching_md5_files = if opts_for_hash && !opts_for_hash.empty? |
| 179 | + Dir[base_path / "*#{mode_suffix}_#{content_hash}-#{options_hash}.txt"] |
| 180 | + else |
| 181 | + Dir[base_path / "*#{mode_suffix}_#{content_hash}.txt"] |
| 182 | + end |
| 183 | + |
| 184 | + if matching_md5_files.any? && matching_md5_files.length == 1 |
| 185 | + old_file = Pathname.new(matching_md5_files.first) |
| 186 | + |
| 187 | + return expected_snapshot_path if old_file.rename(expected_snapshot_path).zero? |
| 188 | + |
| 189 | + return old_file |
| 190 | + end |
| 191 | + |
| 192 | + expected_snapshot_path |
| 193 | + end |
| 194 | + |
| 195 | + private |
| 196 | + |
| 197 | + def sanitize_name_for_filesystem(name) |
| 198 | + [ |
| 199 | + # ntfs reserved characters |
| 200 | + # https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file |
| 201 | + ["<", "lt"], |
| 202 | + [">", "gt"], |
| 203 | + [":", ""], |
| 204 | + ["/", "_"], |
| 205 | + ["\\", ""], |
| 206 | + ["|", ""], |
| 207 | + ["?", ""], |
| 208 | + ["*", ""], |
| 209 | + |
| 210 | + [" ", "_"] |
| 211 | + ].inject(name) { |name, substitution| name.gsub(substitution[0], substitution[1]) } |
| 212 | + end |
| 213 | + |
| 214 | + def underscore(string) |
| 215 | + string.gsub("::", "/") |
| 216 | + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') |
| 217 | + .gsub(/([a-z\d])([A-Z])/, '\1_\2') |
| 218 | + .tr("-", "_") |
| 219 | + .tr(" ", "_") |
| 220 | + .downcase |
| 221 | + end |
| 222 | +end |
0 commit comments