Skip to content

Commit c16e83b

Browse files
marcorothkozy4324
andauthored
Fix double escaping in attributes, <script> and <style> tags (#53)
Depends on marcoroth/herb#720 Resolves #12 --------- Co-authored-by: Koji NAKAMURA <kozy4324@gmail.com>
1 parent 07ddc38 commit c16e83b

61 files changed

Lines changed: 1455 additions & 166 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bin/force_update_snapshots

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/bin/bash
2+
3+
set -e # Exit on error
4+
5+
FORCE_UPDATE_SNAPSHOTS=true bundle exec rake test
6+
7+
echo ""
8+
echo "Force updated snapshots!"

bin/update_snapshots

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/bin/bash
2+
3+
set -e # Exit on error
4+
5+
UPDATE_SNAPSHOTS=true bundle exec rake test
6+
7+
echo ""
8+
echo "Updated snapshots!"

lib/reactionview/template/handlers/herb/herb.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ def initialize(input, properties = {})
2020
# Tell Herb whether the template will be compiled with `frozen_string_literal: true`
2121
properties[:freeze_template_literals] = !::ActionView::Template.frozen_string_literal
2222

23+
# Disable all Herb escape functions - let ActionView::OutputBuffer handle escaping
2324
properties[:escapefunc] = ""
25+
properties[:attrfunc] = ""
26+
properties[:jsfunc] = ""
27+
properties[:cssfunc] = ""
2428

2529
super
2630
end

test/snapshot_utils.rb

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@output_buffer.safe_append='Plain text: '.freeze; @output_buffer.safe_expr_append=( 1 + 1 ); @output_buffer.safe_append=' (<with_an_invalid_bracket>)'.freeze;
2+
@output_buffer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Plain text: 2 (<with_an_invalid_bracket>)

0 commit comments

Comments
 (0)