appium_failure_helper 0.6.2 → 0.6.4

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: 87d48b6025e058dfc945cc71675a8389ffd7c219a8bedccf8994327b10d02760
4
- data.tar.gz: 700ae3934bd4413d7ea30ac589493dca717e199dd0a69374156ec2f4bda966b3
3
+ metadata.gz: fc097199adcadcecb8d7492ab6e2c6f577b0cdf9acfa28cb9486195b9342c1f6
4
+ data.tar.gz: 5e92c1e6cba8685d9254ff550c1eee4a553afaffc1c4b1e726f410f9ed3fa7e1
5
5
  SHA512:
6
- metadata.gz: 83321c33adbc356d531abe28f2f792b635e5e2658676c7510afabd263e4c7f99b4d4c5dcbaaf0d20bba569a62841b812f50015b5901f3e860ebe0b6f44bbbcc2
7
- data.tar.gz: 35935a71ebd0ff920cfeee986d0918337f706146b680c7a5f85bba334dcc790d0ca4c01bb8bcf1b3aec2efaa95a5d758fc6b416ece69bd2504b3c9b59ac45a91
6
+ metadata.gz: 7fd06c93b630f5f70163f2b3396fb0b078d902393f73b7729f94564dbc17f3e90c0aaf42da166728b183a306ee4d12671226a8ac8a45de75f1981d74f04905f8
7
+ data.tar.gz: 8629aa0822ef81b2ea24c8048ee31445d92e195a5404d830b7db7aae19c2606e261fc941aa9d5ca920087ad975af342de489bdc32f355226bfa66d4c97772ec9
@@ -0,0 +1,61 @@
1
+ module AppiumFailureHelper
2
+ module Analyzer
3
+ def self.extract_failure_details(exception)
4
+ message = exception.message
5
+ info = {}
6
+ patterns = [
7
+ /element with locator ['"]?(#?\w+)['"]?/i,
8
+ /(?:could not be found|cannot find element) using (.+?)=['"]?([^'"]+)['"]?/i,
9
+ /no such element: Unable to locate element: {"method":"([^"]+)","selector":"([^"]+)"}/i,
10
+ /(?:with the resource-id|with the accessibility-id) ['"]?(.+?)['"]?/i
11
+ ]
12
+ patterns.each do |pattern|
13
+ match = message.match(pattern)
14
+ if match
15
+ info[:selector_value] = match.captures.last.strip.gsub(/['"]/, '')
16
+ info[:selector_type] = match.captures.size > 1 ? match.captures[0].strip.gsub(/['"]/, '') : 'id'
17
+ return info
18
+ end
19
+ end
20
+ info
21
+ end
22
+
23
+ def self.find_de_para_match(failed_info, element_map)
24
+ logical_name_key = failed_info[:selector_value].to_s.gsub(/^#/, '')
25
+ if element_map.key?(logical_name_key)
26
+ return {
27
+ logical_name: logical_name_key,
28
+ correct_locator: element_map[logical_name_key]
29
+ }
30
+ end
31
+ nil
32
+ end
33
+
34
+ def self.find_similar_elements(failed_info, all_page_suggestions)
35
+ failed_locator_value = failed_info[:selector_value]
36
+ failed_locator_type = failed_info[:selector_type]
37
+ return [] unless failed_locator_value && failed_locator_type
38
+
39
+ normalized_failed_type = failed_locator_type.downcase.include?('id') ? 'id' : failed_locator_type
40
+
41
+ cleaned_failed_locator = failed_locator_value.to_s.gsub(/[:\-\/@=\[\]'"()]/, ' ').gsub(/\s+/, ' ').downcase.strip
42
+ similarities = []
43
+
44
+ all_page_suggestions.each do |suggestion|
45
+ candidate_locator = suggestion[:locators].find { |loc| loc[:strategy] == normalized_failed_type }
46
+ next unless candidate_locator
47
+
48
+ cleaned_candidate_locator = candidate_locator[:locator].gsub(/[:\-\/@=\[\]'"()]/, ' ').gsub(/\s+/, ' ').downcase.strip
49
+ distance = DidYouMean::Levenshtein.distance(cleaned_failed_locator, cleaned_candidate_locator)
50
+ max_len = [cleaned_failed_locator.length, cleaned_candidate_locator.length].max
51
+ next if max_len.zero?
52
+
53
+ similarity_score = 1.0 - (distance.to_f / max_len)
54
+ if similarity_score > 0.8
55
+ similarities << { name: suggestion[:name], locators: suggestion[:locators], score: similarity_score }
56
+ end
57
+ end
58
+ similarities.sort_by { |s| -s[:score] }.first(5)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ module AppiumFailureHelper
2
+ module ElementRepository
3
+ def self.load_all_from_yaml
4
+ elements_map = {}
5
+ # Procura em todo o diretório de trabalho atual por arquivos .yaml
6
+ glob_path = File.join(Dir.pwd, '**', '*.yaml')
7
+
8
+ Dir.glob(glob_path).each do |file|
9
+ # Evita ler os próprios relatórios gerados
10
+ next if file.include?('reports_failure')
11
+
12
+ begin
13
+ data = YAML.load_file(file)
14
+ if data.is_a?(Hash)
15
+ data.each do |key, value|
16
+ if value.is_a?(Hash) && value['tipoBusca'] && value['value']
17
+ elements_map[key] = value
18
+ end
19
+ end
20
+ end
21
+ rescue => e
22
+ Utils.logger.warn("Aviso: Erro ao carregar o arquivo YAML #{file}: #{e.message}")
23
+ end
24
+ end
25
+ elements_map
26
+ end
27
+
28
+ # NOVO: Método para verificar a existência de um elemento em um arquivo .rb
29
+ def self.find_in_ruby_file(element_name, path = 'elements/elements.rb')
30
+ return { found: false, path: path, reason: "Arquivo não encontrado" } unless File.exist?(path)
31
+
32
+ begin
33
+ content = File.read(path)
34
+ # Regex flexível para encontrar definições como:
35
+ # def nome_do_elemento
36
+ # element :nome_do_elemento
37
+ # element('nome_do_elemento')
38
+ if content.match?(/def #{element_name}|element[ |\(]['|:]#{element_name}/)
39
+ return { found: true, path: path }
40
+ else
41
+ return { found: false, path: path, reason: "Definição não encontrada" }
42
+ end
43
+ rescue => e
44
+ Utils.logger.warn("Aviso: Erro ao ler o arquivo Ruby #{path}: #{e.message}")
45
+ return { found: false, path: path, reason: "Erro de leitura" }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,55 @@
1
+ module AppiumFailureHelper
2
+ class Handler
3
+ def self.call(driver, exception)
4
+ new(driver, exception).call
5
+ end
6
+
7
+ def initialize(driver, exception)
8
+ @driver = driver
9
+ @exception = exception
10
+ @timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
11
+ @output_folder = "reports_failure/failure_#{@timestamp}"
12
+ end
13
+
14
+ def call
15
+ FileUtils.mkdir_p(@output_folder)
16
+ page_source = @driver.page_source
17
+ platform = @driver.capabilities['platformName']&.downcase || 'unknown'
18
+
19
+ failed_info = Analyzer.extract_failure_details(@exception)
20
+
21
+ # ALTERADO: Agora busca em todas as fontes de dados
22
+ logical_name_key = failed_info[:selector_value].to_s.gsub(/^#/, '')
23
+
24
+ # 1. Busca nos arquivos YAML dinâmicos
25
+ element_map_yaml = ElementRepository.load_all_from_yaml
26
+ de_para_yaml_result = Analyzer.find_de_para_match(failed_info, element_map_yaml)
27
+
28
+ # 2. Busca no arquivo Ruby
29
+ de_para_rb_result = ElementRepository.find_in_ruby_file(logical_name_key)
30
+
31
+ # 3. Analisa a tela atual
32
+ page_analyzer = PageAnalyzer.new(page_source, platform)
33
+ all_page_elements = page_analyzer.analyze
34
+ similar_elements = Analyzer.find_similar_elements(failed_info, all_page_elements)
35
+
36
+ # Organiza TODOS os resultados para o relatório
37
+ report_data = {
38
+ failed_element: failed_info,
39
+ similar_elements: similar_elements,
40
+ de_para_yaml_analysis: de_para_yaml_result, # Resultado da análise YAML
41
+ de_para_rb_analysis: de_para_rb_result, # Resultado da análise Ruby
42
+ all_page_elements: all_page_elements,
43
+ screenshot_base64: @driver.screenshot_as(:base64),
44
+ platform: platform,
45
+ timestamp: @timestamp
46
+ }
47
+
48
+ ReportGenerator.new(@output_folder, page_source, report_data).generate_all
49
+
50
+ Utils.logger.info("Relatórios gerados com sucesso em: #{@output_folder}")
51
+ rescue => e
52
+ Utils.logger.error("Erro ao capturar detalhes da falha: #{e.message}\n#{e.backtrace.join("\n")}")
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,109 @@
1
+ # lib/appium_failure_helper/page_analyzer.rb
2
+ module AppiumFailureHelper
3
+ class PageAnalyzer
4
+ PREFIX = {
5
+ 'android.widget.Button' => 'btn', 'android.widget.TextView' => 'txt',
6
+ 'android.widget.ImageView' => 'img', 'android.widget.EditText' => 'input',
7
+ 'android.widget.CheckBox' => 'chk', 'android.widget.RadioButton' => 'radio',
8
+ 'android.widget.Switch' => 'switch', 'android.widget.ViewGroup' => 'group',
9
+ 'android.widget.View' => 'view', 'android.widget.FrameLayout' => 'frame',
10
+ 'android.widget.LinearLayout' => 'linear', 'android.widget.RelativeLayout' => 'relative',
11
+ 'android.widget.ScrollView' => 'scroll', 'android.webkit.WebView' => 'web',
12
+ 'android.widget.Spinner' => 'spin', 'XCUIElementTypeButton' => 'btn',
13
+ 'XCUIElementTypeStaticText' => 'txt', 'XCUIElementTypeTextField' => 'input',
14
+ 'XCUIElementTypeImage' => 'img', 'XCUIElementTypeSwitch' => 'switch',
15
+ 'XCUIElementTypeScrollView' => 'scroll', 'XCUIElementTypeOther' => 'elm',
16
+ 'XCUIElementTypeCell' => 'cell'
17
+ }.freeze
18
+
19
+ def initialize(page_source, platform)
20
+ @doc = Nokogiri::XML(page_source)
21
+ @platform = platform
22
+ end
23
+
24
+ def analyze
25
+ seen_elements = {}
26
+ all_elements_suggestions = []
27
+ @doc.xpath('//*').each do |node|
28
+ next if ['hierarchy', 'AppiumAUT'].include?(node.name)
29
+ attrs = node.attributes.transform_values(&:value)
30
+ unique_key = "#{node.name}|#{attrs['resource-id']}|#{attrs['content-desc']}|#{attrs['text']}"
31
+ unless seen_elements[unique_key]
32
+ name = suggest_name(node.name, attrs)
33
+ locators = xpath_generator(node.name, attrs)
34
+ all_elements_suggestions << { name: name, locators: locators }
35
+ seen_elements[unique_key] = true
36
+ end
37
+ end
38
+ all_elements_suggestions
39
+ end
40
+
41
+ private
42
+
43
+ def suggest_name(tag, attrs)
44
+ type = tag.split('.').last
45
+ pfx = PREFIX[tag] || PREFIX[type] || 'elm'
46
+ name_base = nil
47
+
48
+ priority_attrs = if tag.start_with?('XCUIElementType')
49
+ ['name', 'label', 'value']
50
+ else
51
+ ['content-desc', 'text', 'resource-id']
52
+ end
53
+
54
+ priority_attrs.each do |attr_key|
55
+ value = attrs[attr_key]
56
+ if value.is_a?(String) && !value.empty?
57
+ name_base = value
58
+ break
59
+ end
60
+ end
61
+
62
+ name_base ||= type.gsub('XCUIElementType', '')
63
+
64
+ truncated_name = Utils.truncate(name_base)
65
+ sanitized_name = truncated_name.gsub(/[^a-zA-Z0-9\s]/, ' ').split.map(&:capitalize).join
66
+
67
+ "#{pfx}#{sanitized_name}"
68
+ end
69
+
70
+ def xpath_generator(tag, attrs)
71
+ case @platform
72
+ when 'android' then generate_android_xpaths(tag, attrs)
73
+ when 'ios' then generate_ios_xpaths(tag, attrs)
74
+ else generate_unknown_xpaths(tag, attrs)
75
+ end
76
+ end
77
+
78
+ def generate_android_xpaths(tag, attrs)
79
+ locators = []
80
+ if attrs['resource-id'] && !attrs['resource-id'].empty?
81
+ locators << { strategy: 'id', locator: attrs['resource-id'] }
82
+ end
83
+ if attrs['text'] && !attrs['text'].empty?
84
+ locators << { strategy: 'xpath', locator: "//#{tag}[@text=\"#{Utils.truncate(attrs['text'])}\"]" }
85
+ end
86
+ if attrs['content-desc'] && !attrs['content-desc'].empty?
87
+ locators << { strategy: 'xpath_desc', locator: "//#{tag}[@content-desc=\"#{Utils.truncate(attrs['content-desc'])}\"]" }
88
+ end
89
+ locators
90
+ end
91
+
92
+ def generate_ios_xpaths(tag, attrs)
93
+ locators = []
94
+ if attrs['name'] && !attrs['name'].empty?
95
+ locators << { strategy: 'name', locator: attrs['name'] }
96
+ end
97
+ if attrs['label'] && !attrs['label'].empty?
98
+ locators << { strategy: 'xpath', locator: "//#{tag}[@label=\"#{Utils.truncate(attrs['label'])}\"]" }
99
+ end
100
+ locators
101
+ end
102
+
103
+ def generate_unknown_xpaths(tag, attrs)
104
+ locators = []
105
+ attrs.each { |key, value| locators << { strategy: key.to_s, locator: "//#{tag}[@#{key}=\"#{Utils.truncate(value)}\"]" } if value.is_a?(String) && !value.empty? }
106
+ locators
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,160 @@
1
+ module AppiumFailureHelper
2
+ class ReportGenerator
3
+ def initialize(output_folder, page_source, report_data)
4
+ @output_folder = output_folder
5
+ @page_source = page_source
6
+ @data = report_data
7
+ end
8
+
9
+ def generate_all
10
+ generate_xml_report
11
+ generate_yaml_reports
12
+ generate_html_report
13
+ end
14
+
15
+ private
16
+
17
+ def generate_xml_report
18
+ File.write("#{@output_folder}/page_source_#{@data[:timestamp]}.xml", @page_source)
19
+ end
20
+
21
+ def generate_yaml_reports
22
+ analysis_report = {
23
+ failed_element: @data[:failed_element],
24
+ similar_elements: @data[:similar_elements],
25
+ de_para_yaml_analysis: @data[:de_para_yaml_analysis],
26
+ de_para_rb_analysis: @data[:de_para_rb_analysis]
27
+ }
28
+ File.open("#{@output_folder}/failure_analysis_#{@data[:timestamp]}.yaml", 'w') { |f| f.write(YAML.dump(analysis_report)) }
29
+ File.open("#{@output_folder}/all_elements_dump_#{@data[:timestamp]}.yaml", 'w') { |f| f.write(YAML.dump(@data[:all_page_elements])) }
30
+ end
31
+
32
+ def generate_html_report
33
+ # ... (Lambdas locators_html e all_elements_html permanecem iguais) ...
34
+ locators_html = ->(locators) { locators.map { |loc| "..." }.join }
35
+ all_elements_html = ->(elements) { elements.map { |el| "..." }.join }
36
+
37
+ # Prepara o conteúdo dinâmico
38
+ failed_info = @data[:failed_element]
39
+ similar_elements = @data[:similar_elements]
40
+ de_para_yaml = @data[:de_para_yaml_analysis]
41
+ de_para_rb = @data[:de_para_rb_analysis]
42
+ all_suggestions = @data[:all_page_elements]
43
+
44
+ # NOVO: Gera dois blocos de HTML, um para cada análise
45
+
46
+ # Bloco de análise YAML
47
+ de_para_yaml_html = ""
48
+ if de_para_yaml
49
+ de_para_yaml_html = <<~HTML
50
+ <div class="bg-green-50 border border-green-200 p-4 rounded-lg shadow-md mb-6">
51
+ <h3 class="text-lg font-bold text-green-800 mb-2">Análise de Mapeamento YAML (.yaml)</h3>
52
+ <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_yaml[:logical_name])}</strong> foi encontrado nos arquivos .yaml do projeto!</p>
53
+ <p class="text-sm text-gray-700">O localizador correto definido é:</p>
54
+ <div class="font-mono text-xs bg-green-100 p-2 mt-2 rounded">
55
+ <span class="font-bold">#{CGI.escapeHTML(de_para_yaml[:correct_locator]['tipoBusca'].upcase)}:</span>
56
+ <span class="break-all">#{CGI.escapeHTML(de_para_yaml[:correct_locator]['valor'])}</span>
57
+ </div>
58
+ </div>
59
+ HTML
60
+ end
61
+
62
+ # Bloco de análise Ruby
63
+ de_para_rb_html = ""
64
+ if de_para_rb
65
+ if de_para_rb[:found]
66
+ de_para_rb_html = <<~HTML
67
+ <div class="bg-green-50 border border-green-200 p-4 rounded-lg shadow-md mb-6">
68
+ <h3 class="text-lg font-bold text-green-800 mb-2">Análise de Mapeamento Ruby (.rb)</h3>
69
+ <p class="text-sm text-gray-700">A definição do elemento foi encontrada com sucesso no arquivo <strong class="font-mono bg-gray-200 px-1 rounded">#{de_para_rb[:path]}</strong>.</p>
70
+ </div>
71
+ HTML
72
+ else
73
+ de_para_rb_html = <<~HTML
74
+ <div class="bg-yellow-50 border border-yellow-200 p-4 rounded-lg shadow-md mb-6">
75
+ <h3 class="text-lg font-bold text-yellow-800 mb-2">Análise de Mapeamento Ruby (.rb)</h3>
76
+ <p class="text-sm text-gray-700">O elemento <strong class="font-mono bg-gray-200 px-1 rounded">#{CGI.escapeHTML(failed_info[:selector_value].to_s)}</strong> NÃO foi encontrado no arquivo <strong class="font-mono bg-gray-200 px-1 rounded">#{de_para_rb[:path]}</strong>.</p>
77
+ <p class="text-xs text-gray-500 mt-1">Motivo: #{de_para_rb[:reason]}</p>
78
+ </div>
79
+ HTML
80
+ end
81
+ end
82
+
83
+ similar_elements_content = similar_elements.empty? ? "<p class='text-gray-500'>Nenhuma alternativa semelhante foi encontrada na tela atual.</p>" : similar_elements.map { |el|
84
+ score_percent = (el[:score] * 100).round(1)
85
+ "<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>"
86
+ }.join
87
+
88
+ failed_info_content = if failed_info && failed_info[:selector_value]
89
+ "<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>"
90
+ else
91
+ "<p class='text-sm text-gray-500'>O localizador exato não pôde ser extraído da mensagem de erro.</p>"
92
+ end
93
+
94
+ html_content = <<~HTML_REPORT
95
+ <!DOCTYPE html>
96
+ <html lang="pt-BR">
97
+ <head>
98
+ <meta charset="UTF-8">
99
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
100
+ <title>Relatório de Falha Appium - #{@data[:timestamp]}</title>
101
+ <script src="https://cdn.tailwindcss.com"></script>
102
+ <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>
103
+ </head>
104
+ <body class="bg-gray-50 p-8">
105
+ <div class="max-w-7xl mx-auto">
106
+ <header class="mb-8 pb-4 border-b border-gray-300">
107
+ <h1 class="text-3xl font-bold text-gray-800">Diagnóstico de Falha Automatizada</h1>
108
+ <p class="text-sm text-gray-500">Relatório gerado em: #{@data[:timestamp]} | Plataforma: #{@data[:platform].upcase}</p>
109
+ </header>
110
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
111
+ <div class="lg:col-span-1">
112
+ #{de_para_rb_html}
113
+ <div class="bg-white p-4 rounded-lg shadow-xl mb-6 border border-red-200">
114
+ <h2 class="text-xl font-bold text-red-600 mb-4">Elemento com Falha</h2>
115
+ #{failed_info_content}
116
+ </div>
117
+ <div class="bg-white p-4 rounded-lg shadow-xl">
118
+ <h2 class="text-xl font-bold text-gray-800 mb-4">Screenshot da Falha</h2>
119
+ <img src="data:image/png;base64,#{@data[:screenshot_base64]}" alt="Screenshot da Falha" class="w-full rounded-md shadow-lg border border-gray-200">
120
+ </div>
121
+ </div>
122
+ <div class="lg:col-span-2">
123
+ <div class="bg-white rounded-lg shadow-xl">
124
+ <div class="flex border-b border-gray-200">
125
+ <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>
126
+ <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>
127
+ </div>
128
+ <div class="p-6">
129
+ <div id="similar" class="tab-content active">
130
+ <h3 class="text-lg font-semibold text-indigo-700 mb-4">Elementos Semelhantes (Alternativas para o Localizador Falho)</h3>
131
+ <div class="space-y-4">#{similar_elements_content}</div>
132
+ </div>
133
+ <div id="all" class="tab-content">
134
+ <h3 class="text-lg font-semibold text-indigo-700 mb-4">Dump de Todos os Elementos da Tela</h3>
135
+ <div class="max-h-[600px] overflow-y-auto space-y-2">#{all_elements_html.call(all_suggestions)}</div>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ <script>
143
+ document.querySelectorAll('.tab-button').forEach(tab => {
144
+ tab.addEventListener('click', () => {
145
+ const target = tab.getAttribute('data-tab');
146
+ document.querySelectorAll('.tab-button').forEach(t => t.classList.remove('active'));
147
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
148
+ tab.classList.add('active');
149
+ document.getElementById(target).classList.add('active');
150
+ });
151
+ });
152
+ </script>
153
+ </body>
154
+ </html>
155
+ HTML_REPORT
156
+
157
+ File.write("#{@output_folder}/report_#{@data[:timestamp]}.html", html_content)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,17 @@
1
+ module AppiumFailureHelper
2
+ module Utils
3
+ def self.logger
4
+ @logger ||= begin
5
+ logger = Logger.new(STDOUT)
6
+ logger.level = Logger::INFO
7
+ logger.formatter = proc { |severity, datetime, progname, msg| "#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{msg}\n" }
8
+ logger
9
+ end
10
+ end
11
+
12
+ def self.truncate(value, max_length = 100)
13
+ return value unless value.is_a?(String)
14
+ value.size > max_length ? "#{value[0...max_length]}..." : value
15
+ end
16
+ end
17
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppiumFailureHelper
4
- VERSION = "0.6.2"
4
+ VERSION = "0.6.4"
5
5
  end
@@ -1,8 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "appium_failure_helper/version"
4
- require_relative "appium_failure_helper/capture"
3
+ require 'nokogiri'
4
+ require 'fileutils'
5
+ require 'base64'
6
+ require 'yaml'
7
+ require 'logger'
8
+ require 'did_you_mean'
9
+ require 'cgi'
10
+
11
+ # Carrega todos os nossos novos módulos
12
+ require_relative 'appium_failure_helper/utils'
13
+ require_relative 'appium_failure_helper/analyzer'
14
+ require_relative 'appium_failure_helper/element_repository'
15
+ require_relative 'appium_failure_helper/page_analyzer'
16
+ require_relative 'appium_failure_helper/report_generator'
17
+ require_relative 'appium_failure_helper/handler'
5
18
  module AppiumFailureHelper
6
19
  class Error < StandardError; end
7
- # Your code goes here...
20
+ def self.handler_failure(driver, exception)
21
+ Handler.call(driver, exception)
22
+ end
8
23
  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.2
4
+ version: 0.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Nascimento
@@ -94,7 +94,12 @@ files:
94
94
  - README.md
95
95
  - Rakefile
96
96
  - lib/appium_failure_helper.rb
97
- - lib/appium_failure_helper/capture.rb
97
+ - lib/appium_failure_helper/analyzer.rb
98
+ - lib/appium_failure_helper/element_repository.rb
99
+ - lib/appium_failure_helper/handler.rb
100
+ - lib/appium_failure_helper/page_analyzer.rb
101
+ - lib/appium_failure_helper/report_generator.rb
102
+ - lib/appium_failure_helper/utils.rb
98
103
  - lib/appium_failure_helper/version.rb
99
104
  - sig/appium_failure_helper.rbs
100
105
  homepage: https://github.com/David-Nascimento/Appium_failure_helper
@@ -1,346 +0,0 @@
1
- require 'nokogiri'
2
- require 'fileutils'
3
- require 'base64'
4
- require 'yaml'
5
- require 'logger'
6
- require 'did_you_mean'
7
- require 'cgi' # Adicionado para garantir o escape de HTML
8
-
9
- module AppiumFailureHelper
10
- class Capture
11
- # ... (Constantes PREFIX, MAX_VALUE_LENGTH, @@logger permanecem iguais) ...
12
- PREFIX = {
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'
25
- }.freeze
26
- MAX_VALUE_LENGTH = 100
27
- @@logger = nil
28
-
29
- # --- MÉTODO PRINCIPAL (SEM ALTERAÇÕES) ---
30
- def self.handler_failure(driver, exception)
31
- begin
32
- self.setup_logger unless @@logger
33
- timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
34
- output_folder = "reports_failure/failure_#{timestamp}"
35
- FileUtils.mkdir_p(output_folder)
36
- @@logger.info("Pasta de saída criada: #{output_folder}")
37
- screenshot_base64 = driver.screenshot_as(:base64)
38
- page_source = driver.page_source
39
- File.write("#{output_folder}/page_source_#{timestamp}.xml", page_source)
40
- doc = Nokogiri::XML(page_source)
41
- platform = driver.capabilities['platformName']&.downcase || 'unknown'
42
- failed_element_info = self.extract_info_from_exception(exception)
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
- }
51
- end
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)
54
- targeted_report = {
55
- failed_element: failed_element_info,
56
- similar_elements: similar_elements,
57
- de_para_analysis: de_para_result
58
- }
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)) }
61
- html_content = self.generate_html_report(targeted_report, all_elements_suggestions, screenshot_base64, platform, timestamp)
62
- File.write("#{output_folder}/report_#{timestamp}.html", html_content)
63
- @@logger.info("Relatórios gerados com sucesso em: #{output_folder}")
64
- rescue => e
65
- @@logger.error("Erro ao capturar detalhes da falha: #{e.message}\n#{e.backtrace.join("\n")}")
66
- end
67
- end
68
-
69
- private
70
-
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
86
- end
87
- end
88
- elements_map
89
- end
90
-
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" }
95
- end
96
-
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
133
-
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?
138
-
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 }
142
- end
143
- end
144
- similarities.sort_by { |s| -s[:score] }.first(5)
145
- end
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
-
165
- def self.truncate(value)
166
- return value unless value.is_a?(String)
167
- value.size > MAX_VALUE_LENGTH ? "#{value[0...MAX_VALUE_LENGTH]}..." : value
168
- end
169
-
170
- def self.suggest_name(tag, attrs)
171
- type = tag.split('.').last
172
- pfx = PREFIX[tag] || PREFIX[type] || 'elm'
173
- name_base = nil
174
-
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|
182
- value = attrs[attr_key]
183
- if value.is_a?(String) && !value.empty?
184
- name_base = value
185
- break
186
- end
187
- end
188
-
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
191
-
192
- truncated_name = truncate(name_base)
193
- sanitized_name = truncated_name.gsub(/[^a-zA-Z0-9\s]/, ' ').split.map(&:capitalize).join
194
-
195
- "#{pfx}#{sanitized_name}"
196
- end
197
-
198
- def self.xpath_generator(tag, attrs, platform)
199
- case platform
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)
203
- end
204
- end
205
-
206
- def self.generate_android_xpaths(tag, attrs)
207
- locators = []
208
- if attrs['resource-id'] && !attrs['resource-id'].empty?
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'] }
211
- end
212
- if attrs['text'] && !attrs['text'].empty?
213
- locators << { strategy: 'xpath', locator: "//#{tag}[@text=\"#{truncate(attrs['text'])}\"]" }
214
- end
215
- if attrs['content-desc'] && !attrs['content-desc'].empty?
216
- locators << { strategy: 'xpath_desc', locator: "//#{tag}[@content-desc=\"#{truncate(attrs['content-desc'])}\"]" }
217
- end
218
- locators
219
- end
220
-
221
- def self.generate_ios_xpaths(tag, attrs)
222
- locators = []
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'] }
226
- end
227
- if attrs['label'] && !attrs['label'].empty?
228
- locators << { strategy: 'xpath', locator: "//#{tag}[@label=\"#{truncate(attrs['label'])}\"]" }
229
- end
230
- locators
231
- end
232
-
233
- def self.generate_unknown_xpaths(tag, attrs)
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
237
- end
238
-
239
- def self.generate_html_report(targeted_report, all_suggestions, screenshot_base64, platform, timestamp)
240
- # Lambdas para gerar partes do HTML
241
- locators_html = lambda do |locators|
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
243
- end
244
-
245
- all_elements_html = lambda do |elements|
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
247
- end
248
-
249
- # Prepara o conteúdo dinâmico
250
- failed_info = targeted_report[:failed_element]
251
- similar_elements = targeted_report[:similar_elements]
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
-
275
- failed_info_content = if failed_info && failed_info[:selector_value]
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>"
277
- else
278
- "<p class='text-sm text-gray-500'>O localizador exato não pôde ser extraído da mensagem de erro.</p>"
279
- end
280
-
281
- # Template HTML completo
282
- <<~HTML_REPORT
283
- <!DOCTYPE html>
284
- <html lang="pt-BR">
285
- <head>
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>
291
- </head>
292
- <body class="bg-gray-50 p-8">
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>
320
- </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>
324
- </div>
325
- </div>
326
- </div>
327
- </div>
328
- </div>
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>
341
- </body>
342
- </html>
343
- HTML_REPORT
344
- end
345
- end
346
- end