appium_failure_helper 0.6.1 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0e272f2150ce88e42b3b414d0afeb9d4e2309311bd271d77bf912ac3967ad03
4
- data.tar.gz: 982e97ea6c9a43da40ea9315f554bc734b076b20aaf68b61f1cef83bf3455349
3
+ metadata.gz: 87d48b6025e058dfc945cc71675a8389ffd7c219a8bedccf8994327b10d02760
4
+ data.tar.gz: 700ae3934bd4413d7ea30ac589493dca717e199dd0a69374156ec2f4bda966b3
5
5
  SHA512:
6
- metadata.gz: 8d431703babb06ffde65176ec6b16233738790261efbee46376f2b6bb90f1fd42717505d4ab6c78b454bf61bc3e656c2f8e9e1ef3936ea8841b585ec152e51f3
7
- data.tar.gz: d7df2cb2f3757ae8298ce7b8057e11ea66b0112ba38fb15e01e6e8bcb73b3dad26035bcfdeba8dc97e823bf608de787b2b47c3dceb28d0b988525812b4730ebd
6
+ metadata.gz: 83321c33adbc356d531abe28f2f792b635e5e2658676c7510afabd263e4c7f99b4d4c5dcbaaf0d20bba569a62841b812f50015b5901f3e860ebe0b6f44bbbcc2
7
+ data.tar.gz: 35935a71ebd0ff920cfeee986d0918337f706146b680c7a5f85bba334dcc790d0ca4c01bb8bcf1b3aec2efaa95a5d758fc6b416ece69bd2504b3c9b59ac45a91
@@ -3,233 +3,165 @@ require 'fileutils'
3
3
  require 'base64'
4
4
  require 'yaml'
5
5
  require 'logger'
6
+ require 'did_you_mean'
7
+ require 'cgi' # Adicionado para garantir o escape de HTML
6
8
 
7
9
  module AppiumFailureHelper
8
10
  class Capture
11
+ # ... (Constantes PREFIX, MAX_VALUE_LENGTH, @@logger permanecem iguais) ...
9
12
  PREFIX = {
10
- 'android.widget.Button' => 'btn',
11
- 'android.widget.TextView' => 'txt',
12
- 'android.widget.ImageView' => 'img',
13
- 'android.widget.EditText' => 'input',
14
- 'android.widget.CheckBox' => 'chk',
15
- 'android.widget.RadioButton' => 'radio',
16
- 'android.widget.Switch' => 'switch',
17
- 'android.widget.ViewGroup' => 'group',
18
- 'android.widget.View' => 'view',
19
- 'android.widget.FrameLayout' => 'frame',
20
- 'android.widget.LinearLayout' => 'linear',
21
- 'android.widget.RelativeLayout' => 'relative',
22
- 'android.widget.ScrollView' => 'scroll',
23
- 'android.webkit.WebView' => 'web',
24
- 'android.widget.Spinner' => 'spin',
25
- 'XCUIElementTypeButton' => 'btn',
26
- 'XCUIElementTypeStaticText' => 'txt',
27
- 'XCUIElementTypeTextField' => 'input',
28
- 'XCUIElementTypeImage' => 'img',
29
- 'XCUIElementTypeSwitch' => 'switch',
30
- 'XCUIElementTypeScrollView' => 'scroll',
31
- 'XCUIElementTypeOther' => 'elm',
32
- 'XCUIElementTypeCell' => 'cell',
13
+ 'android.widget.Button' => 'btn', 'android.widget.TextView' => 'txt',
14
+ 'android.widget.ImageView' => 'img', 'android.widget.EditText' => 'input',
15
+ 'android.widget.CheckBox' => 'chk', 'android.widget.RadioButton' => 'radio',
16
+ 'android.widget.Switch' => 'switch', 'android.widget.ViewGroup' => 'group',
17
+ 'android.widget.View' => 'view', 'android.widget.FrameLayout' => 'frame',
18
+ 'android.widget.LinearLayout' => 'linear', 'android.widget.RelativeLayout' => 'relative',
19
+ 'android.widget.ScrollView' => 'scroll', 'android.webkit.WebView' => 'web',
20
+ 'android.widget.Spinner' => 'spin', 'XCUIElementTypeButton' => 'btn',
21
+ 'XCUIElementTypeStaticText' => 'txt', 'XCUIElementTypeTextField' => 'input',
22
+ 'XCUIElementTypeImage' => 'img', 'XCUIElementTypeSwitch' => 'switch',
23
+ 'XCUIElementTypeScrollView' => 'scroll', 'XCUIElementTypeOther' => 'elm',
24
+ 'XCUIElementTypeCell' => 'cell'
33
25
  }.freeze
34
-
35
26
  MAX_VALUE_LENGTH = 100
36
27
  @@logger = nil
37
28
 
29
+ # --- MÉTODO PRINCIPAL (SEM ALTERAÇÕES) ---
38
30
  def self.handler_failure(driver, exception)
39
31
  begin
40
32
  self.setup_logger unless @@logger
41
-
42
- FileUtils.rm_rf("reports_failure")
43
- @@logger.info("Pasta 'reports_failure' removida para uma nova execução.")
44
-
45
33
  timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
