spec_views 1.1.1 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +111 -5
  3. data/Rakefile +3 -1
  4. data/app/assets/config/spec_views_manifest.js +1 -0
  5. data/app/assets/javascripts/spec_views/diff.js +60 -0
  6. data/app/assets/javascripts/spec_views/jsdiff.js +1055 -0
  7. data/app/controllers/spec_views/views_controller.rb +35 -87
  8. data/app/models/spec_views/base_matcher.rb +66 -0
  9. data/app/models/spec_views/capybara_session_extractor.rb +30 -0
  10. data/app/models/spec_views/directory.rb +149 -0
  11. data/app/models/spec_views/html_matcher.rb +41 -0
  12. data/app/models/spec_views/http_response_extractor.rb +30 -0
  13. data/app/models/spec_views/mail_message_extractor.rb +31 -0
  14. data/app/models/spec_views/pdf_matcher.rb +53 -0
  15. data/app/models/spec_views/view_sanitizer.rb +20 -0
  16. data/app/views/layouts/spec_views.html.erb +261 -0
  17. data/app/views/spec_views/views/_actions.html.erb +11 -0
  18. data/app/views/spec_views/views/_directory_footer.html.erb +14 -0
  19. data/app/views/spec_views/views/compare.html.erb +11 -0
  20. data/app/views/spec_views/views/diff.html.erb +16 -0
  21. data/app/views/spec_views/views/index.html.erb +48 -0
  22. data/app/views/spec_views/views/preview.html.erb +32 -0
  23. data/config/routes.rb +3 -0
  24. data/lib/spec_views/configuration.rb +3 -2
  25. data/lib/spec_views/engine.rb +10 -0
  26. data/lib/spec_views/support.rb +61 -174
  27. data/lib/spec_views/version.rb +3 -1
  28. data/lib/spec_views.rb +3 -1
  29. data/lib/tasks/spec_views_tasks.rake +1 -0
  30. metadata +28 -15
  31. data/app/views/layouts/spec_views.html.haml +0 -200
  32. data/app/views/spec_views/views/compare.html.haml +0 -16
  33. data/app/views/spec_views/views/index.html.haml +0 -29
  34. data/app/views/spec_views/views/preview.html.haml +0 -19
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SpecViews
4
4
  class ViewsController < ApplicationController
5
- skip_authorization_check
5
+ skip_authorization_check if respond_to?(:skip_authorization_check)
6
6
  layout 'spec_views'
7
7
 
8
8
  def index
@@ -19,16 +19,16 @@ module SpecViews
19
19
  end
20
20
 
21
21
  def show
22
- view = filename('view')
23
- view = filename('challenger') if params[:view] == 'challenger'
24
- if pdf?
22
+ path = directory.champion_path
23
+ path = directory.challenger_path if params[:view] == 'challenger'
24
+ if directory.content_type.pdf?
25
25
  send_data(
26
- get_view(view),
26
+ get_view(path),
27
27
  filename: 'a.pdf',
28
28
  type: 'application/pdf', disposition: 'inline'
29
29
  )
30
30
  else
31
- render html: get_view(view)
31
+ render html: get_view(path)
32
32
  end
33
33
  end
34
34
 
@@ -36,15 +36,25 @@ module SpecViews
36
36
  @directory = directory
37
37
  end
38
38
 
39
+ def diff
40
+ if directory.content_type.pdf?
41
+ redirect_to action: :compare
42
+ return
43
+ end
44
+ @champion = get_view(directory.champion_path, html_safe: false)
45
+ @challenger = get_view(directory.challenger_path, html_safe: false)
46
+ @directory = directory
47
+ end
48
+
39
49
  def preview
40
50
  @directory = directory
41
51
  @next = directories[directories.index(@directory) + 1]
42
52
  index = directories.index(@directory)
43
- @previous = directories[index - 1] if index > 0
53
+ @previous = directories[index - 1] if index.positive?
44
54
  end
45
55
 
46
56
  def accept
