tng 0.1.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.

Potentially problematic release.


This version of tng might be problematic. Click here for more details.

data/lib/tng/utils.rb ADDED
@@ -0,0 +1,263 @@
1
+ require "yaml"
2
+ require "json"
3
+
4
+ module Tng
5
+ module Utils
6
+ def clear_screen
7
+ system("clear") || system("cls")
8
+ end
9
+
10
+ def center_text(text, width = nil)
11
+ width ||= @terminal_width || 80 # Use fallback if still nil
12
+ lines = text.split("\n")
13
+ lines.map do |line|
14
+ # Remove ANSI color codes for length calculation
15
+ clean_line = line.gsub(/\e\[[0-9;]*m/, "")
16
+ padding = [(width - clean_line.length) / 2, 0].max
17
+ " " * padding + line
18
+ end.join("\n")
19
+ end
20
+
21
+ def copy_to_clipboard(text)
22
+ # Try to copy to clipboard
23
+ if system("which pbcopy > /dev/null 2>&1")
24
+ system("echo '#{text}' | pbcopy")
25
+ success_msg = @pastel.green("✅ Command copied to clipboard!")
26
+ elsif system("which xclip > /dev/null 2>&1")
27
+ system("echo '#{text}' | xclip -selection clipboard")
28
+ success_msg = @pastel.green("✅ Command copied to clipboard!")
29
+ else
30
+ success_msg = @pastel.yellow("📋 Copy this command:")
31
+ puts center_text(success_msg)
32
+ puts center_text(@pastel.bright_white(text))
33
+ @prompt.keypress(center_text(@pastel.dim("Press any key to continue...")))
34
+ return
35
+ end
36
+
37
+ puts center_text(success_msg)
38
+ @prompt.keypress(center_text(@pastel.dim("Press any key to continue...")))
39
+ end
40
+
41
+ def load_rails_environment
42
+ # Use bundler environment to avoid gem conflicts
43
+ require "bundler/setup"
44
+ require "./config/environment"
45
+ true
46
+ rescue LoadError => e
47
+ puts "Failed to load Rails: #{e.message}"
48
+ false
49
+ end
50
+
51
+ def find_rails_root
52
+ current_dir = Dir.pwd
53
+ while current_dir != "/"
54
+ return current_dir if File.exist?(File.join(current_dir, "config", "application.rb"))
55
+
56
+ current_dir = File.dirname(current_dir)
57
+ end
58
+ nil
59
+ end
60
+
61
+ def camelize(str, first_letter = :upper)
62
+ # Use Rails' camelize method if available, otherwise use custom implementation
63
+ if defined?(ActiveSupport::Inflector) && ActiveSupport::Inflector.respond_to?(:camelize)
64
+ case first_letter
65
+ when :upper
66
+ ActiveSupport::Inflector.camelize(str, true)
67
+ when :lower
68
+ ActiveSupport::Inflector.camelize(str, false)
69
+ else
70
+ raise ArgumentError, "Invalid option, use either :upper or :lower."
71
+ end
72
+ elsif str.respond_to?(:camelize)
73
+ str.camelize(first_letter)
74
+ else
75
+ # Custom implementation
76
+ result = str.gsub(/(?:^|_)([a-z])/) { Regexp.last_match(1).upcase }
77
+ case first_letter
78
+ when :upper
79
+ result
80
+ when :lower
81
+ result[0].downcase + result[1..] if result.length.positive?
82
+ else
83
+ raise ArgumentError, "Invalid option, use either :upper or :lower."
84
+ end
85
+ end
86
+ end
87
+
88
+ def self.has_gem?(gem_name)
89
+ return false unless defined?(Bundler)
90
+
91
+ gemfile_specs = Bundler.load.specs
92
+ gemfile_specs.any? { |spec| spec.name == gem_name }
93
+ rescue StandardError
94
+ false
95
+ end
96
+
97
+ def self.save_test_file(test_content)
98
+ puts "📋 Raw API response: #{test_content[0..200]}..." if ENV["DEBUG"]
99
+ parsed_response = JSON.parse(test_content)
100
+
101
+ # Validate required fields
102
+ unless parsed_response["file_content"]
103
+ puts "❌ API response missing file_content field"
104
+ puts "📋 Response keys: #{parsed_response.keys.inspect}"
105
+ return nil
106
+ end
107
+
108
+ # Handle both possible field names for file path
109
+ file_path = parsed_response["test_file_path"] || parsed_response["file_path"]
110
+ unless file_path
111
+ puts "❌ API response missing test_file_path or file_path field"
112
+ puts "📋 Response keys: #{parsed_response.keys.inspect}"
113
+ return nil
114
+ end
115
+
116
+ File.write(file_path, parsed_response["file_content"])
117
+ puts "✅ Test generated successfully!"
118
+ absolute_path = File.expand_path(file_path)
119
+ puts "Please review the generated tests at \e]8;;file://#{absolute_path}\e\\#{file_path}\e]8;;\e\\"
120
+
121
+ # Determine run command based on test framework
122
+ run_command = if file_path.include?("/spec/")
123
+ "bundle exec rspec #{file_path}"
124
+ else
125
+ "bundle exec rails test #{file_path}"
126
+ end
127
+
128
+ # Return file information for CLI to use
129
+ {
130
+ file_path: file_path,
131
+ absolute_path: absolute_path,
132
+ run_command: run_command,
133
+ test_class_name: parsed_response["test_class_name"]
134
+ }
135
+ end
136
+
137
+ def self.fixture_content
138
+ factory_library = Tng.factory_library || "active_record"
139
+
140
+ case factory_library.downcase
141
+ when "fixtures"
142
+ load_all_fixtures_data
143
+ when "fabrication", "fabricator"
144
+ load_all_fabricator_data
145
+ when "factory_girl", "factory_bot"
146
+ load_all_factory_data
147
+ when "active_record"
148
+ load_all_active_record_data
149
+ else
150
+ puts "⚠️ Warning: Unknown factory library '#{factory_library}'. Using Active Record object creation as fallback."
151
+ load_all_fixtures_data
152
+ end
153
+ end
154
+
155
+ def self.load_all_fixtures_data
156
+ fixture_data = {}
157
+ # TODO: Load proper folder for Rspec. This is only valid for minitest.
158
+ fixtures_dir = Rails.root.join("test", "fixtures")
159
+
160
+ return fixture_data unless Dir.exist?(fixtures_dir)
161
+
162
+ fixture_files = Dir.glob("#{fixtures_dir}/*.yml")
163
+
164
+ fixture_files.each do |fixture_file|
165
+ model_name = File.basename(fixture_file, ".yml")
166
+
167
+ begin
168
+ fixtures = YAML.load_file(fixture_file)
169
+ fixture_data[model_name] = fixtures
170
+ rescue StandardError => e
171
+ puts "⚠️ Warning: Could not load fixture file #{fixture_file}: #{e.message}"
172
+ end
173
+ end
174
+
175
+ fixture_data
176
+ end
177
+
178
+ def self.load_all_fabricator_data
179
+ fabricator_data = {}
180
+ fabricators_dir = Rails.root.join("spec", "fabricators")
181
+
182
+ return fabricator_data unless Dir.exist?(fabricators_dir)
183
+
184
+ Dir.glob("#{fabricators_dir}/*_fabricator.rb").each do |fabricator_file|
185
+ model_name = File.basename(fabricator_file, "_fabricator.rb")
186
+
187
+ begin
188
+ content = File.read(fabricator_file)
189
+ fabricator_data[model_name] = parse_fabricator_structure(content, model_name)
190
+ rescue StandardError => e
191
+ puts "⚠️ Warning: Could not load fabricator file #{fabricator_file}: #{e.message}"
192
+ end
193
+ end
194
+
195
+ fabricator_data
196
+ end
197
+
198
+ def self.load_all_factory_data
199
+ factory_data = {}
200
+ factory_dirs = [
201
+ Rails.root.join("spec", "factories"),
202
+ Rails.root.join("test", "factories")
203
+ ]
204
+
205
+ factory_dirs.each do |factory_dir|
206
+ next unless Dir.exist?(factory_dir)
207
+
208
+ Dir.glob("#{factory_dir}/*.rb").each do |factory_file|
209
+ content = File.read(factory_file)
210
+
211
+ begin
212
+ # Extract all factory definitions from the file
213
+ content.scan(/factory\s+:(\w+)\s+do/) do |match|
214
+ model_name = match[0]
215
+ factory_data[model_name] = parse_factory_structure(content, model_name)
216
+ end
217
+ rescue StandardError => e
218
+ puts "⚠️ Warning: Could not load factory file #{factory_file}: #{e.message}"
219
+ end
220
+ end
221
+ end
222
+
223
+ factory_data
224
+ end
225
+
226
+ def self.parse_fabricator_structure(content, model_name)
227
+ # Parse fabricator file to extract attribute structure
228
+ attributes = {}
229
+
230
+ # Look for Fabricator definitions
231
+ content.scan(/Fabricator\(:#{model_name}\) do \|f\|(.*?)end/m) do |block|
232
+ block[0].scan(/f\.(\w+)\s+(.+)/) do |attr, value|
233
+ attributes[attr] = parse_attribute_value(value)
234
+ end
235
+ end
236
+
237
+ { "attributes" => attributes, "type" => "fabricator" }
238
+ end
239
+
240
+ def self.parse_factory_structure(content, model_name)
241
+ # Parse factory file to extract attribute structure
242
+ attributes = {}
243
+
244
+ # Look for factory definitions
245
+ content.scan(/factory :#{model_name} do(.*?)end/m) do |block|
246
+ block[0].scan(/(\w+)\s+(.+)/) do |attr, value|
247
+ attributes[attr] = parse_attribute_value(value)
248
+ end
249
+ end
250
+
251
+ { "attributes" => attributes, "type" => "factory" }
252
+ end
253
+
254
+ def self.parse_attribute_value(value)
255
+ # Clean up and parse attribute values
256
+ value.strip.gsub(/^["']|["']$/, "")
257
+ end
258
+
259
+ def self.load_all_active_record_data
260
+ []
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tng
4
+ VERSION = "0.1.4"
5
+ end
data/lib/tng.rb ADDED
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ $VERBOSE = nil
4
+
5
+ require_relative "tng/version"
6
+ require_relative "tng/utils"
7
+ require_relative "tng/api/http_client"
8
+ require_relative "tng/ui/theme"
9
+ require_relative "tng/ui/post_install_box"
10
+ require_relative "tng/ui/authentication_warning_display"
11
+ require_relative "tng/services/test_generator"
12
+
13
+ require_relative "tng/railtie" if defined?(Rails)
14
+
15
+ begin
16
+ platform = RUBY_PLATFORM
17
+ binary_name = if platform.include?("darwin") # macOS
18
+ "tng.bundle"
19
+ elsif platform.include?("linux")
20
+ "tng.so"
21
+ else
22
+ raise "Unsupported platform: #{platform}"
23
+ end
24
+
25
+ binary_paths = [
26
+ File.expand_path("../binaries/#{binary_name}", __dir__), # From gem root
27
+ File.expand_path("../../binaries/#{binary_name}", __FILE__), # Alternative path
28
+ File.expand_path("binaries/#{binary_name}", __dir__) # Direct from lib
29
+ ]
30
+
31
+ loaded = false
32
+ binary_paths.each do |path|
33
+ next unless File.exist?(path)
34
+
35
+ require path
36
+ loaded = true
37
+ break
38
+ end
39
+
40
+ raise LoadError, "Could not find binary at any of: #{binary_paths.join(", ")}" unless loaded
41
+ rescue LoadError => e
42
+ puts "Warning: Could not load native extension: #{e.message}"
43
+ puts "Some functionality may not be available."
44
+ end
45
+
46
+ # Load Ruby analyzers after native binary to ensure Rust classes are available
47
+ # Ruby analyzers depend on Rust classes (e.g., Tng::Analyzer::Controller)
48
+ require_relative "tng/analyzers/controller"
49
+ require_relative "tng/analyzers/model"
50
+ require_relative "tng/analyzers/service"
51
+
52
+ module Tng
53
+ class Error < StandardError; end
54
+
55
+ @config = {
56
+ api_key: nil,
57
+ base_url: "https://app.tng.sh/",
58
+ read_source_code: true,
59
+ read_test_code: true,
60
+ testing_framework: "minitest",
61
+ authentication_enabled: false,
62
+ authorization_library: nil,
63
+ authentication_library: nil,
64
+ authentication_methods: [],
65
+ mock_library: "minitest/mock",
66
+ http_mock_library: "webmock",
67
+ factory_library: "active_record"
68
+ }
69
+
70
+ def self.configure
71
+ yield(self) if block_given?
72
+ end
73
+
74
+ def self.config
75
+ @config
76
+ end
77
+
78
+ def self.api_key=(value)
79
+ @config[:api_key] = value
80
+ end
81
+
82
+ def self.api_key
83
+ @config[:api_key]
84
+ end
85
+
86
+ def self.base_url=(value)
87
+ @config[:base_url] = value
88
+ end
89
+
90
+ def self.base_url
91
+ @config[:base_url]
92
+ end
93
+
94
+ def self.read_source_code=(value)
95
+ @config[:read_source_code] = value
96
+ end
97
+
98
+ def self.read_source_code
99
+ @config[:read_source_code]
100
+ end
101
+
102
+ def self.read_test_code=(value)
103
+ @config[:read_test_code] = value
104
+ end
105
+
106
+ def self.read_test_code
107
+ @config[:read_test_code]
108
+ end
109
+
110
+ def self.testing_framework=(value)
111
+ @config[:testing_framework] = value
112
+ initialize_framework_defaults(value)
113
+ end
114
+
115
+ def self.testing_framework
116
+ @config[:testing_framework]
117
+ end
118
+
119
+ def self.authentication_enabled=(value)
120
+ @config[:authentication_enabled] = value
121
+ end
122
+
123
+ def self.authentication_enabled
124
+ @config[:authentication_enabled]
125
+ end
126
+
127
+ def self.authentication_library=(value)
128
+ @config[:authentication_library] = value
129
+ end
130
+
131
+ def self.authentication_library
132
+ @config[:authentication_library]
133
+ end
134
+
135
+ def self.authorization_library=(value)
136
+ @config[:authorization_library] = value
137
+ end
138
+
139
+ def self.authorization_library
140
+ @config[:authorization_library]
141
+ end
142
+
143
+ def self.authentication_methods=(value)
144
+ @config[:authentication_methods] = value
145
+ end
146
+
147
+ def self.authentication_methods
148
+ @config[:authentication_methods]
149
+ end
150
+
151
+ def self.mock_library=(value)
152
+ @config[:mock_library] = value
153
+ end
154
+
155
+ def self.mock_library
156
+ @config[:mock_library]
157
+ end
158
+
159
+ def self.http_mock_library=(value)
160
+ @config[:http_mock_library] = value
161
+ end
162
+
163
+ def self.http_mock_library
164
+ @config[:http_mock_library]
165
+ end
166
+
167
+ def self.factory_library=(value)
168
+ @config[:factory_library] = value
169
+ end
170
+
171
+ def self.factory_library
172
+ @config[:factory_library]
173
+ end
174
+
175
+ def self.test_style=(value)
176
+ @config[:test_style] = value
177
+ end
178
+
179
+ def self.test_style
180
+ @config[:test_style]
181
+ end
182
+
183
+ def self.setup_style=(value)
184
+ @config[:setup_style] = value
185
+ end
186
+
187
+ def self.setup_style
188
+ @config[:setup_style]
189
+ end
190
+
191
+ def self.assertion_style=(value)
192
+ @config[:assertion_style] = value
193
+ end
194
+
195
+ def self.assertion_style
196
+ @config[:assertion_style]
197
+ end
198
+
199
+ def self.teardown_style=(value)
200
+ @config[:teardown_style] = value
201
+ end
202
+
203
+ def self.teardown_style
204
+ @config[:teardown_style]
205
+ end
206
+
207
+ def self.describe_style=(value)
208
+ @config[:describe_style] = value
209
+ end
210
+
211
+ def self.describe_style
212
+ @config[:describe_style]
213
+ end
214
+
215
+ def self.context_style=(value)
216
+ @config[:context_style] = value
217
+ end
218
+
219
+ def self.context_style
220
+ @config[:context_style]
221
+ end
222
+
223
+ def self.it_style=(value)
224
+ @config[:it_style] = value
225
+ end
226
+
227
+ def self.it_style
228
+ @config[:it_style]
229
+ end
230
+
231
+ def self.before_style=(value)
232
+ @config[:before_style] = value
233
+ end
234
+
235
+ def self.before_style
236
+ @config[:before_style]
237
+ end
238
+
239
+ def self.after_style=(value)
240
+ @config[:after_style] = value
241
+ end
242
+
243
+ def self.after_style
244
+ @config[:after_style]
245
+ end
246
+
247
+ def self.let_style=(value)
248
+ @config[:let_style] = value
249
+ end
250
+
251
+ def self.let_style
252
+ @config[:let_style]
253
+ end
254
+
255
+ def self.subject_style=(value)
256
+ @config[:subject_style] = value
257
+ end
258
+
259
+ def self.subject_style
260
+ @config[:subject_style]
261
+ end
262
+
263
+ def self.authentication_configured?
264
+ return false unless authentication_enabled
265
+ return false if authentication_methods.nil? || authentication_methods.empty?
266
+
267
+ authentication_methods.all? do |method|
268
+ method.is_a?(Hash) &&
269
+ method.key?(:method) && !method[:method].to_s.strip.empty? &&
270
+ method.key?(:file_location) && !method[:file_location].to_s.strip.empty? &&
271
+ method.key?(:auth_type) && !method[:auth_type].to_s.strip.empty?
272
+ end
273
+ end
274
+
275
+ def self.initialize_framework_defaults(framework)
276
+ if framework == "minitest"
277
+ @config.merge!({
278
+ test_style: "spec",
279
+ setup_style: false,
280
+ assertion_style: "assert",
281
+ teardown_style: false
282
+ })
283
+ @config.delete(:describe_style)
284
+ @config.delete(:context_style)
285
+ @config.delete(:it_style)
286
+ @config.delete(:before_style)
287
+ @config.delete(:after_style)
288
+ @config.delete(:let_style)
289
+ @config.delete(:subject_style)
290
+ elsif framework == "rspec"
291
+ @config.merge!({
292
+ describe_style: true,
293
+ context_style: "context",
294
+ it_style: "it",
295
+ before_style: "before",
296
+ after_style: "after",
297
+ let_style: true,
298
+ subject_style: true
299
+ })
300
+ @config.delete(:test_style)
301
+ @config.delete(:setup_style)
302
+ @config.delete(:assertion_style)
303
+ @config.delete(:teardown_style)
304
+ end
305
+ end
306
+
307
+ initialize_framework_defaults(@config[:testing_framework])
308
+ end
data/tng.gemspec ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/tng/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "tng"
7
+ spec.version = Tng::VERSION
8
+ spec.authors = ["ralucab"]
9
+ spec.email = ["claudiu.garba@gmail.com"]
10
+
11
+ spec.summary = "TNG generates tests using static code analysis and LLM"
12
+ spec.description = "TNG (Test Next Generation) is a Rails gem that automatically generates comprehensive test files by analyzing your Ruby code using static analysis and AI. It supports models, controllers, and services with intelligent test case generation."
13
+ spec.homepage = "https://tng.sh/"
14
+ spec.required_ruby_version = ">= 3.1.0"
15
+ spec.required_rubygems_version = ">= 3.3.11"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/tng-sh/tng-rails"
20
+ spec.license = "Nonstandard"
21
+ spec.metadata["license_uri"] = "https://github.com/tng-sh/tng-rails/blob/master/LICENSE.md"
22
+
23
+ # Package pre-compiled binaries and exclude the Rust source code.
24
+ spec.files = Dir.glob("lib/**/*.rb") +
25
+ Dir.glob("binaries/*") +
26
+ Dir.glob("bin/**/*") +
27
+ ["LICENSE.md", "README.md", "Gemfile", "Rakefile", "tng.gemspec"]
28
+
29
+ spec.bindir = "bin"
30
+ spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+ spec.extensions = []
33
+
34
+ spec.add_dependency "httpx", "~> 1.5"
35
+ spec.add_dependency "pastel", "~> 0.8.0"
36
+ spec.add_dependency "prism", "~> 1.4.0"
37
+ spec.add_dependency "rb_sys", "~> 0.9.91"
38
+ spec.add_dependency "tty-box", "~> 0.7"
39
+ spec.add_dependency "tty-file", "~> 0.10"
40
+ spec.add_dependency "tty-option", "~> 0.3"
41
+ spec.add_dependency "tty-progressbar", "~> 0.18.3"
42
+ spec.add_dependency "tty-prompt", "~> 0.23"
43
+ spec.add_dependency "tty-reader", "~> 0.9"
44
+ spec.add_dependency "tty-screen", "~> 0.8"
45
+ spec.add_dependency "tty-spinner", "~> 0.9.3"
46
+ spec.add_dependency "tty-table", "~> 0.12"
47
+
48
+ spec.post_install_message = begin
49
+ require_relative "lib/tng/ui/post_install_box"
50
+ PostInstallBox.new(spec.version).render
51
+ rescue LoadError, StandardError
52
+ "TNG v#{spec.version} installed successfully!\n" +
53
+ "Run 'rails g tng:install' to get started.\n" +
54
+ "Use 'bundle exec tng --help' for usage information."
55
+ end
56
+ end