appium_failure_helper 0.6.1 → 0.6.3

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: 8dfc8ecc63d0e6f70e9ae388b3f5be698586248af80feec88a531e6333273d82
4
+ data.tar.gz: c2a98c1a05885d7ea36b13a3e3366c7b848e76a055dd95f831f6272d6832a880
5
5
  SHA512:
6
- metadata.gz: 8d431703babb06ffde65176ec6b16233738790261efbee46376f2b6bb90f1fd42717505d4ab6c78b454bf61bc3e656c2f8e9e1ef3936ea8841b585ec152e51f3
7
- data.tar.gz: d7df2cb2f3757ae8298ce7b8057e11ea66b0112ba38fb15e01e6e8bcb73b3dad26035bcfdeba8dc97e823bf608de787b2b47c3dceb28d0b988525812b4730ebd
6
+ metadata.gz: bb9efe4166951dff46f7f8fbed2753ab825eded0216172f9cb62c385cf74bc63c44aa1f1ea3288a7dccd511d29a712733ec96aebeb01095c687227cfd6e5726d
7
+ data.tar.gz: eb2f4561139100b968b778545cf12ce537e4a3e28468e731214f6f24a748dd86158afc0c8b8d81221eb0a5c03459dc3aa988747430bd8cac24ac9fa723755742
@@ -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_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.1"
4
+ VERSION = "0.6.3"
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.1
4
+ version: 0.6.3
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,466 +0,0 @@
1
- require 'nokogiri'
2
- require 'fileutils'
3
- require 'base64'
4
- require 'yaml'
5
- require 'logger'
6
-
7
- module AppiumFailureHelper
8
- class Capture
9
- 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',
33
- }.freeze
34
-
35
- MAX_VALUE_LENGTH = 100
36
- @@logger = nil
37
-
38
- def self.handler_failure(driver, exception)
39
- begin
40
- 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
- timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
46
- output_folder = "reports_failure/failure_#{timestamp}"
47
-
48
- FileUtils.mkdir_p(output_folder)
49
- @@logger.info("Pasta de saída criada: #{output_folder}")
50
-
51
- 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
- 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
-
63
- doc = Nokogiri::XML(page_source)
64
- platform = driver.capabilities['platformName']&.downcase || 'unknown'
65
-
66
- 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
83
- end
84
-
85
- targeted_report = {
86
- failed_element: failed_element_info,
87
- similar_elements: [],
88
- }
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"
107
- 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
-
111
- rescue => e
112
- @@logger.error("Erro ao capturar detalhes da falha: #{e.message}\n#{e.backtrace.join("\n")}")
113
- end
114
- end
115
-
116
- 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
-
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/, '')
181
- end
182
- info[:raw_message] = message[0, 1000]
183
- return info
184
- end
185
- end
186
-
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
193
- end
194
-
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)
208
-
209
- selector_value = failed_info[:selector_value].to_s.downcase.strip
210
-
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 }
228
- end
229
- end
230
- similar_elements
231
- end
232
-
233
- def self.truncate(value)
234
- return value unless value.is_a?(String)
235
- value.size > MAX_VALUE_LENGTH ? "#{value[0...MAX_VALUE_LENGTH]}..." : value
236
- end
237
-
238
- def self.suggest_name(tag, attrs)
239
- type = tag.split('.').last
240
- pfx = PREFIX[tag] || PREFIX[type] || 'elm'
241
- name_base = nil
242
-
243
- ['content-desc', 'text', 'resource-id', 'label', 'name'].each do |attr_key|
244
- value = attrs[attr_key]
245
- if value.is_a?(String) && !value.empty?
246
- name_base = value
247
- break
248
- end
249
- end
250
-
251
- name_base ||= type
252
-
253
- truncated_name = truncate(name_base)
254
- sanitized_name = truncated_name.gsub(/[^a-zA-Z0-9\s]/, ' ').split.map(&:capitalize).join
255
-
256
- "#{pfx}#{sanitized_name}"
257
- end
258
-
259
- def self.xpath_generator(tag, attrs, platform)
260
- 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)
267
- end
268
- end
269
-
270
- def self.generate_android_xpaths(tag, attrs)
271
- 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
- if attrs['resource-id'] && !attrs['resource-id'].empty?
280
- locators << { strategy: 'resource_id', locator: "//#{tag}[@resource-id=\"#{attrs['resource-id']}\"]" }
281
- 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
- if attrs['text'] && !attrs['text'].empty?
289
- locators << { strategy: 'text', locator: "//#{tag}[@text=\"#{self.truncate(attrs['text'])}\"]" }
290
- end
291
- if attrs['content-desc'] && !attrs['content-desc'].empty?
292
- locators << { strategy: 'content_desc', locator: "//#{tag}[@content-desc=\"#{self.truncate(attrs['content-desc'])}\"]" }
293
- end
294
-
295
- locators << { strategy: 'generic_tag', locator: "//#{tag}" }
296
-
297
- locators
298
- end
299
-
300
- def self.generate_ios_xpaths(tag, attrs)
301
- 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']}\"]" }
309
- end
310
-
311
- if attrs['label'] && !attrs['label'].empty?
312
- locators << { strategy: 'label', locator: "//#{tag}[@label=\"#{self.truncate(attrs['label'])}\"]" }
313
- 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
- locators
321
- end
322
-
323
- 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
338
- end
339
-
340
- def self.generate_html_report(targeted_report, all_suggestions, screenshot_base64, platform, timestamp)
341
-
342
- 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
346
- end
347
-
348
- 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
352
- end
353
-
354
- failed_info = targeted_report[:failed_element]
355
- 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
-
359
- 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>"
361
- else
362
- "<p class='text-sm text-gray-500'>O localizador exato não pôde ser extraído da mensagem de erro.</p>"
363
- end
364
-
365
- # Template HTML usando um heredoc
366
- <<~HTML_REPORT
367
- <!DOCTYPE html>
368
- <html lang="pt-BR">
369
- <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>
381
- </head>
382
- <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>
401
- </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>
431
- </div>
432
- </div>
433
- </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>
461
- </body>
462
- </html>
463
- HTML_REPORT
464
- end
465
- end
466
- end