47
- accept_directory(params[:id])
57
+ accept_directory(directory)
48
58
  redirect_to action: :index, challenger: :next
49
59
  end
50
60
 
@@ -56,8 +66,7 @@ module SpecViews
56
66
  end
57
67
 
58
68
  def reject
59
- challenger = file_path(filename('challenger'))
60
- FileUtils.remove_file(challenger)
69
+ FileUtils.remove_file(directory.challenger_path)
61
70
  redirect_to action: :index, challenger: :next
62
71
  end
63
72
 
@@ -72,66 +81,20 @@ module SpecViews
72
81
 
73
82
  private
74
83
 
75
- class Directory
76
- attr_reader :path
77
-
78
- def initialize(path)
79
- @path = path
80
- end
81
-
82
- def basename
83
- path.basename
84
- end
85
-
86
- def to_param
87
- basename.to_s
88
- end
89
-
90
- def name
91
- basename
92
- end
93
-
94
- def challenger?
95
- File.file?(path.join('challenger.html')) || File.file?(path.join('challenger.pdf'))
96
- end
97
-
98
- def controller_name
99
- splitted_name.first.gsub(/Controller(_.*)$/, 'Controller').gsub(/Controller$/, '').gsub('_', '::')
100
- end
101
-
102
- def method
103
- splitted_name.second[0, 3]
104
- end
105
-
106
- def description
107
- splitted_name.third.gsub(/__pdf$/, '').humanize
108
- end
109
-
110
- def last_run
111
- @last_run ||= begin
112
- Time.zone.parse(File.read(path.join('last_run.txt')))
113
- rescue Errno::ENOENT
114
- Time.zone.at(0)
115
- end
116
- end
117
-
118
- def delete!
119
- FileUtils.remove_dir(path)
84
+ def directories
85
+ @directories ||= Pathname.new(directory_path).children.select(&:directory?).map do |path|
86
+ SpecViews::Directory.new(path)
120
87
  end
88
+ latest_run = @directories.map(&:last_run).max
121
89
 
122
- private
123
-
124
- def splitted_name
125
- @splitted_name ||= name.to_s.split(/_(DELETE|GET|PATCH|POST|PUT)_/)
90
+ @directories.sort_by! do |dir|
91
+ prefix = '3-'
92
+ prefix = '2-' if dir.last_run < latest_run
93
+ prefix = '1-' if dir.challenger?
94
+ "#{prefix}#{dir.basename}"
126
95
  end
127
96
  end
128
97
 
129
- def directories
130
- @directories ||= Pathname.new(directory_path).children.select(&:directory?).map do |path|
131
- Directory.new(path)
132
- end.sort_by(&:basename)
133
- end
134
-
135
98
  def directory_path
136
99
  Rails.root.join(Rails.configuration.spec_views.directory)
137
100
  end
@@ -140,32 +103,17 @@ module SpecViews
140
103
  directories.detect { |dir| dir.to_param == params[:id] }
141
104
  end
142
105
 
143
- def get_view(filename = nil)
144
- filename ||= pdf? ? 'view.pdf' : 'view.html'
145
- File.read(file_path(filename)).html_safe # rubocop:disable Rails/OutputSafety
106
+ def get_view(path, html_safe: true)
107
+ content = File.read(path)
108
+ content = content.html_safe if html_safe
109
+ content
146
110
  rescue Errno::ENOENT
147
111
  ''
148
112
  end
149
113
 
150
- def file_path(filename, id: nil)
151
- id ||= params[:id]
152
- id = id.to_param if id.respond_to?(:to_param)
153
- directory_path.join(id, filename)
154
- end
155
-
156
- def filename(base = 'view')
157
- pdf? ? "#{base}.pdf" : "#{base}.html"
158
- end
159
-
160
- def pdf?
161
- params[:id] && params[:id].end_with?('__pdf')
162
- end
163
-
164
- def accept_directory(id)
165
- champion = file_path(filename('view'), id: id)
166
- challenger = file_path(filename('challenger'), id: id)
167
- FileUtils.copy_file(challenger, champion)
168
- FileUtils.remove_file(challenger)
114
+ def accept_directory(dir)
115
+ FileUtils.copy_file(dir.challenger_path, dir.champion_path)
116
+ FileUtils.remove_file(dir.challenger_path)
169
117
  end