46
34
  output_folder = "reports_failure/failure_#{timestamp}"
47
-
48
35
  FileUtils.mkdir_p(output_folder)
49
36
  @@logger.info("Pasta de saída criada: #{output_folder}")
50
-
51
37
  screenshot_base64 = driver.screenshot_as(:base64)
52
- screenshot_path = "#{output_folder}/screenshot_#{timestamp}.png"
53
- File.open(screenshot_path, 'wb') do |f|
54
- f.write(Base64.decode64(screenshot_base64))
55
- end
56
- @@logger.info("Screenshot salvo em #{screenshot_path}")
57
-
58
38
  page_source = driver.page_source
59
- xml_path = "#{output_folder}/page_source_#{timestamp}.xml"
60
- File.write(xml_path, page_source)
61
- @@logger.info("Page source salvo em #{xml_path}")
62
-
39
+ File.write("#{output_folder}/page_source_#{timestamp}.xml", page_source)
63
40
  doc = Nokogiri::XML(page_source)
64
41
  platform = driver.capabilities['platformName']&.downcase || 'unknown'
65
-
66
42
  failed_element_info = self.extract_info_from_exception(exception)
67
-
68
- seen_elements = {}
69
- all_elements_suggestions = []
70
- doc.xpath('//*').each do |node|
71
- next if node.name == 'hierarchy'
72
- attrs = node.attributes.transform_values(&:value)
73
-
74
- unique_key = "#{node.name}|#{attrs['resource-id'].to_s}|#{attrs['content-desc'].to_s}|#{attrs['text'].to_s}"
75
-
76
- unless seen_elements[unique_key]
77
- name = self.suggest_name(node.name, attrs)
78
- locators = self.xpath_generator(node.name, attrs, platform)
79
-
80
- all_elements_suggestions << { name: name, locators: locators }
81
- seen_elements[unique_key] = true
82
- end
43
+ local_element_map = self.load_local_element_map
44
+ de_para_result = nil
45
+ logical_name_key = failed_element_info[:selector_value].to_s.gsub(/^#/, '')
46
+ if local_element_map.key?(logical_name_key)
47
+ de_para_result = {
48
+ logical_name: logical_name_key,
49
+ correct_locator: local_element_map[logical_name_key]
50
+ }
83
51
  end
84
-
52
+ all_elements_suggestions = self.get_all_elements_from_screen(doc, platform)
53
+ similar_elements = self.find_similar_elements(failed_element_info, all_elements_suggestions)
85
54
  targeted_report = {
86
55
  failed_element: failed_element_info,
87
- similar_elements: [],
56
+ similar_elements: similar_elements,
57
+ de_para_analysis: de_para_result
88
58
  }
89
-
90
- if failed_element_info && failed_element_info[:selector_value]
91
- targeted_report[:similar_elements] = self.find_similar_elements(doc, failed_element_info, platform)
92
- end
93
-
94
- targeted_yaml_path = "#{output_folder}/failure_analysis_#{timestamp}.yaml"
95
- File.open(targeted_yaml_path, 'w') do |f|
96
- f.write(YAML.dump(targeted_report))
97
- end
98
- @@logger.info("Análise direcionada salva em #{targeted_yaml_path}")
99
-
100
- full_dump_yaml_path = "#{output_folder}/all_elements_dump_#{timestamp}.yaml"
101
- File.open(full_dump_yaml_path, 'w') do |f|
102
- f.write(YAML.dump(all_elements_suggestions))
103
- end
104
- @@logger.info("Dump completo da página salvo em #{full_dump_yaml_path}")
105
-
106
- html_report_path = "#{output_folder}/report_#{timestamp}.html"
59
+ File.open("#{output_folder}/failure_analysis_#{timestamp}.yaml", 'w') { |f| f.write(YAML.dump(targeted_report)) }
60
+ File.open("#{output_folder}/all_elements_dump_#{timestamp}.yaml", 'w') { |f| f.write(YAML.dump(all_elements_suggestions)) }
107
61
  html_content = self.generate_html_report(targeted_report, all_elements_suggestions, screenshot_base64, platform, timestamp)
108
- File.write(html_report_path, html_content)
109
- @@logger.info("Relatório HTML completo salvo em #{html_report_path}")
110
-
62
+ File.write("#{output_folder}/report_#{timestamp}.html", html_content)
63
+ @@logger.info("Relatórios gerados com sucesso em: #{output_folder}")
111
64
  rescue => e
112
65
  @@logger.error("Erro ao capturar detalhes da falha: #{e.message}\n#{e.backtrace.join("\n")}")
113
66
  end
114
67
  end
115
68
 
116
69
  private
117
-
118
- def self.setup_logger
119
- @@logger ||= Logger.new(STDOUT)
120
- @@logger.level = Logger::INFO
121
- @@logger.formatter = proc do |severity, datetime, progname, msg|
122
- "#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{msg}\n"
123
- end
124
- end
125
-
126
- def self.extract_info_from_exception(exception)
127
- message = exception.to_s
128
- info = {}
129
-
130
- # normaliza tipos capturados para nomes previsíveis
131
- normalize_type = lambda do |t|
132
- return 'unknown' unless t
133
- t = t.to_s.downcase.strip
134
- t = t.gsub(/["']/, '')
135
- case t
136
- when 'cssselector', 'css selector' then 'css'
137
- when 'classname', 'class name' then 'class name'
138
- when 'accessibilityid', 'accessibility-id', 'accessibility id' then 'accessibility-id'
139
- when 'resourceid', 'resource-id', 'resource id', 'id' then 'resource-id'
140
- when 'contentdesc', 'content-desc', 'content desc' then 'content-desc'
141
- else
142
- t.gsub(/\s+/, '_').gsub(/[^a-z0-9_\-]/, '')
143
- end
144
- end
145
-
146
- patterns = [
147
- # ChromeDriver/Selenium JSON style:
148
- # no such element: Unable to locate element: {"method":"xpath","selector":"//..."}
149
- /no such element: Unable to locate element:\s*\{\s*["']?method["']?\s*:\s*["']?([^"'\}]+)["']?\s*,\s*["']?selector["']?\s*:\s*["']?([^"']+)["']?\s*\}/i,
150
-
151
- # By.xpath: //..., By.id: "foo"
152
- /By\.(xpath|id|css selector|cssSelector|name|class name|className):\s*['"]?(.+?)['"]?(?:\s|$)/i,
153
70
 
154
- # Generic "using <type>=<value>" or using <type>: '<value>'
155
- /using\s+([a-zA-Z0-9_\-:]+)\s*[=:]\s*['"]?(.+?)['"]?(?:\s|$)/i,
156
-
157
- # "An element with the selector '...' was not found"
158
- /An element with (?:the )?selector ['"](.+?)['"] (?:could not be found|was not found|not found|not be located)/i,
159
-
160
- # "with the resource-id 'xyz'" or "with the accessibility-id 'abc'"
161
- /with the (resource-id|accessibility[- ]?id|content-?desc|label|name)\s*[:=]?\s*['"](.+?)['"]/i,
162
-
163
- # "Unable to find element by: id 'xyz'"
164
- /Unable to find element by:\s*([a-zA-Z0-9_\- ]+)\s*[:=]?\s*['"]?(.+?)['"]?(?:\s|$)/i,
165
-
166
- # Fallback to any "selector: '...'" occurence
167
- /selector['"]?\s*[:=]?\s*['"](.+?)['"]/i
168
- ]
169
-
170
- patterns.each do |pattern|
171
- if (m = message.match(pattern))
172
- caps = m.captures.compact
173
- if caps.length >= 2
174
- raw_type = caps[0].to_s.strip
175
- raw_value = caps[1].to_s.strip
176
- info[:selector_type] = normalize_type.call(raw_type)
177
- info[:selector_value] = raw_value.gsub(/\A['"]|['"]\z/, '')
178
- else
179
- info[:selector_type] = 'unknown'
180
- info[:selector_value] = caps[0].to_s.strip.gsub(/\A['"]|['"]\z/, '')
71
+ def self.load_local_element_map
72
+ elements_map = {}
73
+ glob_path = File.join(Dir.pwd, 'features', 'elements', '**', '*.yaml')
74
+ Dir.glob(glob_path).each do |file|
75
+ begin
76
+ data = YAML.load_file(file)
77
+ if data.is_a?(Hash)
78
+ data.each do |key, value|
79
+ if value.is_a?(Hash) && value['tipoBusca'] && value['valor']
80
+ elements_map[key] = value
81
+ end
82
+ end
83
+ end
84
+ rescue => e
85
+ @@logger.warn("Aviso: Erro ao carregar o arquivo de elementos #{file}: #{e.message}") if @@logger
181
86
  end
182
- info[:raw_message] = message[0, 1000]
183
- return info
184
87
  end
88
+ elements_map
185
89
  end
186
90
 
187
- # tentativa extra: By.<tipo>:<valor> em qualquer lugar da mensagem
188
- if (m = message.match(/By\.([a-zA-Z0-9_\- ]+):\s*['"]?(.+?)['"]?/i))
189
- info[:selector_type] = normalize_type.call(m[1])
190
- info[:selector_value] = m[2].to_s.strip
191
- info[:raw_message] = message[0,1000]
192
- return info
91
+ def self.setup_logger
92
+ @@logger = Logger.new(STDOUT)
93
+ @@logger.level = Logger::INFO
94
+ @@logger.formatter = proc { |severity, datetime, progname, msg| "#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{msg}\n" }
193
95
  end
194
96
 
195
- # fallback final: retorna a mensagem inteira recortada (útil para debug)
196
- info[:selector_type] = 'unknown'
197
- info[:selector_value] = message.strip[0, 500]
198
- info[:raw_message] = message[0,1000]
199
- info
200
- end
201
-
202
-
203
- def self.find_similar_elements(doc, failed_info, platform)
204
- similar_elements = []
205
- doc.xpath('//*').each do |node|
206
- next if node.name == 'hierarchy'
207
- attrs = node.attributes.transform_values(&:value)
97
+ def self.extract_info_from_exception(exception)
98
+ message = exception.message
99
+ info = {}
100
+ patterns = [
101
+ /element with locator ['"]?(#?\w+)['"]?/i,
102
+ /(?:could not be found|cannot find element) using (.+?)=['"]?([^'"]+)['"]?/i,
103
+ /no such element: Unable to locate element: {"method":"([^"]+)","selector":"([^"]+)"}/i,
104
+ /(?:with the resource-id|with the accessibility-id) ['"]?(.+?)['"]?/i
105
+ ]
106
+ patterns.each do |pattern|
107
+ match = message.match(pattern)
108
+ if match
109
+ info[:selector_value] = match.captures.last.strip.gsub(/['"]/, '')
110
+ info[:selector_type] = match.captures.size > 1 ? match.captures[0].strip.gsub(/['"]/, '') : 'id' # Padroniza para 'id'
111
+ return info
112
+ end
113
+ end
114
+ info
115
+ end
116
+
117
+
118
+ def self.find_similar_elements(failed_element_info, all_page_suggestions)
119
+ failed_locator_value = failed_element_info[:selector_value]
120
+ failed_locator_type = failed_element_info[:selector_type]
121
+ return [] unless failed_locator_value && failed_locator_type
122
+
123
+ # Padroniza os tipos de localizador (ex: 'resource-id' vira 'id')
124
+ normalized_failed_type = failed_locator_type.downcase.include?('id') ? 'id' : failed_locator_type
125
+
126
+ cleaned_failed_locator = failed_locator_value.to_s.gsub(/[:\-\/@=\[\]'"()]/, ' ').gsub(/\s+/, ' ').downcase.strip
127
+ similarities = []
128
+
129
+ all_page_suggestions.each do |suggestion|
130
+ # Procura por um localizador na sugestão que tenha a MESMA ESTRATÉGIA do localizador que falhou
131
+ candidate_locator = suggestion[:locators].find { |loc| loc[:strategy] == normalized_failed_type }
132
+ next unless candidate_locator
208
133
 
209
- selector_value = failed_info[:selector_value].to_s.downcase.strip
134
+ cleaned_candidate_locator = candidate_locator[:locator].gsub(/[:\-\/@=\[\]'"()]/, ' ').gsub(/\s+/, ' ').downcase.strip
135
+ distance = DidYouMean::Levenshtein.distance(cleaned_failed_locator, cleaned_candidate_locator)
136
+ max_len = [cleaned_failed_locator.length, cleaned_candidate_locator.length].max
137
+ next if max_len.zero?
210
138
 
211
- is_similar = case platform
212
- when 'android'
213
- (attrs['resource-id']&.downcase&.include?(selector_value) ||
214
- attrs['text']&.downcase&.include?(selector_value) ||
215
- attrs['content-desc']&.downcase&.include?(selector_value))
216
- when 'ios'
217
- (attrs['accessibility-id']&.downcase&.include?(selector_value) ||
218
- attrs['label']&.downcase&.include?(selector_value) ||
219
- attrs['name']&.downcase&.include?(selector_value))
220
- else
221
- false
222
- end
223
-
224
- if is_similar
225
- name = self.suggest_name(node.name, attrs)
226
- locators = self.xpath_generator(node.name, attrs, platform)
227
- similar_elements << { name: name, locators: locators }
139
+ similarity_score = 1.0 - (distance.to_f / max_len)
140
+ if similarity_score > 0.8 # Aumenta o limiar para maior precisão
141
+ similarities << { name: suggestion[:name], locators: suggestion[:locators], score: similarity_score }
228
142
  end
229
143
  end
230
- similar_elements
144
+ similarities.sort_by { |s| -s[:score] }.first(5)
231
145
  end
232
146
 
147
+ # ... (get_all_elements_from_screen, truncate, suggest_name permanecem iguais) ...
148
+ def self.get_all_elements_from_screen(doc, platform)
149
+ seen_elements = {}
150
+ all_elements_suggestions = []
151
+ doc.xpath('//*').each do |node|
152
+ next if ['hierarchy', 'AppiumAUT'].include?(node.name)
153
+ attrs = node.attributes.transform_values(&:value)
154
+ unique_key = "#{node.name}|#{attrs['resource-id']}|#{attrs['content-desc']}|#{attrs['text']}"
155
+ unless seen_elements[unique_key]
156
+ name = self.suggest_name(node.name, attrs)
157
+ locators = self.xpath_generator(node.name, attrs, platform)
158
+ all_elements_suggestions << { name: name, locators: locators }
159
+ seen_elements[unique_key] = true
160
+ end
161
+ end
162
+ all_elements_suggestions
163
+ end
164
+
233
165
  def self.truncate(value)
234
166
  return value unless value.is_a?(String)
235
167
  value.size > MAX_VALUE_LENGTH ? "#{value[0...MAX_VALUE_LENGTH]}..." : value
@@ -240,7 +172,13 @@ module AppiumFailureHelper
240
172
  pfx = PREFIX[tag] || PREFIX[type] || 'elm'
241
173
  name_base = nil
242
174
 
243
- ['content-desc', 'text', 'resource-id', 'label', 'name'].each do |attr_key|
175
+ priority_attrs = if tag.start_with?('XCUIElementType')
176
+ ['name', 'label', 'value']
177
+ else
178
+ ['content-desc', 'text', 'resource-id']
179
+ end
180
+
181
+ priority_attrs.each do |attr_key|
244
182
  value = attrs[attr_key]
245
183
  if value.is_a?(String) && !value.empty?
246
184
  name_base = value
@@ -248,7 +186,8 @@ module AppiumFailureHelper
248
186
  end
249
187
  end
250
188
 
251
- name_base ||= type
189
+ # Se nenhum atributo de prioridade for encontrado, usa o nome da classe.
190
+ name_base ||= type.gsub('XCUIElementType', '') # Remove o prefixo longo do iOS
252
191
 
253
192
  truncated_name = truncate(name_base)
254
193
  sanitized_name = truncated_name.gsub(/[^a-zA-Z0-9\s]/, ' ').split.map(&:capitalize).join
@@ -258,209 +197,150 @@ module AppiumFailureHelper
258
197
 
259
198
  def self.xpath_generator(tag, attrs, platform)
260
199
  case platform
261
- when 'android'
262
- self.generate_android_xpaths(tag, attrs)
263
- when 'ios'
264
- self.generate_ios_xpaths(tag, attrs)
265
- else
266
- self.generate_unknown_xpaths(tag, attrs)
200
+ when 'android' then self.generate_android_xpaths(tag, attrs)
201
+ when 'ios' then self.generate_ios_xpaths(tag, attrs)
202
+ else self.generate_unknown_xpaths(tag, attrs)
267
203
  end
268
204
  end
269
205
 
270
206
  def self.generate_android_xpaths(tag, attrs)
271
207
  locators = []
272
-
273
- if attrs['resource-id'] && !attrs['resource-id'].empty? && attrs['text'] && !attrs['text'].empty?
274
- locators << { strategy: 'resource_id_and_text', locator: "//#{tag}[@resource-id=\"#{attrs['resource-id']}\" and @text=\"#{self.truncate(attrs['text'])}\"]" }
275
- elsif attrs['resource-id'] && !attrs['resource-id'].empty? && attrs['content-desc'] && !attrs['content-desc'].empty?
276
- locators << { strategy: 'resource_id_and_content_desc', locator: "//#{tag}[@resource-id=\"#{attrs['resource-id']}\" and @content-desc=\"#{self.truncate(attrs['content-desc'])}\"]" }
277
- end
278
-
279
208
  if attrs['resource-id'] && !attrs['resource-id'].empty?
280
- locators << { strategy: 'resource_id', locator: "//#{tag}[@resource-id=\"#{attrs['resource-id']}\"]" }
209
+ # CORREÇÃO: A estratégia para resource-id é 'id', e o valor é o próprio ID.
210
+ locators << { strategy: 'id', locator: attrs['resource-id'] }
281
211
  end
282
-
283
- if attrs['resource-id'] && attrs['resource-id'].include?(':id/')
284
- id_part = attrs['resource-id'].split(':id/').last
285
- locators << { strategy: 'starts_with_resource_id', locator: "//#{tag}[starts-with(@resource-id, \"#{id_part}\")]" }
286
- end
287
-
288
212
  if attrs['text'] && !attrs['text'].empty?
289
- locators << { strategy: 'text', locator: "//#{tag}[@text=\"#{self.truncate(attrs['text'])}\"]" }
213
+ locators << { strategy: 'xpath', locator: "//#{tag}[@text=\"#{truncate(attrs['text'])}\"]" }
290
214
  end
291
215
  if attrs['content-desc'] && !attrs['content-desc'].empty?
292
- locators << { strategy: 'content_desc', locator: "//#{tag}[@content-desc=\"#{self.truncate(attrs['content-desc'])}\"]" }
216
+ locators << { strategy: 'xpath_desc', locator: "//#{tag}[@content-desc=\"#{truncate(attrs['content-desc'])}\"]" }
293
217
  end
294
-
295
- locators << { strategy: 'generic_tag', locator: "//#{tag}" }
296
-
297
218
  locators
298
219
  end
299
220
 
300
221
  def self.generate_ios_xpaths(tag, attrs)
301
222
  locators = []
302
-
303
- if attrs['accessibility-id'] && !attrs['accessibility-id'].empty? && attrs['label'] && !attrs['label'].empty?
304
- locators << { strategy: 'accessibility_id_and_label', locator: "//#{tag}[@accessibility-id=\"#{attrs['accessibility-id']}\" and @label=\"#{self.truncate(attrs['label'])}\"]" }
305
- end
306
-
307
- if attrs['accessibility-id'] && !attrs['accessibility-id'].empty?
308
- locators << { strategy: 'accessibility_id', locator: "//#{tag}[@accessibility-id=\"#{attrs['accessibility-id']}\"]" }
223
+ if attrs['name'] && !attrs['name'].empty?
224
+ # CORREÇÃO: A estratégia para 'name' no iOS é 'name', e o valor é o próprio nome.
225
+ locators << { strategy: 'name', locator: attrs['name'] }
309
226
  end
310
-
311
227
  if attrs['label'] && !attrs['label'].empty?
312
- locators << { strategy: 'label', locator: "//#{tag}[@label=\"#{self.truncate(attrs['label'])}\"]" }
228
+ locators << { strategy: 'xpath', locator: "//#{tag}[@label=\"#{truncate(attrs['label'])}\"]" }
313
229
  end
314
- if attrs['name'] && !attrs['name'].empty?
315
- locators << { strategy: 'name', locator: "//#{tag}[@name=\"#{self.truncate(attrs['name'])}\"]" }
316
- end
317
-
318
- locators << { strategy: 'generic_tag', locator: "//#{tag}" }
319
-
320
230
  locators
321
231
  end
322
-
232
+
323
233
  def self.generate_unknown_xpaths(tag, attrs)
324
- locators = []
325
- if attrs['resource-id'] && !attrs['resource-id'].empty?
326
- locators << { strategy: 'resource_id', locator: "//#{tag}[@resource-id=\"#{attrs['resource-id']}\"]" }
327
- end
328
- if attrs['content-desc'] && !attrs['content-desc'].empty?
329
- locators << { strategy: 'content_desc', locator: "//#{tag}[@content-desc=\"#{self.truncate(attrs['content-desc'])}\"]" }
330
- end
331
- if attrs['text'] && !attrs['text'].empty?
332
- locators << { strategy: 'text', locator: "//#{tag}[@text=\"#{self.truncate(attrs['text'])}\"]" }
333
- end
334
-
335
- locators << { strategy: 'generic_tag', locator: "//#{tag}" }
336
-
337
- locators
234
+ locators = []
235
+ attrs.each { |key, value| locators << { strategy: key.to_s, locator: "//#{tag}[@#{key}=\"#{truncate(value)}\"]" } if value.is_a?(String) && !value.empty? }
236
+ locators
338
237
  end
339
238
 
340
239
  def self.generate_html_report(targeted_report, all_suggestions, screenshot_base64, platform, timestamp)
341
-
240
+ # Lambdas para gerar partes do HTML
342
241
  locators_html = lambda do |locators|
343
- locators.map do |loc|
344
- "<li class='flex justify-between items-center bg-gray-50 p-2 rounded-md mb-1 text-xs font-mono'><span class='font-bold text-indigo-600'>#{loc[:strategy].upcase.gsub('_', ' ')}:</span><span class='text-gray-700 ml-2 overflow-auto max-w-[70%]'>#{loc[:locator]}</span></li>"
345
- end.join
242
+ locators.map { |loc| "<li class='flex justify-between items-center bg-gray-50 p-2 rounded-md mb-1 text-xs font-mono'><span class='font-bold text-indigo-600'>#{CGI.escapeHTML(loc[:strategy].upcase.gsub('_', ' '))}:</span><span class='text-gray-700 ml-2 overflow-auto max-w-[70%]'>#{CGI.escapeHTML(loc[:locator])}</span></li>" }.join
346
243
  end
347
244
 
348
245
  all_elements_html = lambda do |elements|
349
- elements.map do |el|
350
- "<details class='border-b border-gray-200 py-3'><summary class='font-semibold text-sm text-gray-800 cursor-pointer'>#{el[:name]}</summary><ul class='text-xs space-y-1 mt-2'>#{locators_html.call(el[:locators])}</ul></details>"
351
- end.join
246
+ elements.map { |el| "<details class='border-b border-gray-200 py-3'><summary class='font-semibold text-sm text-gray-800 cursor-pointer'>#{CGI.escapeHTML(el[:name])}</summary><ul class='text-xs space-y-1 mt-2'>#{locators_html.call(el[:locators])}</ul></details>" }.join
352
247
  end
353
248
 
249
+ # Prepara o conteúdo dinâmico
354
250
  failed_info = targeted_report[:failed_element]
355
251
  similar_elements = targeted_report[:similar_elements]
356
-
357
- similar_elements_content = similar_elements.empty? ? "<p class='text-gray-500'>Nenhuma alternativa semelhante foi encontrada. O elemento pode ter sido removido.</p>" : similar_elements.map { |el| "<div class='border border-indigo-100 p-3 rounded-lg bg-indigo-50'><p class='font-bold text-indigo-800 mb-2'>#{el[:name]}</p><ul>#{locators_html.call(el[:locators])}</ul></div>" }.join
358
-
252
+ de_para_analysis = targeted_report[:de_para_analysis]
253
+
254
+ # Bloco de HTML para a análise "De/Para"
255
+ de_para_html = ""
256
+ if de_para_analysis
257
+ de_para_html = <<~HTML
258
+ <div class="bg-green-50 border border-green-200 p-4 rounded-lg shadow-md mb-6">
259
+ <h3 class="text-lg font-bold text-green-800 mb-2">Análise de Mapeamento (De/Para)</h3>
260
+ <p class="text-sm text-gray-700 mb-1">O nome lógico <strong class="font-mono bg-gray-200 px-1 rounded">#{CGI.escapeHTML(de_para_analysis[:logical_name])}</strong> foi encontrado nos seus arquivos locais!</p>
261
+ <p class="text-sm text-gray-700">O localizador correto definido é:</p>
262
+ <div class="font-mono text-xs bg-green-100 p-2 mt-2 rounded">
263
+ <span class="font-bold">#{CGI.escapeHTML(de_para_analysis[:correct_locator]['tipoBusca'].upcase)}:</span>
264
+ <span class="break-all">#{CGI.escapeHTML(de_para_analysis[:correct_locator]['valor'])}</span>
265
+ </div>
266
+ </div>
267
+ HTML
268
+ end
269
+
270
+ similar_elements_content = similar_elements.empty? ? "<p class='text-gray-500'>Nenhuma alternativa semelhante foi encontrada na tela atual.</p>" : similar_elements.map { |el|
271
+ score_percent = (el[:score] * 100).round(1)
272
+ "<div class='border border-indigo-100 p-3 rounded-lg bg-indigo-50'><p class='font-bold text-indigo-800 mb-2'>#{CGI.escapeHTML(el[:name])} <span class='text-xs font-normal text-green-600 bg-green-100 rounded-full px-2 py-1'>Similaridade: #{score_percent}%</span></p><ul>#{locators_html.call(el[:locators])}</ul></div>"
273
+ }.join
274
+
359
275
  failed_info_content = if failed_info && failed_info[:selector_value]
360
- "<p class='text-sm text-gray-700 font-medium mb-2'>Tipo de Seletor: <span class='font-mono text-xs bg-red-100 p-1 rounded'>#{failed_info[:selector_type]}</span></p><p class='text-sm text-gray-700 font-medium'>Valor Buscado: <span class='font-mono text-xs bg-red-100 p-1 rounded break-all'>#{failed_info[:selector_value]}</span></p>"
276
+ "<p class='text-sm text-gray-700 font-medium mb-2'>Tipo de Seletor: <span class='font-mono text-xs bg-red-100 p-1 rounded'>#{CGI.escapeHTML(failed_info[:selector_type].to_s)}</span></p><p class='text-sm text-gray-700 font-medium'>Valor Buscado: <span class='font-mono text-xs bg-red-100 p-1 rounded break-all'>#{CGI.escapeHTML(failed_info[:selector_value].to_s)}</span></p>"
361
277
  else
362
278
  "<p class='text-sm text-gray-500'>O localizador exato não pôde ser extraído da mensagem de erro.</p>"
363
279
  end
364
280
 
365
- # Template HTML usando um heredoc
281
+ # Template HTML completo
366
282
  <<~HTML_REPORT
367
283
  <!DOCTYPE html>
368
284
  <html lang="pt-BR">
369
285
  <head>
370
- <meta charset="UTF-8">
371
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
372
- <title>Relatório de Falha Appium - #{timestamp}</title>
373
- <script src="https://cdn.tailwindcss.com"></script>
374
- <style>
375
- body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; }
376
- .tab-content { display: none; }
377
- .tab-content.active { display: block; }
378
- .tab-button.active { background-color: #4f46e5; color: white; }
379
- .tab-button:not(.active):hover { background-color: #e0e7ff; }
380
- </style>
286
+ <meta charset="UTF-8">
287
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
288
+ <title>Relatório de Falha Appium - #{timestamp}</title>
289
+ <script src="https://cdn.tailwindcss.com"></script>
290
+ <style> body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto; } .tab-content { display: none; } .tab-content.active { display: block; } .tab-button.active { background-color: #4f46e5; color: white; } </style>
381
291
  </head>
382
292
  <body class="bg-gray-50 p-8">
383
- <div class="max-w-7xl mx-auto">
384
- <header class="mb-8 pb-4 border-b border-gray-300">
385
- <h1 class="text-3xl font-bold text-gray-800">Diagnóstico de Falha Automatizada</h1>
386
- <p class="text-sm text-gray-500">Relatório gerado em: #{timestamp} | Plataforma: #{platform.upcase}</p>
387
- </header>
388
-
389
- <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
390
- <!-- Coluna de Screenshots e Falha -->
391
- <div class="lg:col-span-1">
392
- <div class="bg-white p-4 rounded-lg shadow-xl mb-6 border border-red-200">
393
- <h2 class="text-xl font-bold text-red-600 mb-4">Elemento com Falha</h2>
394
- #{failed_info_content}
395
- </div>
396
-
397
- <div class="bg-white p-4 rounded-lg shadow-xl">
398
- <h2 class="text-xl font-bold text-gray-800 mb-4">Screenshot da Falha</h2>
399
- <img src="data:image/png;base64,#{screenshot_base64}" alt="Screenshot da Falha" class="w-full rounded-md shadow-lg border border-gray-200">
400
- </div>
293
+ <div class="max-w-7xl mx-auto">
294
+ <header class="mb-8 pb-4 border-b border-gray-300">
295
+ <h1 class="text-3xl font-bold text-gray-800">Diagnóstico de Falha Automatizada</h1>
296
+ <p class="text-sm text-gray-500">Relatório gerado em: #{timestamp} | Plataforma: #{platform.upcase}</p>
297
+ </header>
298
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
299
+ <div class="lg:col-span-1">
300
+ #{de_para_html}
301
+ <div class="bg-white p-4 rounded-lg shadow-xl mb-6 border border-red-200">
302
+ <h2 class="text-xl font-bold text-red-600 mb-4">Elemento com Falha</h2>
303
+ #{failed_info_content}
304
+ </div>
305
+ <div class="bg-white p-4 rounded-lg shadow-xl">
306
+ <h2 class="text-xl font-bold text-gray-800 mb-4">Screenshot da Falha</h2>
307
+ <img src="data:image/png;base64,#{screenshot_base64}" alt="Screenshot da Falha" class="w-full rounded-md shadow-lg border border-gray-200">
308
+ </div>
309
+ </div>
310
+ <div class="lg:col-span-2">
311
+ <div class="bg-white rounded-lg shadow-xl">
312
+ <div class="flex border-b border-gray-200">
313
+ <button class="tab-button active px-4 py-3 text-sm font-medium rounded-tl-lg" data-tab="similar">Sugestões de Reparo (#{similar_elements.size})</button>
314
+ <button class="tab-button px-4 py-3 text-sm font-medium text-gray-600" data-tab="all">Dump Completo da Página (#{all_suggestions.size} Elementos)</button>
315
+ </div>
316
+ <div class="p-6">
317
+ <div id="similar" class="tab-content active">
318
+ <h3 class="text-lg font-semibold text-indigo-700 mb-4">Elementos Semelhantes (Alternativas para o Localizador Falho)</h3>
319
+ <div class="space-y-4">#{similar_elements_content}</div>
401
320
  </div>
402
-
403
- <!-- Coluna de Relatórios e Sugestões -->
404
- <div class="lg:col-span-2">
405
- <div class="bg-white rounded-lg shadow-xl">
406
- <!-- Abas de Navegação -->
407
- <div class="flex border-b border-gray-200">
408
- <button class="tab-button active px-4 py-3 text-sm font-medium rounded-tl-lg" data-tab="similar">Sugestões de Reparo (#{similar_elements.size})</button>
409
- <button class="tab-button px-4 py-3 text-sm font-medium text-gray-600" data-tab="all">Dump Completo da Página (#{all_suggestions.size} Elementos)</button>
410
- </div>
411
-
412
- <!-- Conteúdo das Abas -->
413
- <div class="p-6">
414
- <!-- Aba Sugestões de Reparo -->
415
- <div id="similar" class="tab-content active">
416
- <h3 class="text-lg font-semibold text-indigo-700 mb-4">Elementos Semelhantes (Alternativas para o Localizador Falho)</h3>
417
- <div class="space-y-4">
418
- #{similar_elements_content}
419
- </div>
420
- </div>
421
-
422
- <!-- Aba Dump Completo -->
423
- <div id="all" class="tab-content">
424
- <h3 class="text-lg font-semibold text-indigo-700 mb-4">Dump Completo de Todos os Elementos da Tela</h3>
425
- <div class="max-h-[600px] overflow-y-auto space-y-2">
426
- #{all_elements_html.call(all_suggestions)}
427
- </div>
428
- </div>
429
- </div>
430
- </div>
321
+ <div id="all" class="tab-content">
322
+ <h3 class="text-lg font-semibold text-indigo-700 mb-4">Dump de Todos os Elementos da Tela</h3>
323
+ <div class="max-h-[600px] overflow-y-auto space-y-2">#{all_elements_html.call(all_suggestions)}</div>
431
324
  </div>
325
+ </div>
432
326
  </div>
327
+ </div>
433
328
  </div>
434
-
435
- <script>
436
- document.addEventListener('DOMContentLoaded', () => {
437
- const tabs = document.querySelectorAll('.tab-button');
438
- const contents = document.querySelectorAll('.tab-content');
439
-
440
- tabs.forEach(tab => {
441
- tab.addEventListener('click', () => {
442
- const target = tab.getAttribute('data-tab');
443
-
444
- tabs.forEach(t => {
445
- t.classList.remove('active', 'text-white', 'bg-indigo-600');
446
- t.classList.add('text-gray-600');
447
- });
448
- contents.forEach(c => c.classList.remove('active'));
449
-
450
- tab.classList.add('active', 'text-white', 'bg-indigo-600');
451
- tab.classList.remove('text-gray-600');
452
- document.getElementById(target).classList.add('active');
453
- });
454
- });
455
-
456
- // Set initial active state for styling consistency
457
- const activeTab = document.querySelector('.tab-button[data-tab="similar"]');
458
- activeTab.classList.add('active', 'text-white', 'bg-indigo-600');
459
- });
460
- </script>
329
+ </div>
330
+ <script>
331
+ document.querySelectorAll('.tab-button').forEach(tab => {
332
+ tab.addEventListener('click', () => {
333
+ const target = tab.getAttribute('data-tab');
334
+ document.querySelectorAll('.tab-button').forEach(t => t.classList.remove('active'));
335
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
336
+ tab.classList.add('active');
337
+ document.getElementById(target).classList.add('active');
338
+ });
339
+ });
340
+ </script>
461
341
  </body>
462
342
  </html>
463
343
  HTML_REPORT
464
344
  end
465
345
  end
466
- end
346
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppiumFailureHelper
4
- VERSION = "0.6.1"
4
+ VERSION = "0.6.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appium_failure_helper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Nascimento