tng 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/tng +404 -19
- data/binaries/go-ui-darwin-amd64 +0 -0
- data/binaries/go-ui-darwin-arm64 +0 -0
- data/binaries/go-ui-linux-amd64 +0 -0
- data/binaries/go-ui-linux-arm64 +0 -0
- data/binaries/tng-darwin-arm64.bundle +0 -0
- data/binaries/tng-darwin-x86_64.bundle +0 -0
- data/binaries/tng-linux-arm64.so +0 -0
- data/binaries/tng-linux-x86_64.so +0 -0
- data/binaries/tng.bundle +0 -0
- data/lib/tng/analyzers/controller.rb +0 -6
- data/lib/tng/analyzers/model.rb +4 -9
- data/lib/tng/analyzers/service.rb +1 -7
- data/lib/tng/services/direct_generation.rb +57 -27
- data/lib/tng/services/fix_orchestrator.rb +92 -0
- data/lib/tng/services/repair_service.rb +48 -0
- data/lib/tng/services/test_generator.rb +87 -5
- data/lib/tng/ui/go_ui_session.rb +27 -4
- data/lib/tng/ui/json_session.rb +198 -0
- data/lib/tng/utils.rb +25 -2
- data/lib/tng/version.rb +1 -1
- data/lib/tng.rb +7 -3
- metadata +7 -2
|
@@ -8,29 +8,42 @@ module Tng
|
|
|
8
8
|
class DirectGeneration
|
|
9
9
|
include ExtractMethods
|
|
10
10
|
|
|
11
|
-
def initialize(pastel, testng, http_client, params, show_post_generation_menu_proc)
|
|
11
|
+
def initialize(pastel, testng, http_client, params, show_post_generation_menu_proc, go_ui)
|
|
12
12
|
@pastel = pastel
|
|
13
13
|
@testng = testng
|
|
14
14
|
@http_client = http_client
|
|
15
15
|
@params = params
|
|
16
16
|
@show_post_generation_menu = show_post_generation_menu_proc
|
|
17
|
+
@go_ui = go_ui
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# ... (rest of methods)
|
|
21
|
+
|
|
22
|
+
def display_audit_results(result)
|
|
23
|
+
audit_results = result[:audit_results]
|
|
24
|
+
return unless audit_results
|
|
25
|
+
|
|
26
|
+
# Use Go UI to display rich audit results
|
|
27
|
+
# "issues" type handles both issues and behaviours in the unified view
|
|
28
|
+
@go_ui.show_audit_results(audit_results, "issues")
|
|
17
29
|
end
|
|
18
30
|
|
|
19
31
|
def run
|
|
20
|
-
file_path
|
|
32
|
+
file_path = @params[:file]
|
|
33
|
+
method_name = @params[:method]
|
|
21
34
|
|
|
22
35
|
unless file_path && method_name
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
@go_ui.display_error(@pastel.red("❌ Both file and method parameters are required"))
|
|
37
|
+
@go_ui.display_warning(@pastel.yellow("Usage: bundle exec tng app/controllers/users_controller.rb index"))
|
|
38
|
+
@go_ui.display_warning(@pastel.yellow(" or: bundle exec tng --file=users_controller.rb --method=index"))
|
|
39
|
+
@go_ui.display_warning(@pastel.yellow(" or: bundle exec tng f=users_controller.rb m=index"))
|
|
27
40
|
return
|
|
28
41
|
end
|
|
29
42
|
|
|
30
43
|
resolved_path = FileTypeDetector.resolve_file_path(file_path)
|
|
31
44
|
|
|
32
45
|
unless resolved_path && File.exist?(resolved_path)
|
|
33
|
-
|
|
46
|
+
@go_ui.display_error(@pastel.red("❌ File not found: #{file_path}"))
|
|
34
47
|
suggest_similar_files(file_path)
|
|
35
48
|
return
|
|
36
49
|
end
|
|
@@ -42,15 +55,14 @@ module Tng
|
|
|
42
55
|
private
|
|
43
56
|
|
|
44
57
|
def suggest_similar_files(file_path)
|
|
45
|
-
base_name = File.basename(file_path,
|
|
46
|
-
puts @pastel.yellow("💡 Did you mean one of these?")
|
|
58
|
+
base_name = File.basename(file_path, ".rb")
|
|
47
59
|
|
|
48
60
|
similar_files = find_similar_files(base_name)
|
|
49
61
|
|
|
50
62
|
if similar_files.empty?
|
|
51
|
-
|
|
63
|
+
@go_ui.display_info(@pastel.dim(" No similar files found"))
|
|
52
64
|
else
|
|
53
|
-
|
|
65
|
+
@go_ui.display_list(@pastel.yellow("💡 Did you mean one of these?"), similar_files.first(5))
|
|
54
66
|
end
|
|
55
67
|
end
|
|
56
68
|
|
|
@@ -61,8 +73,8 @@ module Tng
|
|
|
61
73
|
%w[app/controllers app/models app/services app/service].each do |dir|
|
|
62
74
|
next unless Dir.exist?(File.join(rails_root, dir))
|
|
63
75
|
|
|
64
|
-
Dir.glob(File.join(rails_root, dir,
|
|
65
|
-
similar_files << file.gsub(
|
|
76
|
+
Dir.glob(File.join(rails_root, dir, "**", "*#{base_name}*.rb")).each do |file|
|
|
77
|
+
similar_files << file.gsub(%r{^#{Regexp.escape(rails_root)}/}, "")
|
|
66
78
|
end
|
|
67
79
|
end
|
|
68
80
|
|
|
@@ -74,27 +86,34 @@ module Tng
|
|
|
74
86
|
methods = extract_methods_for_type(file_object, type)
|
|
75
87
|
|
|
76
88
|
if methods.empty?
|
|
77
|
-
|
|
89
|
+
@go_ui.display_warning(@pastel.yellow("⚠️ No methods found in #{file_object[:name]}"))
|
|
78
90
|
return
|
|
79
91
|
end
|
|
80
92
|
|
|
81
93
|
method_info = methods.find { |m| m[:name].downcase == method_name.downcase }
|
|
82
94
|
|
|
83
95
|
unless method_info
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
methods.first(10).each { |m| puts @pastel.dim(" • #{m[:name]}") }
|
|
96
|
+
@go_ui.display_error(@pastel.red("❌ Method '#{method_name}' not found in #{file_object[:name]}"))
|
|
97
|
+
@go_ui.display_list(@pastel.yellow("Available methods:"), methods.first(10).map { |m| m[:name] })
|
|
87
98
|
return
|
|
88
99
|
end
|
|
89
100
|
|
|
90
|
-
|
|
101
|
+
@go_ui.display_info(@pastel.bright_white("🎯 #{@params[:audit] ? "Auditing" : "Generating test for"} #{file_object[:name]}##{method_info[:name]}..."))
|
|
91
102
|
|
|
92
|
-
result =
|
|
103
|
+
result = @go_ui.show_progress("Processing...") do |progress|
|
|
104
|
+
generate_test_result(file_object, method_info, type, progress)
|
|
105
|
+
end
|
|
93
106
|
|
|
94
|
-
if result
|
|
107
|
+
if result&.dig(:error)
|
|
108
|
+
@go_ui.display_error(@pastel.red("❌ #{@params[:audit] ? "Audit" : "Test generation"} failed: #{result[:message]}"))
|
|
109
|
+
elsif @params[:audit]
|
|
110
|
+
# Audit mode - display results inline
|
|
111
|
+
display_audit_results(result)
|
|
112
|
+
elsif result && result[:file_path]
|
|
113
|
+
# Test generation mode - show post-generation menu
|
|
95
114
|
@show_post_generation_menu.call(result)
|
|
96
115
|
else
|
|
97
|
-
|
|
116
|
+
@go_ui.display_error(@pastel.red("❌ Failed to generate test"))
|
|
98
117
|
end
|
|
99
118
|
end
|
|
100
119
|
|
|
@@ -107,14 +126,25 @@ module Tng
|
|
|
107
126
|
end
|
|
108
127
|
end
|
|
109
128
|
|
|
110
|
-
def generate_test_result(file_object, method_info, type)
|
|
129
|
+
def generate_test_result(file_object, method_info, type, progress)
|
|
111
130
|
generator = Tng::Services::TestGenerator.new(@http_client)
|
|
112
131
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
if @params[:audit]
|
|
133
|
+
# Audit mode - return issues and behaviours
|
|
134
|
+
case type
|
|
135
|
+
when "controller" then generator.run_audit_for_controller_method(file_object, method_info, progress: progress)
|
|
136
|
+
when "model" then generator.run_audit_for_model_method(file_object, method_info, progress: progress)
|
|
137
|
+
when "service" then generator.run_audit_for_service_method(file_object, method_info, progress: progress)
|
|
138
|
+
else generator.run_audit_for_other_method(file_object, method_info, progress: progress)
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
# Test generation mode
|
|
142
|
+
case type
|
|
143
|
+
when "controller" then generator.run_for_controller_method(file_object, method_info, progress: progress)
|
|
144
|
+
when "model" then generator.run_for_model_method(file_object, method_info, progress: progress)
|
|
145
|
+
when "service" then generator.run_for_service_method(file_object, method_info, progress: progress)
|
|
146
|
+
else generator.run_for_other_method(file_object, method_info, progress: progress)
|
|
147
|
+
end
|
|
118
148
|
end
|
|
119
149
|
end
|
|
120
150
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "repair_service"
|
|
4
|
+
|
|
5
|
+
module Tng
|
|
6
|
+
module Services
|
|
7
|
+
class FixOrchestrator
|
|
8
|
+
MAX_ITERATIONS = 3
|
|
9
|
+
|
|
10
|
+
def initialize(pastel, http_client)
|
|
11
|
+
@pastel = pastel
|
|
12
|
+
@repair_service = RepairService.new(http_client)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(file_path)
|
|
16
|
+
unless File.exist?(file_path)
|
|
17
|
+
puts @pastel.red("❌ File not found: #{file_path}")
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
puts @pastel.bright_white("🔧 Starting Auto-Fix loop for #{file_path}...")
|
|
22
|
+
|
|
23
|
+
all_healthy = false
|
|
24
|
+
MAX_ITERATIONS.times do |i|
|
|
25
|
+
puts @pastel.dim("\n--- Iteration #{i + 1} ---")
|
|
26
|
+
|
|
27
|
+
# 1. Syntax Check
|
|
28
|
+
status = Tng::Utils.validate_ruby_syntax(file_path)
|
|
29
|
+
unless status[:success]
|
|
30
|
+
puts @pastel.yellow("⚠️ Syntax error detected!")
|
|
31
|
+
apply_fix(file_path, :syntax, status[:output])
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
puts @pastel.green("✓ Syntax OK")
|
|
35
|
+
|
|
36
|
+
# 2. Rubocop Check
|
|
37
|
+
status = Tng::Utils.validate_rubocop(file_path)
|
|
38
|
+
unless status[:success]
|
|
39
|
+
puts @pastel.yellow("⚠️ Rubocop violations detected!")
|
|
40
|
+
apply_fix(file_path, :lint, status[:output])
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
puts @pastel.green("✓ Rubocop OK")
|
|
44
|
+
|
|
45
|
+
# 3. Test Check (Optional/Proactive)
|
|
46
|
+
status = Tng::Utils.run_tests(file_path)
|
|
47
|
+
unless status[:success]
|
|
48
|
+
puts @pastel.yellow("⚠️ Test failures detected!")
|
|
49
|
+
apply_fix(file_path, :runtime, status[:output])
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
puts @pastel.green("✓ Tests Passed")
|
|
53
|
+
|
|
54
|
+
all_healthy = true
|
|
55
|
+
break
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if all_healthy
|
|
59
|
+
puts @pastel.bright_green("\n🎉 All checks passed! File is healthy.")
|
|
60
|
+
else
|
|
61
|
+
puts @pastel.red("\n❌ Max iterations reached. File still has issues.")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def apply_fix(file_path, error_type, error_message)
|
|
68
|
+
puts @pastel.blue("🧠 Calling LLM to fix #{error_type}...")
|
|
69
|
+
|
|
70
|
+
context = {
|
|
71
|
+
app_config: Tng::Services::UserAppConfig.config_with_source,
|
|
72
|
+
test_gems: Tng::Utils.has_gem?("rspec") ? ["rspec"] : ["minitest"] # Basic for now
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
result = @repair_service.repair_file(file_path, error_type, error_message, context)
|
|
76
|
+
|
|
77
|
+
if result[:error]
|
|
78
|
+
puts @pastel.red("❌ Repair failed: #{result[:message]}")
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if result["file_content"]
|
|
83
|
+
File.write(file_path, result["file_content"])
|
|
84
|
+
puts @pastel.green("✅ Fix applied: #{result["applied_fixes"]&.join(", ")}")
|
|
85
|
+
puts @pastel.dim("📝 Explanation: #{result["explanation"]}")
|
|
86
|
+
else
|
|
87
|
+
puts @pastel.yellow("⚠️ LLM did not return file content.")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Tng
|
|
7
|
+
module Services
|
|
8
|
+
class RepairService
|
|
9
|
+
REPAIR_PATH = "cli/tng_rails/contents/repair"
|
|
10
|
+
|
|
11
|
+
def initialize(http_client)
|
|
12
|
+
@http_client = http_client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def repair_file(file_path, error_type, error_message, context_info = {})
|
|
16
|
+
file_content = File.read(file_path)
|
|
17
|
+
|
|
18
|
+
payload = {
|
|
19
|
+
file_path: file_path,
|
|
20
|
+
file_content: file_content,
|
|
21
|
+
error_type: error_type,
|
|
22
|
+
error_message: error_message,
|
|
23
|
+
context_info: context_info
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Compress payload
|
|
27
|
+
json_payload = payload.to_json
|
|
28
|
+
compressed_payload = Zlib::Deflate.deflate(json_payload)
|
|
29
|
+
|
|
30
|
+
response = @http_client.post_binary(REPAIR_PATH, compressed_payload)
|
|
31
|
+
|
|
32
|
+
return { error: :network_error, message: "Network error" } if response.is_a?(HTTPX::ErrorResponse)
|
|
33
|
+
return { error: :auth_failed, message: "Auth failed" } if [401, 403].include?(response.status)
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
data = JSON.parse(response.body)
|
|
37
|
+
if data["error"]
|
|
38
|
+
{ error: :server_error, message: data["error"] }
|
|
39
|
+
else
|
|
40
|
+
data["result"] # This contains { file_content, explanation, applied_fixes }
|
|
41
|
+
end
|
|
42
|
+
rescue JSON::ParserError => e
|
|
43
|
+
{ error: :parse_error, message: "Failed to parse repair response: #{e.message}" }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -36,6 +36,70 @@ module Tng
|
|
|
36
36
|
generate_test_for_type(other_file, method_info, :other, progress: progress)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Audit mode - async, polls for completion
|
|
40
|
+
def run_audit_for_controller_method(controller, method_info, progress: nil)
|
|
41
|
+
run_audit_for_type(controller, method_info, :controller, progress: progress)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run_audit_for_model_method(model, method_info, progress: nil)
|
|
45
|
+
run_audit_for_type(model, method_info, :model, progress: progress)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_audit_for_service_method(service, method_info, progress: nil)
|
|
49
|
+
run_audit_for_type(service, method_info, :service, progress: progress)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_audit_for_other_method(other_file, method_info, progress: nil)
|
|
53
|
+
run_audit_for_type(other_file, method_info, :other, progress: progress)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run_audit_for_type(file_object, method_info, type, progress: nil)
|
|
57
|
+
response = send_audit_request_for_type(file_object, method_info, type)
|
|
58
|
+
return { error: :network_error, message: "Network error" } unless response
|
|
59
|
+
|
|
60
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
|
61
|
+
return { error: :network_error, message: response.error&.message || "Network error" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return { error: :auth_failed, message: "Invalid or missing API key" } if response.status == 401
|
|
65
|
+
return { error: :auth_failed, message: "API key expired" } if response.status == 403
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
job_data = JSON.parse(response.body)
|
|
69
|
+
return { error: :server_error, message: job_data["error"] } if job_data["error"]
|
|
70
|
+
|
|
71
|
+
job_id = job_data["job_id"]
|
|
72
|
+
return { error: :server_error, message: "No job_id returned" } unless job_id
|
|
73
|
+
|
|
74
|
+
# Poll for completion (similar to test generation)
|
|
75
|
+
result = poll_for_completion(job_id, progress: progress)
|
|
76
|
+
return { error: :timeout, message: "Audit timed out" } unless result
|
|
77
|
+
|
|
78
|
+
# Audit results come back as the result directly (not wrapped)
|
|
79
|
+
# Wrap in audit_results key for save_audit_file compatibility
|
|
80
|
+
{ audit_results: result }
|
|
81
|
+
rescue JSON::ParserError => e
|
|
82
|
+
{ error: :parse_error, message: "Failed to parse audit response: #{e.message}" }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def send_audit_request_for_type(file_object, method_info, type)
|
|
87
|
+
config = request_config
|
|
88
|
+
name = file_object[:name] || File.basename(file_object[:path], ".rb")
|
|
89
|
+
|
|
90
|
+
# Pass audit_mode: true as 8th parameter
|
|
91
|
+
case type
|
|
92
|
+
when :controller
|
|
93
|
+
Tng.send_request_for_controller(name, file_object[:path], method_info, *config, true)
|
|
94
|
+
when :model
|
|
95
|
+
Tng.send_request_for_model(name, file_object[:path], method_info, *config, true)
|
|
96
|
+
when :service
|
|
97
|
+
Tng.send_request_for_service(name, file_object[:path], method_info, *config, true)
|
|
98
|
+
when :other
|
|
99
|
+
Tng.send_request_for_other(name, file_object[:path], method_info, *config, true)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
39
103
|
def generate_test_for_type(file_object, method_info, type, progress: nil)
|
|
40
104
|
start_time = Time.now
|
|
41
105
|
|
|
@@ -77,15 +141,16 @@ module Tng
|
|
|
77
141
|
config = request_config
|
|
78
142
|
name = file_object[:name] || File.basename(file_object[:path], ".rb")
|
|
79
143
|
|
|
144
|
+
# Pass audit_mode: false as 8th parameter for normal test generation
|
|
80
145
|
case type
|
|
81
146
|
when :controller
|
|
82
|
-
Tng.send_request_for_controller(name, file_object[:path], method_info, *config)
|
|
147
|
+
Tng.send_request_for_controller(name, file_object[:path], method_info, *config, false)
|
|
83
148
|
when :model
|
|
84
|
-
Tng.send_request_for_model(name, file_object[:path], method_info, *config)
|
|
149
|
+
Tng.send_request_for_model(name, file_object[:path], method_info, *config, false)
|
|
85
150
|
when :service
|
|
86
|
-
Tng.send_request_for_service(name, file_object[:path], method_info, *config)
|
|
151
|
+
Tng.send_request_for_service(name, file_object[:path], method_info, *config, false)
|
|
87
152
|
when :other
|
|
88
|
-
Tng.send_request_for_other(name, file_object[:path], method_info, *config)
|
|
153
|
+
Tng.send_request_for_other(name, file_object[:path], method_info, *config, false)
|
|
89
154
|
end
|
|
90
155
|
end
|
|
91
156
|
|
|
@@ -98,6 +163,7 @@ module Tng
|
|
|
98
163
|
]
|
|
99
164
|
end
|
|
100
165
|
|
|
166
|
+
# Modified method signature and added interrupt handler
|
|
101
167
|
def poll_for_completion(job_id, progress: nil)
|
|
102
168
|
start_time = Time.current
|
|
103
169
|
attempts = 0
|
|
@@ -229,10 +295,26 @@ module Tng
|
|
|
229
295
|
next
|
|
230
296
|
end
|
|
231
297
|
rescue JSON::ParserError => e
|
|
232
|
-
debug_log("
|
|
298
|
+
debug_log("JSON parse error: #{e.message}") if debug_enabled?
|
|
233
299
|
next
|
|
234
300
|
end
|
|
235
301
|
end
|
|
302
|
+
rescue Interrupt
|
|
303
|
+
# User pressed Ctrl+C - exit gracefully
|
|
304
|
+
system("clear") || system("cls")
|
|
305
|
+
puts "\n\n#{TTY::Box.frame(
|
|
306
|
+
"Generation cancelled by user",
|
|
307
|
+
padding: 1,
|
|
308
|
+
align: :center,
|
|
309
|
+
border: :thick,
|
|
310
|
+
style: {
|
|
311
|
+
fg: :yellow,
|
|
312
|
+
border: {
|
|
313
|
+
fg: :yellow
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
)}"
|
|
317
|
+
exit(0)
|
|
236
318
|
end
|
|
237
319
|
|
|
238
320
|
def trigger_cleanup(job_id)
|
data/lib/tng/ui/go_ui_session.rb
CHANGED
|
@@ -47,14 +47,15 @@ module Tng
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# Show test type selection menu
|
|
50
|
+
# @param mode [String] "test" or "audit" (default: "test")
|
|
50
51
|
# Returns: "controller", "model", "service", "other", "back"
|
|
51
|
-
def show_test_type_menu
|
|
52
|
+
def show_test_type_menu(mode = "test")
|
|
52
53
|
output_file = Tempfile.new(["go_ui_test_type", ".json"])
|
|
53
54
|
output_path = output_file.path
|
|
54
55
|
output_file.close
|
|
55
56
|
|
|
56
57
|
begin
|
|
57
|
-
system(@binary_path, "rails-test-menu", "--output", output_path)
|
|
58
|
+
system(@binary_path, "rails-test-menu", "--output", output_path, "--mode", mode)
|
|
58
59
|
|
|
59
60
|
return "back" unless File.exist?(output_path) && File.size(output_path) > 0
|
|
60
61
|
|
|
@@ -92,7 +93,7 @@ module Tng
|
|
|
92
93
|
# @param message [String] Spinner message
|
|
93
94
|
# @yield Block that returns Hash with :success and :message keys
|
|
94
95
|
# @return [Hash] Result from block
|
|
95
|
-
def show_spinner(message
|
|
96
|
+
def show_spinner(message)
|
|
96
97
|
control_file = Tempfile.new(["go_ui_spinner", ".json"])
|
|
97
98
|
control_path = control_file.path
|
|
98
99
|
control_file.close
|
|
@@ -126,7 +127,7 @@ module Tng
|
|
|
126
127
|
# @param title [String] Progress title
|
|
127
128
|
# @yield [ProgressUpdater] Block receives updater and returns Hash with :message and :result
|
|
128
129
|
# @return [Hash] Result from block
|
|
129
|
-
def show_progress(title
|
|
130
|
+
def show_progress(title)
|
|
130
131
|
control_file = Tempfile.new(["go_ui_progress", ".json"])
|
|
131
132
|
control_path = control_file.path
|
|
132
133
|
control_file.close
|
|
@@ -239,6 +240,28 @@ module Tng
|
|
|
239
240
|
puts "Test results error: #{e.message}"
|
|
240
241
|
end
|
|
241
242
|
|
|
243
|
+
# Show audit results
|
|
244
|
+
# @param audit_result [Hash] Full audit result with items, method_name, class_name, method_source_with_lines
|
|
245
|
+
# @param audit_type [String] "issues" or "behaviours"
|
|
246
|
+
def show_audit_results(audit_result, _audit_type)
|
|
247
|
+
# Always use the unified audit-results command which handles both issues and behaviours
|
|
248
|
+
command = "audit-results"
|
|
249
|
+
|
|
250
|
+
# Send full result object via temp file to avoid CLI argument limits
|
|
251
|
+
data_json = JSON.generate(audit_result)
|
|
252
|
+
|
|
253
|
+
input_file = Tempfile.new(["go_ui_audit", ".json"])
|
|
254
|
+
input_path = input_file.path
|
|
255
|
+
File.write(input_path, data_json)
|
|
256
|
+
input_file.close
|
|
257
|
+
|
|
258
|
+
system(@binary_path, command, "--file", input_path)
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
puts "Audit results error: #{e.message}"
|
|
261
|
+
ensure
|
|
262
|
+
File.unlink(input_path) if input_path && File.exist?(input_path)
|
|
263
|
+
end
|
|
264
|
+
|
|
242
265
|
# Show authentication error
|
|
243
266
|
# @param message [String] Error message to display
|
|
244
267
|
def show_auth_error(message = "Authentication failed")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tng
|
|
6
|
+
module UI
|
|
7
|
+
# UI Session that outputs JSON events to STDOUT for machine consumption
|
|
8
|
+
class JsonSession
|
|
9
|
+
def initialize
|
|
10
|
+
# Ensure stdout is sync so events are emitted immediately
|
|
11
|
+
$stdout.sync = true
|
|
12
|
+
@running = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start
|
|
16
|
+
@running = true
|
|
17
|
+
emit_event("started", { message: "TNG JSON Session Started" })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stop
|
|
21
|
+
@running = false
|
|
22
|
+
emit_event("stopped", { message: "TNG JSON Session Stopped" })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def display_error(message)
|
|
26
|
+
emit_event("error", { message: strip_colors(message) })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def display_warning(message)
|
|
30
|
+
emit_event("warning", { message: strip_colors(message) })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def display_info(message)
|
|
34
|
+
emit_event("info", { message: strip_colors(message) })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def display_list(title, items)
|
|
38
|
+
emit_event("list", { title: strip_colors(title), items: items })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def running?
|
|
42
|
+
@running
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Progress & Spinners
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def show_spinner(message)
|
|
50
|
+
emit_event("progress", { message: message, status: "running" })
|
|
51
|
+
|
|
52
|
+
result = yield
|
|
53
|
+
|
|
54
|
+
status = result&.dig(:success) ? "success" : "error"
|
|
55
|
+
emit_event("progress", {
|
|
56
|
+
message: result&.dig(:message) || "Done",
|
|
57
|
+
status: status
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
result
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
emit_event("error", { message: e.message })
|
|
63
|
+
raise
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def show_progress(title)
|
|
67
|
+
emit_event("progress_start", { title: title })
|
|
68
|
+
|
|
69
|
+
reporter = JsonProgressReporter.new
|
|
70
|
+
result = yield(reporter)
|
|
71
|
+
|
|
72
|
+
if result&.dig(:error)
|
|
73
|
+
# Error handled by reporter or caller
|
|
74
|
+
else
|
|
75
|
+
emit_event("progress_complete", {
|
|
76
|
+
message: result&.dig(:message) || "Done",
|
|
77
|
+
result: result
|
|
78
|
+
})
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
result
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
emit_event("error", { message: e.message })
|
|
84
|
+
raise
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Results Display
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def show_audit_results(audit_result, _audit_type)
|
|
92
|
+
emit_event("result", audit_result)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def show_test_results(title, passed, failed, errors, total, results = [])
|
|
96
|
+
emit_event("test_results", {
|
|
97
|
+
title: title,
|
|
98
|
+
passed: passed,
|
|
99
|
+
failed: failed,
|
|
100
|
+
errors: errors,
|
|
101
|
+
total: total,
|
|
102
|
+
results: results
|
|
103
|
+
})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Errors & Warnings
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def show_auth_error(message = "Authentication failed")
|
|
111
|
+
emit_event("auth_error", { message: message })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def show_config_error(missing)
|
|
115
|
+
emit_event("config_error", { missing: missing })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def show_config_missing(missing_items)
|
|
119
|
+
emit_event("config_missing", { missing: missing_items })
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def show_system_status(status)
|
|
123
|
+
emit_event("system_status", status)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Stubs for Interactive Methods (Not supported in JSON mode)
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def show_menu
|
|
131
|
+
# JSON mode is non-interactive usually, but if called, we just exit
|
|
132
|
+
"exit"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def show_test_type_menu(_mode = "test")
|
|
136
|
+
"back"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def show_list_view(_title, _items)
|
|
140
|
+
"back"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def show_post_generation_menu(_file_path, _run_command)
|
|
144
|
+
"back"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def show_auth_warning(missing_items = "")
|
|
148
|
+
emit_event("auth_warning", { missing: missing_items })
|
|
149
|
+
# In headless mode, we probably can't prompt, so we might default to cancel
|
|
150
|
+
# or we could make this configurable. For now, cancel to be safe.
|
|
151
|
+
"cancel"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def show_banner(_version); end
|
|
155
|
+
def show_goodbye; end
|
|
156
|
+
def show_post_install(_version); end
|
|
157
|
+
def show_help(_version); end
|
|
158
|
+
def show_stats(_stats); end
|
|
159
|
+
def show_clipboard_success(_cmd); end
|
|
160
|
+
|
|
161
|
+
def show_no_items(type)
|
|
162
|
+
emit_event("no_items", { type: type })
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def strip_colors(str)
|
|
168
|
+
str.gsub(/\e\[\d+(;\d+)*m/, "")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def emit_event(type, data = {})
|
|
172
|
+
puts JSON.generate({ type: type }.merge(data))
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class JsonProgressReporter
|
|
177
|
+
def initialize
|
|
178
|
+
@step = 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def update(message, percent = nil, step_increment: true, explicit_step: nil)
|
|
182
|
+
step_idx = explicit_step || @step
|
|
183
|
+
|
|
184
|
+
payload = { type: "progress_update", message: message }
|
|
185
|
+
payload[:percent] = percent if percent
|
|
186
|
+
payload[:step] = step_idx
|
|
187
|
+
|
|
188
|
+
puts JSON.generate(payload)
|
|
189
|
+
|
|
190
|
+
@step += 1 if step_increment && explicit_step.nil?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def error(message)
|
|
194
|
+
puts JSON.generate({ type: "error", message: message })
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|