170
118
  end
171
119
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecViews
4
+ class BaseMatcher
5
+ attr_reader :response, :directory, :failure_message, :sanitizer, :type
6
+
7
+ delegate :champion_html, to: :directory
8
+
9
+ def initialize(response, description, run_time:, expected_status: :ok, sanitizer: nil, type: :request)
10
+ @response = response
11
+ @type = type
12
+ @sanitizer = sanitizer
13
+ @extractor = extractor_class.new(response, expected_status: expected_status)
14
+ @directory = SpecViews::Directory.for_description(description, content_type: content_type)
15
+ @extractor_failure = @extractor.extractor_failure?
16
+ @match = !extractor_failure? && match_challenger
17
+ directory.write_last_run(run_time) if champion_html
18
+ return if match?
19
+
20
+ if extractor_failure?
21
+ @failure_message = @extractor.failure_message
22
+ return
23
+ end
24
+
25
+ @failure_message = "#{subject_name} has changed."
26
+ @failure_message = "#{subject_name} has been added." if champion_html.nil?
27
+
28
+ directory.write_last_run(run_time)
29
+ directory.write_meta(description, type, content_type)
30
+ @directory.write_challenger(challenger_body)
31
+ end
32
+
33
+ def match?
34
+ @match
35
+ end
36
+
37
+ def extractor_failure?
38
+ @extractor_failure
39
+ end
40
+
41
+ protected
42
+
43
+ def subject_name
44
+ raise NotImplementedError
45
+ end
46
+
47
+ def challenger_body
48
+ raise NotImplementedError
49
+ end
50
+
51
+ def match_challenger
52
+ raise NotImplementedError
53
+ end
54
+
55
+ def content_type
56
+ raise NotImplementedError
57
+ end
58
+
59
+ def extractor_class
60
+ return CapybaraSessionExtractor if type == :feature
61
+ return MailMessageExtractor if type == :mailer
62
+
63
+ HttpResponseExtractor
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecViews
4
+ class CapybaraSessionExtractor
5
+ attr_reader :page, :failure_message
6
+
7
+ def initialize(page, expected_status:)
8
+ @page = page
9
+ @status_match = response_status_match?(page, expected_status)
10
+ return if @status_match
11
+
12
+ @failure_message = "Unexpected response status #{page.status_code}."
13
+ end
14
+
15
+ def extractor_failure?
16
+ failure_message.present?
17
+ end
18
+
19
+ def body
20
+ page.source
21
+ end
22
+
23
+ private
24
+
25
+ def response_status_match?(page, expected_status)
26
+ page.status_code == expected_status ||
27
+ Rack::Utils::SYMBOL_TO_STATUS_CODE.invert[page.status_code] == expected_status.to_sym
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecViews
4
+ class Directory
5
+ attr_reader :path
6
+
7
+ def self.for_description(description, content_type: :html)
8
+ dir_name = description.strip.gsub(/[^0-9A-Za-z.\-]/, '_').gsub('__', '_')
9
+ new(Rails.root.join(Rails.configuration.spec_views.directory, dir_name), content_type)
10
+ end
11
+
12
+ def initialize(path, content_type = nil)
13
+ @path = path
14
+ @content_type = ActiveSupport::StringInquirer.new(content_type.to_s) if content_type
15
+ end
16
+
17
+ def basename
18
+ path.basename
19
+ end
20
+
21
+ def to_param
22
+ basename.to_s
23
+ end
24
+
25
+ def name
26
+ basename
27
+ end
28
+
29
+ def controller_name
30
+ splitted_description.first.gsub(/Controller(_.*)$/, 'Controller').gsub(/Controller$/, '').gsub('_', '::')
31
+ end
32
+
33
+ def method
34
+ splitted_description.second[0, 4]
35
+ end
36
+
37
+ def description_tail
38
+ splitted_description.third
39
+ end
40
+
41
+ def delete!
42
+ FileUtils.remove_dir(path)
43
+ end
44
+
45
+ def champion_path
46
+ path.join("view.#{file_extension}")
47
+ end
48
+
49
+ def champion_html
50
+ File.read(champion_path)
51
+ rescue Errno::ENOENT
52
+ nil
53
+ end
54
+
55
+ def challenger_path
56
+ path.join("challenger.#{file_extension}")
57
+ end
58
+
59
+ def challenger?
60
+ File.file?(challenger_path)
61
+ end
62
+
63
+ def write_challenger(content)
64
+ FileUtils.mkdir_p(path)
65
+ File.open(challenger_path, binary? ? 'wb' : 'w') { |f| f.write(content) }
66
+ end
67
+
68
+ def meta_path
69
+ path.join('meta.txt')
70
+ end
71
+
72
+ def write_meta(description, spec_type, content_type)
73
+ FileUtils.mkdir_p(path)
74
+ lines = [
75
+ description.to_s.gsub("\n", ' '),
76
+ '',
77
+ spec_type.to_s,
78
+ content_type.to_s
79
+ ]
80
+ File.write(meta_path, lines.join("\n"))
81
+ end
82
+
83
+ def meta
84
+ @meta ||= File.read(meta_path).lines.map(&:strip)
85
+ end
86
+
87
+ def description
88
+ meta[0]
89
+ rescue Errno::ENOENT
90
+ name
91
+ end
92
+
93
+ def spec_type
94
+ ActiveSupport::StringInquirer.new(meta[2])
95
+ rescue Errno::ENOENT
96
+ :response
97
+ end
98
+
99
+ def content_type
100
+ @content_type ||= begin
101
+ ActiveSupport::StringInquirer.new(meta[3])
102
+ rescue Errno::ENOENT
103
+ :response
104
+ end
105
+ end
106
+
107
+ def binary?
108
+ content_type.pdf?
109
+ end
110
+
111
+ def last_run_path
112
+ path.join('last_run.txt')
113
+ end
114
+
115
+ def write_last_run(run_time)
116
+ FileUtils.mkdir_p(path)
117
+ File.write(last_run_path, run_time.to_s)
118
+ end
119
+
120
+ def last_run
121
+ Time.zone.parse(File.read(last_run_path).strip)
122
+ rescue Errno::ENOENT
123
+ Time.zone.at(0)
124
+ end
125
+
126
+ private
127
+
128
+ def splitted_description
129
+ @splitted_description ||= begin
130
+ if spec_type.feature?
131
+ split = ['', 'FEAT', description]
132
+ elsif spec_type.mailer?
133
+ split = description.to_s.match(/(\A[a-zA-Z0-9:]+)(Mailer)(.*)/)
134
+ split = split[1..3]
135
+ split[0] += 'Mailer'
136
+ split[1] = 'MAIL'
137
+ else
138
+ split = description.to_s.split(/\s(DELETE|GET|PATCH|POST|PUT)\s/)
139
+ end
140
+ split = ['', '', split[0]] if split.size == 1
141
+ split
142
+ end
143
+ end
144
+
145
+ def file_extension
146
+ content_type.pdf? ? 'pdf' : 'html'
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecViews
4
+ class HtmlMatcher < BaseMatcher
5
+ delegate :champion_html, to: :directory
6
+
7
+ protected
8
+
9
+ def subject_name
10
+ 'View'
11
+ end
12
+
13
+ def content_type
14
+ :html
15
+ end
16
+
17
+ def challenger_body
18
+ sanitized_body
19
+ end
20
+
21
+ def match_challenger
22
+ champion_html == challenger_body
23
+ end
24
+
25
+ private
26
+
27
+ def sanitized_body
28
+ body = remove_pack_digests_from_body(remove_digests_from_body(@extractor.body))
29
+ body = sanitizer.sanitize(body) if sanitizer
30
+ body
31
+ end
32
+
33
+ def remove_digests_from_body(body)
34
+ body.gsub(/(-[a-z0-9]{64})(\.css|\.js|\.ico|\.png|\.jpg|\.jpeg|\.svg|\.gif)/, '\2')
35
+ end
36
+
37
+ def remove_pack_digests_from_body(body)
38
+ body.gsub(%r{(packs.*/js/[a-z0-9_]+)(-[a-z0-9]{20})(\.js)}, '\1\3')
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecViews
4
+ class HttpResponseExtractor
5
+ attr_reader :response, :failure_message
6
+
7
+ def initialize(response, expected_status:)
8
+ @response = response
9
+ @status_match = response_status_match?(response, expected_status)
10
+ return if @status_match
11
+
12
+ @failure_message = "Unexpected response status #{response.status}."
13
+ end
14
+
15
+ def extractor_failure?
16
+ failure_message.present?
17
+ end
18
+
19
+ def body
20
+ response.body
21
+ end
22
+
23
+ private
24
+
25
+ def response_status_match?(response, expected_status)
26
+ response.status == expected_status ||
27
+ response.message.parameterize.underscore == expected_status.to_s
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecViews
4
+ class MailMessageExtractor
5
+ attr_reader :mail, :failure_message, :body
6
+
7
+ def initialize(mail, expected_status:)
8
+ @mail = mail
9
+ @body = extract_body(mail)
10
+ return if body.present?
11
+
12
+ @failure_message = 'Failed to find mail part'
13
+ end
14
+
15
+ def extractor_failure?
16
+ failure_message.present?
17
+ end
18
+
19
+ def extract_body(part)
20
+ return part.raw_source if part.respond_to?(:raw_source) && part.raw_source.present?
21
+ return extract_body(part.body) if part.respond_to?(:body)
22
+ return part if part.is_a?(String)
23
+
24
+ return if !part.respond_to?(:parts) && nil
25
+
26
+ part.parts.map do |inner_part|
27
+ extract_body(inner_part)
28
+ end.compact.first
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecViews
4
+ class PdfMatcher < BaseMatcher
5
+ protected
6
+
7
+ def subject_name
8
+ 'PDF'
9
+ end
10
+
11
+ def content_type
12
+ :pdf
13
+ end
14
+
15
+ def challenger_body
16
+ sanitized_body
17
+ end
18
+
19
+ def match_challenger
20
+ champion_hash == challenger_hash
21
+ end
22
+
23
+ private
24
+
25
+ def champion_hash
26
+ @champion_hash ||= begin
27
+ Digest::MD5.file(directory.champion_path).hexdigest
28
+ rescue Errno::ENOENT
29
+ nil
30
+ end
31
+ end
32
+
33
+ def challenger_hash
34
+ @challenger_hash ||= Digest::MD5.hexdigest(sanitized_body)
35
+ end
36
+
37
+ def sanitized_body
38
+ @sanitized_body ||= begin
39
+ body = remove_headers_from_pdf(@extractor.body)
40
+ body = sanitizer.sanitize(body) if sanitizer
41
+ body
42
+ end
43
+ end
44
+
45
+ def remove_headers_from_pdf(body)
46
+ body
47
+ .force_encoding('BINARY')
48
+ .gsub(%r{^/CreationDate \(.*\)$}, '')
49
+ .gsub(%r{^/ModDate \(.*\)$}, '')
50
+ .gsub(%r{^/ID \[<.+><.+>\]$}, '')
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,20 @@
1
+ module SpecViews
2
+ class ViewSanitizer
3
+ delegate :each, to: :@sanitizers
4
+
5
+ def initialize
6
+ @sanitizers = []
7
+ end
8
+
9
+ def gsub(*args, &block)
10
+ @sanitizers << [:gsub, args, block]
11
+ end
12
+
13
+ def sanitize(string)
14
+ each do |sanitize_method, args, block|
15
+ string = string.send(sanitize_method, *args, &block)
16
+ end
17
+ string
18
+ end
19
+ end
20
+ end