tng 0.1.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.
@@ -0,0 +1,102 @@
1
+ require "httpx"
2
+
3
+ module Tng
4
+ class HttpClient
5
+ def initialize(api_endpoint, api_key)
6
+ @api_endpoint = api_endpoint
7
+ @api_key = api_key
8
+ @timeout = {
9
+ connect_timeout: 300,
10
+ read_timeout: 300,
11
+ write_timeout: 300,
12
+ request_timeout: 300
13
+ }
14
+ end
15
+
16
+ def post(path, payload: {}, headers: {})
17
+ default_headers = {
18
+ "Content-Type" => "application/json",
19
+ "Authorization" => "Bearer #{@api_key}",
20
+ "User-Agent" => "TestNG-Rails/#{Tng::VERSION} Ruby/#{RUBY_VERSION}"
21
+ }
22
+
23
+ merged_headers = default_headers.merge(headers)
24
+
25
+ response = HTTPX.with(timeout: @timeout).post(
26
+ "#{@api_endpoint}/#{path}",
27
+ json: payload,
28
+ headers: merged_headers
29
+ )
30
+
31
+ debug_response("POST #{path}", response) if debug_enabled?
32
+ response
33
+ end
34
+
35
+ def post_binary(path, data, headers: {})
36
+ default_headers = {
37
+ "Content-Type" => "application/octet-stream",
38
+ "Authorization" => "Bearer #{@api_key}",
39
+ "User-Agent" => "TestNG-Rails/#{Tng::VERSION} Ruby/#{RUBY_VERSION}"
40
+ }
41
+
42
+ merged_headers = default_headers.merge(headers)
43
+
44
+ response = HTTPX.with(timeout: @timeout).post(
45
+ "#{@api_endpoint}/#{path}",
46
+ body: data,
47
+ headers: merged_headers
48
+ )
49
+
50
+ debug_response("POST #{path} (binary)", response) if debug_enabled?
51
+ response
52
+ end
53
+
54
+ def get(path, headers: {})
55
+ default_headers = {
56
+ "Content-Type" => "application/json",
57
+ "Authorization" => "Bearer #{@api_key}",
58
+ "User-Agent" => "TestNG-Rails/#{Tng::VERSION} Ruby/#{RUBY_VERSION}"
59
+ }
60
+
61
+ merged_headers = default_headers.merge(headers)
62
+
63
+ response = HTTPX.with(timeout: @timeout).get(
64
+ "#{@api_endpoint}/#{path}",
65
+ headers: merged_headers
66
+ )
67
+
68
+ debug_response("GET #{path}", response) if debug_enabled?
69
+ response
70
+ end
71
+
72
+ def ping
73
+ response = HTTPX.with(timeout: @timeout).get("#{@api_endpoint}/ping")
74
+ debug_response("GET /ping", response) if debug_enabled?
75
+ response
76
+ end
77
+
78
+ private
79
+
80
+ def debug_enabled?
81
+ ENV["DEBUG"] == "1"
82
+ end
83
+
84
+ def debug_response(request_info, response)
85
+ puts "\n -> DEBUG: #{request_info}"
86
+ puts " Status: #{response.status}"
87
+ puts " Headers: #{response.headers.to_h}"
88
+
89
+ if response.is_a?(HTTPX::ErrorResponse)
90
+ puts " Error: #{response.error&.message}"
91
+ else
92
+ body = response.body.to_s
93
+ if body.length > 500
94
+ puts " Body: #{body[0..500]}... (truncated, total length: #{body.length})"
95
+ else
96
+ puts " Body: #{body}"
97
+ end
98
+ end
99
+ puts " " + "─" * 50
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Tng
6
+ class Railtie < Rails::Railtie
7
+ generators do
8
+ require_relative "../generators/tng/install_generator"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "tng/utils"
5
+ require "tng/services/user_app_config"
6
+
7
+ module Tng
8
+ module Services
9
+ class TestGenerator
10
+ GENERATE_TESTS_PATH = "cli/tng_rails/contents/generate_tests"
11
+
12
+ def initialize(http_client)
13
+ @http_client = http_client
14
+ @machine_info = Tng.machine_info
15
+ end
16
+
17
+ def run_for_controller(controller)
18
+ payload = {
19
+ controller_test: Tng::Analyzers::Controller.read_test_file_for_controller(controller[:path]),
20
+ object_fixture_data: Tng::Utils.fixture_content,
21
+ machine_info: @machine_info,
22
+ ast: Tng::Analyzers::Controller.value_for_controller(controller[:path]),
23
+ test_type: "controller",
24
+ controller: controller,
25
+ routes: Tng::Analyzers::Controller.routes_for_controller(controller[:path]),
26
+ auth_patterns: Tng::Services::UserAppConfig.config_with_source,
27
+ model_info: Tng::Analyzers::Controller.model_info_for_controller(controller[:path]),
28
+ parents: Tng::Analyzers::Controller.parents_for_controller(controller)
29
+ }
30
+
31
+ send_request_and_save_test(payload)
32
+ end
33
+
34
+ def run_for_model(model)
35
+ payload = {
36
+ model_test: Tng::Analyzers::Model.read_test_file_for_model(model[:path]),
37
+ machine_info: @machine_info,
38
+ object_fixture_data: Tng::Utils.fixture_content,
39
+ ast: Tng::Analyzers::Model.value_for_model(model[:path]),
40
+ test_type: "model",
41
+ model: model,
42
+ auth_patterns: Tng::Services::UserAppConfig.config_with_source,
43
+ model_connections: Tng::Analyzers::Model.model_connections(model)
44
+ }
45
+
46
+ send_request_and_save_test(payload)
47
+ end
48
+
49
+ def run_for_service(service)
50
+ payload = {
51
+ service_test: Tng::Analyzers::Service.read_test_file_for_service(service[:path]),
52
+ machine_info: @machine_info,
53
+ object_fixture_data: Tng::Utils.fixture_content,
54
+ ast: Tng::Analyzers::Service.value_for_service(service[:path]),
55
+ test_type: "service",
56
+ service: service
57
+ }
58
+ send_request_and_save_test(payload)
59
+ end
60
+
61
+ def run_for_controller_method(controller, method_info)
62
+ payload = {
63
+ controller_test: Tng::Analyzers::Controller.read_test_file_for_controller(controller[:path]),
64
+ object_fixture_data: Tng::Utils.fixture_content,
65
+ machine_info: @machine_info,
66
+ ast: Tng::Analyzers::Controller.value_for_controller(controller[:path]),
67
+ test_type: "controller_method",
68
+ controller: controller,
69
+ method: method_info,
70
+ routes: Tng::Analyzers::Controller.routes_for_controller(controller[:path]),
71
+ auth_patterns: Tng::Services::UserAppConfig.config_with_source,
72
+ model_info: Tng::Analyzers::Controller.model_info_for_controller(controller[:path]),
73
+ parents: Tng::Analyzers::Controller.parents_for_controller(controller)
74
+ }
75
+
76
+ send_request_and_save_test(payload)
77
+ end
78
+
79
+ def run_for_model_method(model, method_info)
80
+ payload = {
81
+ model_test: Tng::Analyzers::Model.read_test_file_for_model(model[:path]),
82
+ machine_info: @machine_info,
83
+ object_fixture_data: Tng::Utils.fixture_content,
84
+ ast: Tng::Analyzers::Model.value_for_model(model[:path]),
85
+ test_type: "model_method",
86
+ model: model,
87
+ method: method_info,
88
+ auth_patterns: Tng::Services::UserAppConfig.config_with_source,
89
+ model_connections: Tng::Analyzers::Model.model_connections(model)
90
+ }
91
+
92
+ send_request_and_save_test(payload)
93
+ end
94
+
95
+ def run_for_service_method(service, method_info)
96
+ payload = {
97
+ service_test: Tng::Analyzers::Service.read_test_file_for_service(service[:path]),
98
+ machine_info: @machine_info,
99
+ object_fixture_data: Tng::Utils.fixture_content,
100
+ ast: Tng::Analyzers::Service.value_for_service(service[:path]),
101
+ test_type: "service_method",
102
+ service: service,
103
+ method: method_info,
104
+ auth_patterns: Tng::Services::UserAppConfig.config_with_source
105
+ }
106
+
107
+ send_request_and_save_test(payload)
108
+ end
109
+
110
+ private
111
+
112
+ def send_request_and_save_test(payload)
113
+ marshaled = Marshal.dump(payload)
114
+ compressed = Zlib::Deflate.deflate(marshaled)
115
+ response = @http_client.post_binary(GENERATE_TESTS_PATH, compressed)
116
+
117
+ if response.is_a?(HTTPX::ErrorResponse)
118
+ debug_log("Request failed with error response") if debug_enabled?
119
+ return handle_request_error(response)
120
+ end
121
+
122
+ debug_log("Request successful, status: #{response.status}") if debug_enabled?
123
+
124
+ test_content = response.body.to_s
125
+ if test_content.nil? || test_content.empty?
126
+ debug_log("Empty response body received") if debug_enabled?
127
+ return handle_empty_response
128
+ end
129
+
130
+ debug_log("Received test content, length: #{test_content.length}") if debug_enabled?
131
+ Tng::Utils.save_test_file(test_content)
132
+ rescue JSON::ParserError => e
133
+ debug_log("JSON parsing failed: #{e.message}") if debug_enabled?
134
+ puts "❌ Failed to parse generated tests via API: #{e.message}"
135
+ nil
136
+ end
137
+
138
+ def handle_request_error(response)
139
+ debug_log("Handling request error: #{response.error&.message}") if debug_enabled?
140
+ puts "❌ Request failed: #{response.error&.message}"
141
+ nil
142
+ end
143
+
144
+ def handle_empty_response
145
+ debug_log("Handling empty response") if debug_enabled?
146
+ puts "❌ Failed to generate test via API"
147
+ nil
148
+ end
149
+
150
+ def debug_enabled?
151
+ ENV["DEBUG"] == "1"
152
+ end
153
+
154
+ def debug_log(message)
155
+ puts "-> DEBUG [TestGenerator]: #{message}"
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,100 @@
1
+ module Services
2
+ class Testng
3
+ STATS_PATH = "cli/tng_rails/stats"
4
+
5
+ def initialize(http_client)
6
+ @http_client = http_client
7
+ end
8
+
9
+ def check_system_status
10
+ response = @http_client.ping
11
+
12
+ if response.is_a?(HTTPX::ErrorResponse)
13
+ return {
14
+ status: :error,
15
+ message: "Unable to connect to TNG service: #{response.error&.message}",
16
+ error_type: :connection_error
17
+ }
18
+ end
19
+
20
+ case response.status
21
+ when 200
22
+ begin
23
+ data = JSON.parse(response.body)
24
+ current_version = data["current_version"]
25
+ server_base_url = data["base_url"]
26
+ user_base_url = Tng::Services::UserAppConfig.base_url
27
+
28
+ if current_version != Tng::VERSION
29
+ return {
30
+ status: :version_mismatch,
31
+ message: "Version mismatch detected",
32
+ current_version: current_version,
33
+ gem_version: Tng::VERSION,
34
+ error_type: :version_mismatch
35
+ }
36
+ end
37
+
38
+ # Check for base URL mismatch
39
+ if server_base_url && user_base_url && server_base_url != user_base_url
40
+ return {
41
+ status: :base_url_mismatch,
42
+ message: "Base URL mismatch detected",
43
+ server_base_url: server_base_url,
44
+ user_base_url: user_base_url,
45
+ current_version: current_version,
46
+ gem_version: Tng::VERSION,
47
+ error_type: :base_url_mismatch
48
+ }
49
+ end
50
+
51
+ {
52
+ status: :ok,
53
+ message: data["message"] || "System is operational",
54
+ current_version: current_version,
55
+ gem_version: Tng::VERSION,
56
+ server_base_url: server_base_url
57
+ }
58
+ rescue JSON::ParserError => e
59
+ {
60
+ status: :error,
61
+ message: "Invalid response from server: #{e.message}",
62
+ error_type: :parse_error
63
+ }
64
+ end
65
+ else
66
+ {
67
+ status: :service_down,
68
+ message: "TestNG service is currently unavailable (HTTP #{response.status})",
69
+ error_type: :service_unavailable
70
+ }
71
+ end
72
+ end
73
+
74
+ def get_user_stats
75
+ headers = {
76
+ "Authorization" => "Bearer #{Tng::Services::UserAppConfig.api_key}",
77
+ "Content-Type" => "application/json",
78
+ "User-Agent" => "Tng/#{Tng::VERSION} Ruby/#{RUBY_VERSION}"
79
+ }
80
+
81
+ response = @http_client.get(STATS_PATH, headers: headers)
82
+
83
+ return nil if response.is_a?(HTTPX::ErrorResponse)
84
+
85
+ if response.status == 200
86
+ begin
87
+ stats_data = JSON.parse(response.body)
88
+ puts "✅ Statistics fetched successfully!"
89
+ stats_data
90
+ rescue JSON::ParserError => e
91
+ puts "❌ Failed to parse statistics response: #{e.message}"
92
+ nil
93
+ end
94
+ else
95
+ puts "❌ Failed to fetch statistics (#{response.status})"
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tng
4
+ module Services
5
+ class UserAppConfig
6
+ def self.api_key
7
+ Tng.api_key
8
+ end
9
+
10
+ def self.base_url
11
+ Tng.base_url
12
+ end
13
+
14
+ def self.configured?
15
+ Tng.api_key && Tng.base_url
16
+ end
17
+
18
+ def self.missing_config
19
+ missing = []
20
+ missing << "API key" unless api_key
21
+ missing << "Base URL" unless base_url
22
+ missing
23
+ end
24
+
25
+ def self.config_with_source
26
+ auth_entry_points_with_source = []
27
+ methods = Tng.authentication_methods
28
+ methods.each do |method|
29
+ auth_entry_points_with_source << {
30
+ method: method[:method],
31
+ file: method[:file_location],
32
+ auth_type: method[:auth_type],
33
+ source: extract_method_source(method[:file_location], method[:method])
34
+ }
35
+ end
36
+ Tng.config[:authentication_entry_points_with_source] = auth_entry_points_with_source
37
+ Tng.config
38
+ end
39
+
40
+ def self.find_method_in_ast(node, method_name)
41
+ return nil unless node.is_a?(Prism::Node)
42
+
43
+ return node if node.is_a?(Prism::DefNode) && node.name == method_name.to_sym
44
+
45
+ node.child_nodes.each do |child|
46
+ result = find_method_in_ast(child, method_name)
47
+ return result if result
48
+ end
49
+
50
+ nil
51
+ end
52
+
53
+ def self.extract_method_source(file_path, method_name)
54
+ return nil unless file_path && method_name
55
+
56
+ full_path = Rails.root.join(file_path)
57
+ return nil unless File.exist?(full_path)
58
+
59
+ file_content = File.read(full_path)
60
+ result = Prism.parse(file_content)
61
+
62
+ method_node = find_method_in_ast(result.value, method_name)
63
+ return nil unless method_node
64
+
65
+ # Extract the method source from the original file content
66
+ start_line = method_node.location.start_line - 1 # Convert to 0-based index
67
+ end_line = method_node.location.end_line - 1
68
+
69
+ lines = file_content.lines
70
+ method_source = lines[start_line..end_line].join
71
+ method_source.strip
72
+ rescue StandardError
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-box"
4
+ require "pastel"
5
+ require "tty-screen"
6
+
7
+ class AboutDisplay
8
+ def initialize(pastel, prompt, version)
9
+ @pastel = pastel
10
+ @prompt = prompt
11
+ @version = version
12
+ @terminal_width = begin
13
+ TTY::Screen.width
14
+ rescue StandardError
15
+ 80
16
+ end
17
+ end
18
+
19
+ def display
20
+ about_content = [
21
+ @pastel.cyan.bold("About Tng"),
22
+ "",
23
+ @pastel.bright_white("Tng is an LLM-powered test generation tool for Rails applications."),
24
+ "",
25
+ @pastel.bold("Features:"),
26
+ "• Controller test generation",
27
+ "• Model test generation",
28
+ "• LLM-powered test suggestions",
29
+ "• Test coverage analysis",
30
+ "",
31
+ @pastel.bold("Technology:"),
32
+ "• Static code analysis with Prism",
33
+ "• Intelligent pattern recognition",
34
+ "• Rails-specific optimizations",
35
+ "",
36
+ @pastel.dim("Version: #{@version}"),
37
+ @pastel.dim("Built with ❤️ for Rails developers")
38
+ ].join("\n")
39
+
40
+ box_width = 64
41
+ about_box = TTY::Box.frame(
42
+ title: { top_left: " About Tng " },
43
+ style: {
44
+ fg: :bright_white,
45
+ border: { fg: :cyan },
46
+ title: { fg: :bright_cyan }
47
+ },
48
+ padding: [1, 2],
49
+ width: box_width
50
+ ) do
51
+ about_content
52
+ end
53
+
54
+ puts center_box(about_box, box_width)
55
+ @prompt.keypress(center_text(@pastel.dim("Press any key to continue...")))
56
+ end
57
+
58
+ private
59
+
60
+ def center_text(text)
61
+ padding = (@terminal_width - @pastel.strip(text).length) / 2
62
+ padding = 0 if padding.negative?
63
+ (" " * padding) + text
64
+ end
65
+
66
+ def center_box(box_string, box_width)
67
+ padding = (@terminal_width - box_width) / 2
68
+ padding = 0 if padding.negative?
69
+ box_string.lines.map { |line| (" " * padding) + line.chomp }.join("\n")
70
+ end
71
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty-screen"
5
+
6
+ class ConfigurationDisplay
7
+ def initialize(pastel)
8
+ @pastel = pastel
9
+ @terminal_width = begin
10
+ TTY::Screen.width
11
+ rescue StandardError
12
+ 80
13
+ end
14
+ end
15
+
16
+ def display_missing_config(missing_config)
17
+ puts @pastel.red.bold("❌ Configuration Required!")
18
+ puts
19
+ puts @pastel.yellow("Missing required configuration:")
20
+ missing_config.each do |key|
21
+ puts @pastel.cyan(" • #{key}")
22
+ end
23
+ puts
24
+ puts @pastel.bright_white("Please edit your configuration file:")
25
+ puts @pastel.cyan(" config/initializers/tng.rb")
26
+ puts
27
+ puts @pastel.bright_white("Ensure these values are set:")
28
+ puts @pastel.cyan(" Tng.configure do |config|")
29
+ puts @pastel.cyan(" config.api_key = \"your-api-key-here\"") if missing_config.include?("api_key")
30
+ puts @pastel.cyan(" config.testing_framework = \"minitest\"") if missing_config.include?("test_framework")
31
+ puts @pastel.cyan(" end")
32
+ puts
33
+ puts @pastel.dim("💡 Run 'rails generate tng:install' to create a proper config file.")
34
+ puts @pastel.dim("💡 Verify the generated setup corresponds to your project settings.")
35
+ puts
36
+ puts @pastel.yellow("After configuring, run 'bundle exec tng' again.")
37
+ end
38
+
39
+ private
40
+
41
+ def center_text(text)
42
+ padding = (@terminal_width - @pastel.strip(text).length) / 2
43
+ padding = 0 if padding.negative?
44
+ (" " * padding) + text
45
+ end
46
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "tty-spinner"
5
+ require "pastel"
6
+ require "tty-screen"
7
+
8
+ class ControllerTestFlowDisplay
9
+ def initialize(prompt, pastel)
10
+ @prompt = prompt
11
+ @pastel = pastel
12
+ @terminal_width = begin
13
+ TTY::Screen.width
14
+ rescue StandardError
15
+ 80
16
+ end
17
+ end
18
+
19
+ def select_controller(controllers)
20
+ header = @pastel.bright_white.bold("📋 Select controller to test:")
21
+ puts center_text(header)
22
+
23
+ @prompt.select(
24
+ "",
25
+ cycle: true,
26
+ per_page: 12,
27
+ filter: true,
28
+ symbols: { marker: "▶" }
29
+ ) do |menu|
30
+ controllers.each do |controller|
31
+ display_name = "#{controller[:name]} #{@pastel.dim("(#{controller[:path]})")}"
32
+ menu.choice display_name, controller
33
+ end
34
+ menu.choice @pastel.cyan("⬅️ Back"), :back
35
+ end
36
+ end
37
+
38
+ def select_test_option(controller_choice)
39
+ header = @pastel.bright_white.bold("🎯 Test Generation Options for #{controller_choice[:name]}")
40
+ puts center_text(header)
41
+ puts
42
+
43
+ @prompt.select(
44
+ @pastel.bright_white("Choose test generation type:"),
45
+ cycle: true,
46
+ symbols: { marker: "▶" }
47
+ ) do |menu|
48
+ menu.choice @pastel.green("Generate all possible tests"), :all_possible_tests
49
+ menu.choice @pastel.blue("Generate per method"), :per_method
50
+ menu.choice @pastel.cyan("⬅️ Back"), :back
51
+ end
52
+ end
53
+
54
+ def show_no_controllers_message
55
+ error_msg = "#{@pastel.red.bold("❌ No controllers found in your application")}\n#{@pastel.dim("Make sure you have controllers in app/controllers/")}"
56
+ puts center_text(error_msg)
57
+ @prompt.keypress(center_text(@pastel.dim("Press any key to continue...")))
58
+ end
59
+
60
+ def select_controller_method(controller, methods)
61
+ header = @pastel.bright_white.bold("🎯 Select method to test in #{controller[:name]}")
62
+ puts center_text(header)
63
+
64
+ @prompt.select(
65
+ "",
66
+ cycle: true,
67
+ per_page: 10,
68
+ filter: true,
69
+ symbols: { marker: "▶" }
70
+ ) do |menu|
71
+ methods.each do |method|
72
+ menu.choice method[:name], method
73
+ end
74
+ menu.choice @pastel.cyan("⬅️ Back"), :back
75
+ end
76
+ end
77
+
78
+ def show_no_methods_message(controller)
79
+ error_msg = "#{@pastel.red.bold("❌ No methods found in #{controller[:name]}")}\n#{@pastel.dim("Controller may be empty or have syntax errors")}"
80
+ puts center_text(error_msg)
81
+ @prompt.keypress(center_text(@pastel.dim("Press any key to continue...")))
82
+ end
83
+
84
+ def center_text(text)
85
+ padding = (@terminal_width - @pastel.strip(text).length) / 2
86
+ padding = 0 if padding.negative?
87
+ (" " * padding) + text
88
+ end
89
+ end