ruby_raider 2.0.0 → 3.0.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/.github/workflows/e2e_tests.yml +58 -0
- data/.github/workflows/steep.yml +21 -0
- data/.gitignore +1 -1
- data/.reek.yml +46 -4
- data/.ruby-version +1 -1
- data/README.md +138 -77
- data/Steepfile +22 -0
- data/assets/ruby_raider_logo.svg +51 -0
- data/lib/adopter/adopt_menu.rb +11 -15
- data/lib/adopter/converters/base_converter.rb +1 -2
- data/lib/adopter/converters/identity_converter.rb +3 -6
- data/lib/adopter/migration_plan.rb +0 -1
- data/lib/adopter/plan_builder.rb +2 -5
- data/lib/adopter/project_analyzer.rb +1 -5
- data/lib/adopter/project_detector.rb +3 -5
- data/lib/commands/adopt_commands.rb +0 -1
- data/lib/commands/plugin_commands.rb +0 -2
- data/lib/commands/scaffolding_commands.rb +220 -37
- data/lib/commands/utility_commands.rb +82 -2
- data/lib/generators/automation/automation_generator.rb +0 -7
- data/lib/generators/automation/templates/partials/element.tt +1 -1
- data/lib/generators/automation/templates/partials/initialize_selector.tt +0 -7
- data/lib/generators/automation/templates/partials/url_methods.tt +0 -1
- data/lib/generators/common_generator.rb +12 -0
- data/lib/generators/cucumber/cucumber_generator.rb +36 -0
- data/lib/generators/cucumber/templates/accessibility_feature.tt +5 -0
- data/lib/generators/cucumber/templates/accessibility_steps.tt +21 -0
- data/lib/generators/cucumber/templates/cucumber.tt +8 -1
- data/lib/generators/cucumber/templates/feature.tt +0 -4
- data/lib/generators/cucumber/templates/partials/appium_env.tt +5 -0
- data/lib/generators/cucumber/templates/partials/capybara_env.tt +19 -1
- data/lib/generators/cucumber/templates/partials/driver_world.tt +1 -4
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -35
- data/lib/generators/cucumber/templates/partials/watir_env.tt +20 -1
- data/lib/generators/cucumber/templates/partials/web_steps.tt +6 -12
- data/lib/generators/cucumber/templates/performance_feature.tt +5 -0
- data/lib/generators/cucumber/templates/performance_steps.tt +17 -0
- data/lib/generators/cucumber/templates/visual_feature.tt +5 -0
- data/lib/generators/cucumber/templates/visual_steps.tt +19 -0
- data/lib/generators/generator.rb +38 -7
- data/lib/generators/helper_generator.rb +24 -7
- data/lib/generators/infrastructure/templates/github.tt +1 -1
- data/lib/generators/infrastructure/templates/github_appium.tt +2 -2
- data/lib/generators/infrastructure/templates/gitlab.tt +1 -1
- data/lib/generators/invoke_generators.rb +42 -9
- data/lib/generators/menu_generator.rb +120 -11
- data/lib/generators/minitest/minitest_generator.rb +16 -4
- data/lib/generators/minitest/templates/accessibility_test.tt +26 -0
- data/lib/generators/minitest/templates/performance_test.tt +18 -0
- data/lib/generators/minitest/templates/test.tt +5 -34
- data/lib/generators/minitest/templates/visual_test.tt +23 -0
- data/lib/generators/rspec/rspec_generator.rb +16 -4
- data/lib/generators/rspec/templates/accessibility_spec.tt +25 -0
- data/lib/generators/rspec/templates/performance_spec.tt +18 -0
- data/lib/generators/rspec/templates/spec.tt +5 -35
- data/lib/generators/rspec/templates/visual_spec.tt +20 -0
- data/lib/generators/template_renderer/partial_cache.rb +11 -1
- data/lib/generators/template_renderer/partial_resolver.rb +17 -10
- data/lib/generators/template_renderer.rb +17 -1
- data/lib/generators/templates/common/gemfile.tt +21 -6
- data/lib/generators/templates/common/git_ignore.tt +6 -1
- data/lib/generators/templates/common/partials/mobile_config.tt +5 -1
- data/lib/generators/templates/common/partials/web_config.tt +16 -7
- data/lib/generators/templates/common/rakefile.tt +36 -0
- data/lib/generators/templates/common/read_me.tt +41 -91
- data/lib/generators/templates/common/rspec.tt +3 -0
- data/lib/generators/templates/common/ruby_version.tt +1 -0
- data/lib/generators/templates/helpers/allure_helper.tt +11 -0
- data/lib/generators/templates/helpers/browser_helper.tt +12 -2
- data/lib/generators/templates/helpers/capybara_helper.tt +5 -1
- data/lib/generators/templates/helpers/debug_helper.tt +190 -0
- data/lib/generators/templates/helpers/driver_helper.tt +2 -10
- data/lib/generators/templates/helpers/partials/appium_driver.tt +0 -2
- data/lib/generators/templates/helpers/partials/debug_diagnostics.tt +7 -0
- data/lib/generators/templates/helpers/partials/debug_start.tt +7 -0
- data/lib/generators/templates/helpers/partials/driver_and_options.tt +1 -3
- data/lib/generators/templates/helpers/partials/selenium_driver.tt +8 -7
- data/lib/generators/templates/helpers/partials/video_start.tt +9 -0
- data/lib/generators/templates/helpers/partials/video_stop.tt +4 -0
- data/lib/generators/templates/helpers/performance_helper.tt +57 -0
- data/lib/generators/templates/helpers/spec_helper.tt +57 -8
- data/lib/generators/templates/helpers/test_helper.tt +69 -1
- data/lib/generators/templates/helpers/video_helper.tt +270 -0
- data/lib/generators/templates/helpers/visual_helper.tt +39 -46
- data/lib/llm/client.rb +79 -0
- data/lib/llm/config.rb +57 -0
- data/lib/llm/prompts.rb +84 -0
- data/lib/llm/provider.rb +27 -0
- data/lib/llm/providers/anthropic_provider.rb +43 -0
- data/lib/llm/providers/ollama_provider.rb +56 -0
- data/lib/llm/providers/openai_provider.rb +42 -0
- data/lib/llm/response_parser.rb +67 -0
- data/lib/plugin/plugin.rb +22 -20
- data/lib/plugin/plugin_exposer.rb +16 -38
- data/lib/ruby_raider.rb +47 -12
- data/lib/scaffolding/crud_generator.rb +94 -0
- data/lib/scaffolding/dry_run_presenter.rb +16 -0
- data/lib/scaffolding/name_normalizer.rb +63 -0
- data/lib/scaffolding/page_introspector.rb +45 -0
- data/lib/scaffolding/project_detector.rb +72 -0
- data/lib/scaffolding/scaffold_menu.rb +103 -0
- data/lib/scaffolding/scaffolding.rb +158 -11
- data/lib/scaffolding/templates/component.tt +30 -0
- data/lib/scaffolding/templates/feature.tt +4 -4
- data/lib/scaffolding/templates/helper.tt +15 -1
- data/lib/scaffolding/templates/page_from_url.tt +75 -0
- data/lib/scaffolding/templates/page_object.tt +50 -1
- data/lib/scaffolding/templates/spec.tt +33 -2
- data/lib/scaffolding/templates/spec_from_page.tt +31 -0
- data/lib/scaffolding/templates/spec_from_url.tt +46 -0
- data/lib/scaffolding/templates/steps.tt +17 -5
- data/lib/scaffolding/url_analyzer.rb +179 -0
- data/lib/utilities/desktop_downloader.rb +177 -0
- data/lib/utilities/logo.rb +83 -0
- data/lib/utilities/utilities.rb +53 -20
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/adopter/adopt_menu.rbs +25 -0
- data/sig/adopter/converters/base_converter.rbs +23 -0
- data/sig/adopter/converters/identity_converter.rbs +16 -0
- data/sig/adopter/migration_plan.rbs +34 -0
- data/sig/adopter/migrator.rbs +21 -0
- data/sig/adopter/plan_builder.rbs +38 -0
- data/sig/adopter/project_analyzer.rbs +39 -0
- data/sig/adopter/project_detector.rbs +26 -0
- data/sig/commands/adopt_commands.rbs +8 -0
- data/sig/commands/loaded_commands.rbs +5 -0
- data/sig/commands/plugin_commands.rbs +9 -0
- data/sig/commands/scaffolding_commands.rbs +28 -0
- data/sig/commands/utility_commands.rbs +21 -0
- data/sig/generators/automation/automation_generator.rbs +20 -0
- data/sig/generators/common_generator.rbs +12 -0
- data/sig/generators/cucumber/cucumber_generator.rbs +16 -0
- data/sig/generators/generator.rbs +40 -0
- data/sig/generators/helper_generator.rbs +18 -0
- data/sig/generators/infrastructure/github_generator.rbs +5 -0
- data/sig/generators/infrastructure/gitlab_generator.rbs +4 -0
- data/sig/generators/invoke_generators.rbs +10 -0
- data/sig/generators/menu_generator.rbs +29 -0
- data/sig/generators/minitest/minitest_generator.rbs +8 -0
- data/sig/generators/rspec/rspec_generator.rbs +8 -0
- data/sig/generators/template_renderer/partial_cache.rbs +20 -0
- data/sig/generators/template_renderer/partial_resolver.rbs +20 -0
- data/sig/generators/template_renderer/template_error.rbs +19 -0
- data/sig/generators/template_renderer.rbs +10 -0
- data/sig/llm/client.rbs +15 -0
- data/sig/llm/config.rbs +20 -0
- data/sig/llm/prompts.rbs +8 -0
- data/sig/llm/provider.rbs +12 -0
- data/sig/llm/providers/anthropic_provider.rbs +16 -0
- data/sig/llm/providers/ollama_provider.rbs +18 -0
- data/sig/llm/providers/openai_provider.rbs +16 -0
- data/sig/llm/response_parser.rbs +13 -0
- data/sig/plugin/plugin.rbs +24 -0
- data/sig/plugin/plugin_exposer.rbs +20 -0
- data/sig/ruby_raider.rbs +15 -0
- data/sig/scaffolding/crud_generator.rbs +16 -0
- data/sig/scaffolding/dry_run_presenter.rbs +4 -0
- data/sig/scaffolding/name_normalizer.rbs +17 -0
- data/sig/scaffolding/page_introspector.rbs +14 -0
- data/sig/scaffolding/project_detector.rbs +14 -0
- data/sig/scaffolding/scaffold_menu.rbs +18 -0
- data/sig/scaffolding/scaffolding.rbs +55 -0
- data/sig/scaffolding/url_analyzer.rbs +28 -0
- data/sig/utilities/desktop_downloader.rbs +23 -0
- data/sig/utilities/logger.rbs +13 -0
- data/sig/utilities/logo.rbs +16 -0
- data/sig/utilities/utilities.rbs +30 -0
- data/sig/vendor/thor.rbs +34 -0
- data/sig/vendor/tty_prompt.rbs +15 -0
- data/spec/adopter/adopt_menu_spec.rb +12 -12
- data/spec/adopter/migration_plan_spec.rb +1 -1
- data/spec/adopter/migrator_spec.rb +2 -2
- data/spec/adopter/project_detector_spec.rb +1 -1
- data/spec/commands/raider_commands_spec.rb +129 -0
- data/spec/generators/generator_spec.rb +23 -0
- data/spec/integration/commands/scaffolding_commands_spec.rb +1 -1
- data/spec/integration/commands/utility_commands_spec.rb +23 -3
- data/spec/integration/content/ci_content_spec.rb +119 -0
- data/spec/integration/content/common_content_spec.rb +288 -0
- data/spec/integration/content/config_content_spec.rb +175 -0
- data/spec/integration/content/content_helper.rb +32 -0
- data/spec/integration/content/gemfile_content_spec.rb +209 -0
- data/spec/integration/content/helper_content_spec.rb +485 -0
- data/spec/integration/content/page_content_spec.rb +259 -0
- data/spec/integration/content/reporter_content_spec.rb +236 -0
- data/spec/integration/content/skip_flags_content_spec.rb +206 -0
- data/spec/integration/content/syntax_validation_spec.rb +30 -0
- data/spec/integration/content/test_content_spec.rb +266 -0
- data/spec/integration/end_to_end_features_spec.rb +690 -0
- data/spec/integration/end_to_end_spec.rb +52 -16
- data/spec/integration/generators/automation_generator_spec.rb +0 -12
- data/spec/integration/generators/axe_addon_spec.rb +150 -0
- data/spec/integration/generators/common_generator_spec.rb +12 -13
- data/spec/integration/generators/config_features_spec.rb +155 -0
- data/spec/integration/generators/debug_helper_spec.rb +68 -0
- data/spec/integration/generators/helpers_generator_spec.rb +0 -12
- data/spec/integration/generators/lighthouse_addon_spec.rb +132 -0
- data/spec/integration/generators/minitest_generator_spec.rb +0 -6
- data/spec/integration/generators/reporter_spec.rb +159 -0
- data/spec/integration/generators/skip_flags_spec.rb +134 -0
- data/spec/integration/generators/visual_addon_spec.rb +148 -0
- data/spec/integration/settings_helper.rb +0 -3
- data/spec/integration/spec_helper.rb +30 -13
- data/spec/llm/client_spec.rb +79 -0
- data/spec/llm/config_spec.rb +92 -0
- data/spec/llm/prompts_spec.rb +49 -0
- data/spec/llm/response_parser_spec.rb +92 -0
- data/spec/menus/adopter_adopt_menu_spec.rb +97 -0
- data/spec/menus/menu_generator_spec.rb +263 -0
- data/spec/scaffolding/name_normalizer_spec.rb +113 -0
- data/spec/scaffolding/page_introspector_spec.rb +82 -0
- data/spec/scaffolding/scaffold_project_detector_spec.rb +104 -0
- data/spec/scaffolding/scaffolding_features_spec.rb +311 -0
- data/spec/scaffolding/url_analyzer_spec.rb +110 -0
- data/spec/system/adopt_matrix_spec.rb +537 -0
- data/spec/system/adopt_spec.rb +225 -0
- data/spec/system/support/system_test_helper.rb +0 -2
- data/spec/utilities/desktop_downloader_spec.rb +92 -0
- metadata +150 -5
- data/lib/generators/automation/templates/visual_options.tt +0 -16
- data/lib/generators/templates/helpers/partials/axe_driver.tt +0 -10
- data/lib/generators/templates/helpers/visual_spec_helper.tt +0 -35
data/lib/llm/client.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require_relative 'config'
|
|
5
|
+
|
|
6
|
+
module Llm
|
|
7
|
+
# Facade for LLM completion with retry logic and graceful fallback.
|
|
8
|
+
# Returns nil on any failure — callers should always have a non-AI fallback.
|
|
9
|
+
module Client
|
|
10
|
+
MAX_RETRIES = 3
|
|
11
|
+
BASE_DELAY = 1
|
|
12
|
+
|
|
13
|
+
# Only retry on transient network errors, not configuration or API errors
|
|
14
|
+
RETRYABLE_ERRORS = [
|
|
15
|
+
Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
|
|
16
|
+
Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def complete(prompt, system_prompt: nil)
|
|
21
|
+
provider = build_provider
|
|
22
|
+
return nil unless provider
|
|
23
|
+
|
|
24
|
+
attempt = 0
|
|
25
|
+
begin
|
|
26
|
+
attempt += 1
|
|
27
|
+
provider.complete(prompt, system_prompt:)
|
|
28
|
+
rescue *RETRYABLE_ERRORS => e
|
|
29
|
+
if attempt < MAX_RETRIES
|
|
30
|
+
sleep(BASE_DELAY * (2**(attempt - 1)))
|
|
31
|
+
retry
|
|
32
|
+
end
|
|
33
|
+
warn "[Ruby Raider] LLM failed after #{MAX_RETRIES} attempts: #{e.message}"
|
|
34
|
+
nil
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
warn "[Ruby Raider] LLM error: #{e.message}"
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def available?
|
|
42
|
+
config = Config.new
|
|
43
|
+
return false unless config.configured?
|
|
44
|
+
|
|
45
|
+
provider = config.build_provider
|
|
46
|
+
provider&.available? || false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def status
|
|
50
|
+
config = Config.new
|
|
51
|
+
return { configured: false, provider: nil } unless config.configured?
|
|
52
|
+
|
|
53
|
+
provider = config.build_provider
|
|
54
|
+
{
|
|
55
|
+
configured: true,
|
|
56
|
+
provider: config.provider_name,
|
|
57
|
+
model: config.model,
|
|
58
|
+
available: provider&.available? || false
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def build_provider
|
|
65
|
+
config = Config.new
|
|
66
|
+
unless config.configured?
|
|
67
|
+
warn '[Ruby Raider] No LLM configured. Use: raider u llm ollama'
|
|
68
|
+
return nil
|
|
69
|
+
end
|
|
70
|
+
provider = config.build_provider
|
|
71
|
+
unless provider&.available?
|
|
72
|
+
warn "[Ruby Raider] LLM provider '#{config.provider_name}' is not available"
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
provider
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/llm/config.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Llm
|
|
6
|
+
# Reads LLM configuration from env vars and config/config.yml
|
|
7
|
+
# Env vars take precedence over config file values.
|
|
8
|
+
class Config
|
|
9
|
+
PROVIDERS = %w[openai anthropic ollama].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :provider_name, :api_key, :model, :url
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@provider_name = env('RUBY_RAIDER_LLM_PROVIDER') || config_value('llm_provider')
|
|
15
|
+
@api_key = env('RUBY_RAIDER_LLM_API_KEY') || config_value('llm_api_key')
|
|
16
|
+
@model = env('RUBY_RAIDER_LLM_MODEL') || config_value('llm_model')
|
|
17
|
+
@url = env('RUBY_RAIDER_LLM_URL') || config_value('llm_url')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def configured?
|
|
21
|
+
return false unless @provider_name && PROVIDERS.include?(@provider_name)
|
|
22
|
+
return true if @provider_name == 'ollama'
|
|
23
|
+
|
|
24
|
+
!@api_key.nil? && !@api_key.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_provider
|
|
28
|
+
return nil unless configured?
|
|
29
|
+
|
|
30
|
+
case @provider_name
|
|
31
|
+
when 'openai'
|
|
32
|
+
require_relative 'providers/openai_provider'
|
|
33
|
+
Providers::OpenaiProvider.new(api_key: @api_key, model: @model)
|
|
34
|
+
when 'anthropic'
|
|
35
|
+
require_relative 'providers/anthropic_provider'
|
|
36
|
+
Providers::AnthropicProvider.new(api_key: @api_key, model: @model)
|
|
37
|
+
when 'ollama'
|
|
38
|
+
require_relative 'providers/ollama_provider'
|
|
39
|
+
Providers::OllamaProvider.new(model: @model, url: @url)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def env(key)
|
|
46
|
+
value = ENV.fetch(key, nil)
|
|
47
|
+
value&.strip&.empty? ? nil : value&.strip
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def config_value(key)
|
|
51
|
+
return nil unless File.exist?('config/config.yml')
|
|
52
|
+
|
|
53
|
+
data = YAML.load_file('config/config.yml')
|
|
54
|
+
data&.dig(key)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/llm/prompts.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llm
|
|
4
|
+
# Centralized prompt templates for LLM-enhanced code generation.
|
|
5
|
+
# All prompts instruct the model to return JSON matching specific schemas.
|
|
6
|
+
module Prompts
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def analyze_page(html, url)
|
|
10
|
+
<<~PROMPT
|
|
11
|
+
Analyze this HTML page and extract all interactive elements that would be useful for a page object model in UI test automation.
|
|
12
|
+
|
|
13
|
+
URL: #{url}
|
|
14
|
+
|
|
15
|
+
HTML:
|
|
16
|
+
#{html[0..8000]}
|
|
17
|
+
|
|
18
|
+
Return a JSON object with this exact structure:
|
|
19
|
+
{
|
|
20
|
+
"elements": [
|
|
21
|
+
{
|
|
22
|
+
"name": "descriptive_snake_case_name",
|
|
23
|
+
"type": "input|select|textarea|button|submit|link",
|
|
24
|
+
"locator": {"type": "id|name|css|xpath", "value": "the_locator"},
|
|
25
|
+
"purpose": "brief description of what this element does",
|
|
26
|
+
"input_type": "text|email|password|etc (only for inputs)",
|
|
27
|
+
"text": "visible text (only for buttons/links)"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Guidelines:
|
|
33
|
+
- Use semantic, descriptive names (e.g., "email_field" not "input_1", "submit_login" not "button_1")
|
|
34
|
+
- Prefer ID locators, then name, then CSS, then XPath
|
|
35
|
+
- Skip hidden inputs and purely decorative elements
|
|
36
|
+
- Include up to 5 important links
|
|
37
|
+
- The "purpose" field should be a brief, clear description
|
|
38
|
+
|
|
39
|
+
Return ONLY the JSON object, no other text.
|
|
40
|
+
PROMPT
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def generate_test_scenarios(class_name, methods, automation, framework)
|
|
44
|
+
methods_desc = methods.map do |m|
|
|
45
|
+
params = m[:params].empty? ? '' : "(#{m[:params].join(', ')})"
|
|
46
|
+
" - #{m[:name]}#{params}"
|
|
47
|
+
end.join("\n")
|
|
48
|
+
|
|
49
|
+
<<~PROMPT
|
|
50
|
+
Generate meaningful test scenarios for this page object class used in UI test automation.
|
|
51
|
+
|
|
52
|
+
Class: #{class_name}
|
|
53
|
+
Automation: #{automation}
|
|
54
|
+
Framework: #{framework}
|
|
55
|
+
Public methods:
|
|
56
|
+
#{methods_desc}
|
|
57
|
+
|
|
58
|
+
Return a JSON object with this exact structure:
|
|
59
|
+
{
|
|
60
|
+
"scenarios": [
|
|
61
|
+
{
|
|
62
|
+
"method": "method_name",
|
|
63
|
+
"description": "human-readable test description",
|
|
64
|
+
"assertion_hint": "what to assert after calling this method"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Guidelines:
|
|
70
|
+
- Generate one scenario per method
|
|
71
|
+
- Descriptions should read naturally (e.g., "fills in the login form with valid credentials")
|
|
72
|
+
- Assertion hints should be specific (e.g., "expect page to redirect to dashboard")
|
|
73
|
+
- Consider the class name for context about what page this is
|
|
74
|
+
|
|
75
|
+
Return ONLY the JSON object, no other text.
|
|
76
|
+
PROMPT
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def system_prompt
|
|
80
|
+
'You are a UI test automation expert. You generate clean, idiomatic Ruby code ' \
|
|
81
|
+
'for page object models and test specs. Always return valid JSON when asked for JSON.'
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/llm/provider.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llm
|
|
4
|
+
# Abstract base class for LLM providers
|
|
5
|
+
class Provider
|
|
6
|
+
def complete(prompt, system_prompt: nil)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#complete must be implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def available?
|
|
11
|
+
raise NotImplementedError, "#{self.class}#available? must be implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name
|
|
15
|
+
self.class.name.split('::').last.sub('Provider', '').downcase
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def build_messages(prompt, system_prompt)
|
|
21
|
+
messages = []
|
|
22
|
+
messages << { role: 'system', content: system_prompt } if system_prompt
|
|
23
|
+
messages << { role: 'user', content: prompt }
|
|
24
|
+
messages
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../provider'
|
|
4
|
+
|
|
5
|
+
module Llm
|
|
6
|
+
module Providers
|
|
7
|
+
class AnthropicProvider < Provider
|
|
8
|
+
DEFAULT_MODEL = 'claude-sonnet-4-20250514'
|
|
9
|
+
|
|
10
|
+
def initialize(api_key:, model: nil)
|
|
11
|
+
super()
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
@model = model || DEFAULT_MODEL
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def complete(prompt, system_prompt: nil)
|
|
17
|
+
client = build_client
|
|
18
|
+
params = { model: @model, max_tokens: 4096, messages: [{ role: 'user', content: prompt }] }
|
|
19
|
+
params[:system] = system_prompt if system_prompt
|
|
20
|
+
response = client.messages(parameters: params)
|
|
21
|
+
response.dig('content', 0, 'text')
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
warn "[Ruby Raider] Anthropic error: #{e.message}"
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def available?
|
|
28
|
+
require 'anthropic'
|
|
29
|
+
!@api_key.nil? && !@api_key.empty?
|
|
30
|
+
rescue LoadError
|
|
31
|
+
warn '[Ruby Raider] Install anthropic gem: gem install anthropic-rb'
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_client
|
|
38
|
+
require 'anthropic'
|
|
39
|
+
Anthropic::Client.new(access_token: @api_key)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative '../provider'
|
|
7
|
+
|
|
8
|
+
module Llm
|
|
9
|
+
module Providers
|
|
10
|
+
class OllamaProvider < Provider
|
|
11
|
+
DEFAULT_MODEL = 'llama3.2'
|
|
12
|
+
DEFAULT_URL = 'http://localhost:11434'
|
|
13
|
+
TIMEOUT = 60
|
|
14
|
+
|
|
15
|
+
def initialize(model: nil, url: nil)
|
|
16
|
+
super()
|
|
17
|
+
@model = model || DEFAULT_MODEL
|
|
18
|
+
@base_url = url || DEFAULT_URL
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def complete(prompt, system_prompt: nil)
|
|
22
|
+
uri = URI("#{@base_url}/api/generate")
|
|
23
|
+
body = { model: @model, prompt:, stream: false }
|
|
24
|
+
body[:system] = system_prompt if system_prompt
|
|
25
|
+
|
|
26
|
+
response = post_request(uri, body)
|
|
27
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
28
|
+
|
|
29
|
+
JSON.parse(response.body)['response']
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
warn "[Ruby Raider] Ollama error: #{e.message}"
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def available?
|
|
36
|
+
uri = URI("#{@base_url}/api/tags")
|
|
37
|
+
response = Net::HTTP.get_response(uri)
|
|
38
|
+
response.is_a?(Net::HTTPSuccess)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
warn '[Ruby Raider] Ollama not reachable. Start it with: ollama serve'
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def post_request(uri, body)
|
|
47
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
48
|
+
http.read_timeout = TIMEOUT
|
|
49
|
+
http.open_timeout = 10
|
|
50
|
+
request = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
|
|
51
|
+
request.body = JSON.generate(body)
|
|
52
|
+
http.request(request)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../provider'
|
|
4
|
+
|
|
5
|
+
module Llm
|
|
6
|
+
module Providers
|
|
7
|
+
class OpenaiProvider < Provider
|
|
8
|
+
DEFAULT_MODEL = 'gpt-4o-mini'
|
|
9
|
+
|
|
10
|
+
def initialize(api_key:, model: nil)
|
|
11
|
+
super()
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
@model = model || DEFAULT_MODEL
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def complete(prompt, system_prompt: nil)
|
|
17
|
+
client = build_client
|
|
18
|
+
messages = build_messages(prompt, system_prompt)
|
|
19
|
+
response = client.chat(parameters: { model: @model, messages:, temperature: 0.2 })
|
|
20
|
+
response.dig('choices', 0, 'message', 'content')
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
warn "[Ruby Raider] OpenAI error: #{e.message}"
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def available?
|
|
27
|
+
require 'openai'
|
|
28
|
+
!@api_key.nil? && !@api_key.empty?
|
|
29
|
+
rescue LoadError
|
|
30
|
+
warn '[Ruby Raider] Install ruby-openai gem: gem install ruby-openai'
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_client
|
|
37
|
+
require 'openai'
|
|
38
|
+
OpenAI::Client.new(access_token: @api_key)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Llm
|
|
6
|
+
# Extracts JSON from LLM responses, handling markdown wrapping and malformed output.
|
|
7
|
+
# Returns nil on parse failure — callers should fall back to non-AI path.
|
|
8
|
+
module ResponseParser
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def parse_json(response)
|
|
12
|
+
return nil if response.nil? || response.strip.empty?
|
|
13
|
+
|
|
14
|
+
json_str = extract_json(response)
|
|
15
|
+
result = JSON.parse(json_str, symbolize_names: true)
|
|
16
|
+
result.is_a?(Hash) ? result : nil
|
|
17
|
+
rescue JSON::ParserError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def extract_elements(response)
|
|
22
|
+
parsed = parse_json(response)
|
|
23
|
+
return nil unless parsed && parsed[:elements].is_a?(Array)
|
|
24
|
+
|
|
25
|
+
parsed[:elements].map { |el| normalize_element(el) }.compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def extract_scenarios(response)
|
|
29
|
+
parsed = parse_json(response)
|
|
30
|
+
return nil unless parsed && parsed[:scenarios].is_a?(Array)
|
|
31
|
+
|
|
32
|
+
parsed[:scenarios]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_element(element)
|
|
36
|
+
return nil unless element[:name] && element[:type] && element[:locator]
|
|
37
|
+
|
|
38
|
+
locator = element[:locator]
|
|
39
|
+
locator = { type: locator[:type]&.to_sym, value: locator[:value] } if locator.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
name: element[:name].to_s.gsub(/[^a-z0-9_]/i, '_').downcase,
|
|
43
|
+
type: element[:type].to_sym,
|
|
44
|
+
locator:,
|
|
45
|
+
purpose: element[:purpose],
|
|
46
|
+
input_type: element[:input_type],
|
|
47
|
+
text: element[:text]
|
|
48
|
+
}.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def extract_json(text)
|
|
52
|
+
# Try raw JSON first
|
|
53
|
+
stripped = text.strip
|
|
54
|
+
return stripped if stripped.start_with?('{')
|
|
55
|
+
|
|
56
|
+
# Try markdown code block
|
|
57
|
+
match = text.match(/```(?:json)?\s*\n?(.*?)\n?\s*```/m)
|
|
58
|
+
return match[1].strip if match
|
|
59
|
+
|
|
60
|
+
# Try finding first { ... } block
|
|
61
|
+
brace_match = text.match(/(\{.*\})/m)
|
|
62
|
+
return brace_match[1] if brace_match
|
|
63
|
+
|
|
64
|
+
text
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/plugin/plugin.rb
CHANGED
|
@@ -13,6 +13,7 @@ module RubyRaider
|
|
|
13
13
|
|
|
14
14
|
pp "Adding #{plugin_name}..."
|
|
15
15
|
add_plugin_to_gemfile(plugin_name)
|
|
16
|
+
invalidate_gemfile_cache
|
|
16
17
|
system('bundle install')
|
|
17
18
|
PluginExposer.expose_commands(plugin_name)
|
|
18
19
|
pp "The plugin #{plugin_name} is added"
|
|
@@ -24,6 +25,7 @@ module RubyRaider
|
|
|
24
25
|
|
|
25
26
|
pp "Deleting #{plugin_name}..."
|
|
26
27
|
remove_plugin_from_gemfile(plugin_name)
|
|
28
|
+
invalidate_gemfile_cache
|
|
27
29
|
PluginExposer.remove_command(plugin_name)
|
|
28
30
|
system('bundle install')
|
|
29
31
|
pp "The plugin #{plugin_name} is deleted"
|
|
@@ -32,8 +34,10 @@ module RubyRaider
|
|
|
32
34
|
def installed_plugins
|
|
33
35
|
return gemfile_guard unless File.exist?('Gemfile')
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
cached_gemfile_lines.filter_map do |line|
|
|
38
|
+
stripped = line.sub('gem ', '').strip.delete("'")
|
|
39
|
+
stripped if plugins.include?(stripped)
|
|
40
|
+
end
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def installed?(plugin_name)
|
|
@@ -54,12 +58,25 @@ module RubyRaider
|
|
|
54
58
|
|
|
55
59
|
private
|
|
56
60
|
|
|
61
|
+
# Read Gemfile once and cache until explicitly invalidated
|
|
62
|
+
def cached_gemfile_lines
|
|
63
|
+
@cached_gemfile_lines ||= File.readlines('Gemfile')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def invalidate_gemfile_cache
|
|
67
|
+
@cached_gemfile_lines = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
57
70
|
def add_plugin_to_gemfile(plugin_name)
|
|
58
71
|
return gemfile_guard unless File.exist?('Gemfile')
|
|
59
72
|
|
|
73
|
+
lines = cached_gemfile_lines
|
|
74
|
+
has_comment = lines.any? { |l| l.include?('Ruby Raider Plugins') }
|
|
75
|
+
has_plugin = lines.any? { |l| l.include?(plugin_name) }
|
|
76
|
+
|
|
60
77
|
File.open('Gemfile', 'a') do |file|
|
|
61
|
-
file.puts "\n# Ruby Raider Plugins\n" unless
|
|
62
|
-
file.puts "gem '#{plugin_name}'" unless
|
|
78
|
+
file.puts "\n# Ruby Raider Plugins\n" unless has_comment
|
|
79
|
+
file.puts "gem '#{plugin_name}'" unless has_plugin
|
|
63
80
|
end
|
|
64
81
|
end
|
|
65
82
|
|
|
@@ -72,27 +89,12 @@ module RubyRaider
|
|
|
72
89
|
installed_plugins.count == 1
|
|
73
90
|
end
|
|
74
91
|
|
|
75
|
-
def read_gemfile
|
|
76
|
-
return gemfile_guard unless File.exist?('Gemfile')
|
|
77
|
-
|
|
78
|
-
File.readlines('Gemfile')
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def comment_present?
|
|
82
|
-
read_gemfile.grep(/Ruby Raider Plugins/).any?
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def plugin_present?(plugin_name)
|
|
86
|
-
read_gemfile.grep(/#{plugin_name}/).any?
|
|
87
|
-
end
|
|
88
|
-
|
|
89
92
|
def remove_plugins_and_comments(plugin_name)
|
|
90
|
-
|
|
93
|
+
cached_gemfile_lines.reject do |line|
|
|
91
94
|
line.include?(plugin_name) || line.include?('Ruby Raider Plugins') && last_plugin?
|
|
92
95
|
end
|
|
93
96
|
end
|
|
94
97
|
|
|
95
|
-
# :reek:NestedIterators { enabled: false }
|
|
96
98
|
def update_gemfile(output_lines)
|
|
97
99
|
return gemfile_guard unless File.exist?('Gemfile')
|
|
98
100
|
|
|
@@ -6,33 +6,38 @@ module RubyRaider
|
|
|
6
6
|
module PluginExposer
|
|
7
7
|
class << self
|
|
8
8
|
FILE_PATH = File.expand_path('../commands/loaded_commands.rb', __dir__)
|
|
9
|
-
# :reek:NestedIterators { enabled: false }
|
|
10
9
|
def expose_commands(plugin_name)
|
|
11
|
-
return pp 'The plugin is already installed' if plugin_present?(plugin_name)
|
|
12
|
-
|
|
13
10
|
commands = read_loaded_commands
|
|
11
|
+
return pp 'The plugin is already installed' if commands.any? { |l| l.include?(plugin_name) }
|
|
12
|
+
|
|
13
|
+
has_subcommands = commands.any? { |l| l.include?('subcommand') }
|
|
14
14
|
|
|
15
15
|
File.open(FILE_PATH, 'w') do |file|
|
|
16
16
|
commands.each do |line|
|
|
17
17
|
file.puts line
|
|
18
|
-
file.puts
|
|
19
|
-
|
|
18
|
+
file.puts "require '#{plugin_name}'" if line.include?("require 'thor'")
|
|
19
|
+
if line.strip == 'class LoadedCommands < Thor' && !has_subcommands
|
|
20
|
+
file.puts formatted_command_without_space(plugin_name)
|
|
21
|
+
elsif line.strip == 'class LoadedCommands < Thor' && has_subcommands
|
|
22
|
+
file.puts formatted_command_with_space(plugin_name)
|
|
23
|
+
end
|
|
20
24
|
end
|
|
21
25
|
end
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def remove_command(plugin_name)
|
|
25
|
-
|
|
29
|
+
commands = read_loaded_commands
|
|
30
|
+
return pp 'The plugin is not installed' unless commands.any? { |l| l.include?(plugin_name) }
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
output_lines = commands.reject { |line| line.include?(plugin_name) }
|
|
33
|
+
|
|
34
|
+
File.open(FILE_PATH, 'w') do |file|
|
|
35
|
+
output_lines.each { |line| file.puts line }
|
|
36
|
+
end
|
|
28
37
|
end
|
|
29
38
|
|
|
30
39
|
private
|
|
31
40
|
|
|
32
|
-
def any_commands?
|
|
33
|
-
read_loaded_commands.any? { |line| line.include?('subcommand') }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
41
|
def read_loaded_commands
|
|
37
42
|
File.readlines(FILE_PATH)
|
|
38
43
|
end
|
|
@@ -45,33 +50,6 @@ module RubyRaider
|
|
|
45
50
|
def formatted_command_with_space(plugin_name)
|
|
46
51
|
"\n#{formatted_command_without_space(plugin_name)}"
|
|
47
52
|
end
|
|
48
|
-
|
|
49
|
-
def plugin_present?(plugin_name)
|
|
50
|
-
read_loaded_commands.grep(/#{plugin_name}/).any?
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def select_command_formatting(plugin_name, line)
|
|
54
|
-
if line.strip == 'class LoadedCommands < Thor' && !any_commands?
|
|
55
|
-
formatted_command_without_space(plugin_name)
|
|
56
|
-
elsif any_commands?
|
|
57
|
-
formatted_command_with_space(plugin_name)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def require_plugin(plugin_name, line)
|
|
62
|
-
"require '#{plugin_name}'" if line.include?("require 'thor'") && !plugin_present?(plugin_name)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# :reek:NestedIterators { enabled: false }
|
|
66
|
-
def delete_plugin_command(plugin_name)
|
|
67
|
-
output_lines = read_loaded_commands.reject do |line|
|
|
68
|
-
line.include?(plugin_name) if plugin_present?(plugin_name)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
File.open(FILE_PATH, 'w') do |file|
|
|
72
|
-
output_lines.each { |line| file.puts line }
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
53
|
end
|
|
76
54
|
end
|
|
77
55
|
end
|