spec_views 1.1.0 → 3.0.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.
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 +65 -0
  9. data/app/models/spec_views/capybara_session_extractor.rb +30 -0
  10. data/app/models/spec_views/directory.rb +140 -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 -170
  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
104
- end
105
-
106
- def description
107
- splitted_name.third.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,65 @@
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_meta(description, run_time, type, content_type) 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_meta(description, run_time, type, content_type)
29
+ @directory.write_challenger(challenger_body)
30
+ end
31
+
32
+ def match?
33
+ @match
34
+ end
35
+
36
+ def extractor_failure?
37
+ @extractor_failure
38
+ end
39
+
40
+ protected
41
+
42
+ def subject_name
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def challenger_body
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def match_challenger
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def content_type
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def extractor_class
59
+ return CapybaraSessionExtractor if type == :feature
60
+ return MailMessageExtractor if type == :mailer
61
+
62
+ HttpResponseExtractor
63
+ end
64
+ end
65
+ 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,140 @@
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, run_time, spec_type, content_type)
73
+ FileUtils.mkdir_p(path)
74
+ lines = [
75
+ description.to_s.gsub("\n", ' '),
76
+ run_time.to_s,
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 last_run
94
+ Time.zone.parse(meta[1])
95
+ rescue Errno::ENOENT
96
+ Time.zone.at(0)
97
+ end
98
+
99
+ def spec_type
100
+ ActiveSupport::StringInquirer.new(meta[2])
101
+ rescue Errno::ENOENT
102
+ :response
103
+ end
104
+
105
+ def content_type
106
+ @content_type ||= begin
107
+ ActiveSupport::StringInquirer.new(meta[3])
108
+ rescue Errno::ENOENT
109
+ :response
110
+ end
111
+ end
112
+
113
+ def binary?
114
+ content_type.pdf?
115
+ end
116
+
117
+ private
118
+
119
+ def splitted_description
120
+ @splitted_description ||= begin
121
+ if spec_type.feature?
122
+ split = ['', 'FEAT', description]
123
+ elsif spec_type.mailer?
124
+ split = description.to_s.match(/(\A[a-zA-Z0-9:]+)(Mailer)(.*)/)
125
+ split = split[1..3]
126
+ split[0] += 'Mailer'
127
+ split[1] = 'MAIL'
128
+ else
129
+ split = description.to_s.split(/\s(DELETE|GET|PATCH|POST|PUT)\s/)
130
+ end
131
+ split = ['', '', split[0]] if split.size == 1
132
+ split
133
+ end
134
+ end
135
+
136
+ def file_extension
137
+ content_type.pdf? ? 'pdf' : 'html'
138
+ end
139
+ end
140
+ 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