ruby_raider 1.1.4 → 2.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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/integration.yml +4 -6
  3. data/.github/workflows/reek.yml +6 -5
  4. data/.github/workflows/release.yml +175 -0
  5. data/.github/workflows/rubocop.yml +7 -6
  6. data/.github/workflows/system_tests.yml +83 -0
  7. data/.gitignore +1 -1
  8. data/.rubocop.yml +24 -0
  9. data/README.md +3 -1
  10. data/RELEASE.md +412 -0
  11. data/RELEASE_QUICK_GUIDE.md +77 -0
  12. data/bin/release +186 -0
  13. data/lib/adopter/adopt_menu.rb +150 -0
  14. data/lib/adopter/converters/base_converter.rb +85 -0
  15. data/lib/adopter/converters/identity_converter.rb +56 -0
  16. data/lib/adopter/migration_plan.rb +75 -0
  17. data/lib/adopter/migrator.rb +96 -0
  18. data/lib/adopter/plan_builder.rb +278 -0
  19. data/lib/adopter/project_analyzer.rb +256 -0
  20. data/lib/adopter/project_detector.rb +159 -0
  21. data/lib/commands/adopt_commands.rb +43 -0
  22. data/lib/generators/automation/templates/account.tt +9 -5
  23. data/lib/generators/automation/templates/appium_caps.tt +60 -6
  24. data/lib/generators/automation/templates/home.tt +4 -4
  25. data/lib/generators/automation/templates/login.tt +61 -4
  26. data/lib/generators/automation/templates/page.tt +13 -7
  27. data/lib/generators/automation/templates/partials/home_page_selector.tt +4 -4
  28. data/lib/generators/automation/templates/partials/initialize_selector.tt +3 -1
  29. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +4 -4
  30. data/lib/generators/automation/templates/partials/visit_method.tt +11 -1
  31. data/lib/generators/automation/templates/pdp.tt +1 -1
  32. data/lib/generators/cucumber/templates/env.tt +6 -4
  33. data/lib/generators/cucumber/templates/partials/capybara_env.tt +20 -0
  34. data/lib/generators/cucumber/templates/partials/capybara_world.tt +6 -0
  35. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +2 -2
  36. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -3
  37. data/lib/generators/cucumber/templates/steps.tt +2 -2
  38. data/lib/generators/cucumber/templates/world.tt +5 -3
  39. data/lib/generators/generator.rb +14 -2
  40. data/lib/generators/helper_generator.rb +16 -3
  41. data/lib/generators/infrastructure/github_generator.rb +6 -0
  42. data/lib/generators/infrastructure/templates/github.tt +11 -7
  43. data/lib/generators/infrastructure/templates/github_appium.tt +108 -0
  44. data/lib/generators/infrastructure/templates/gitlab.tt +5 -2
  45. data/lib/generators/invoke_generators.rb +1 -0
  46. data/lib/generators/menu_generator.rb +2 -0
  47. data/lib/generators/minitest/minitest_generator.rb +23 -0
  48. data/lib/generators/minitest/templates/test.tt +93 -0
  49. data/lib/generators/rspec/templates/spec.tt +12 -10
  50. data/lib/generators/template_renderer/partial_cache.rb +116 -0
  51. data/lib/generators/template_renderer/partial_resolver.rb +103 -0
  52. data/lib/generators/template_renderer/template_error.rb +50 -0
  53. data/lib/generators/template_renderer.rb +90 -0
  54. data/lib/generators/templates/common/config.tt +2 -2
  55. data/lib/generators/templates/common/gemfile.tt +15 -3
  56. data/lib/generators/templates/common/partials/web_config.tt +1 -1
  57. data/lib/generators/templates/common/read_me.tt +3 -1
  58. data/lib/generators/templates/helpers/allure_helper.tt +2 -2
  59. data/lib/generators/templates/helpers/browser_helper.tt +1 -0
  60. data/lib/generators/templates/helpers/capybara_helper.tt +28 -0
  61. data/lib/generators/templates/helpers/driver_helper.tt +1 -1
  62. data/lib/generators/templates/helpers/partials/allure_imports.tt +3 -1
  63. data/lib/generators/templates/helpers/partials/allure_requirements.tt +3 -1
  64. data/lib/generators/templates/helpers/partials/appium_driver.tt +46 -0
  65. data/lib/generators/templates/helpers/partials/axe_driver.tt +10 -0
  66. data/lib/generators/templates/helpers/partials/browserstack_config.tt +13 -0
  67. data/lib/generators/templates/helpers/partials/driver_and_options.tt +6 -114
  68. data/lib/generators/templates/helpers/partials/quit_driver.tt +3 -1
  69. data/lib/generators/templates/helpers/partials/screenshot.tt +3 -1
  70. data/lib/generators/templates/helpers/partials/selenium_driver.tt +25 -0
  71. data/lib/generators/templates/helpers/spec_helper.tt +17 -4
  72. data/lib/generators/templates/helpers/test_helper.tt +26 -0
  73. data/lib/generators/templates/helpers/visual_spec_helper.tt +1 -1
  74. data/lib/ruby_raider.rb +5 -0
  75. data/lib/version +1 -1
  76. data/spec/adopter/adopt_menu_spec.rb +176 -0
  77. data/spec/adopter/converters/identity_converter_spec.rb +145 -0
  78. data/spec/adopter/migration_plan_spec.rb +113 -0
  79. data/spec/adopter/migrator_spec.rb +277 -0
  80. data/spec/adopter/plan_builder_spec.rb +298 -0
  81. data/spec/adopter/project_analyzer_spec.rb +337 -0
  82. data/spec/adopter/project_detector_spec.rb +295 -0
  83. data/spec/generators/fixtures/templates/test.tt +1 -0
  84. data/spec/generators/fixtures/templates/test_partial.tt +1 -0
  85. data/spec/generators/template_renderer_spec.rb +298 -0
  86. data/spec/integration/commands/scaffolding_commands_spec.rb +2 -2
  87. data/spec/integration/commands/utility_commands_spec.rb +2 -2
  88. data/spec/integration/end_to_end_spec.rb +325 -0
  89. data/spec/integration/generators/automation_generator_spec.rb +11 -11
  90. data/spec/integration/generators/common_generator_spec.rb +40 -40
  91. data/spec/integration/generators/cucumber_generator_spec.rb +7 -7
  92. data/spec/integration/generators/github_generator_spec.rb +8 -8
  93. data/spec/integration/generators/gitlab_generator_spec.rb +8 -8
  94. data/spec/integration/generators/helpers_generator_spec.rb +73 -35
  95. data/spec/integration/generators/minitest_generator_spec.rb +70 -0
  96. data/spec/integration/generators/rspec_generator_spec.rb +7 -7
  97. data/spec/integration/settings_helper.rb +1 -1
  98. data/spec/integration/spec_helper.rb +20 -2
  99. data/spec/system/capybara_spec.rb +42 -0
  100. data/spec/system/selenium_spec.rb +19 -17
  101. data/spec/system/support/system_test_helper.rb +35 -0
  102. data/spec/system/watir_spec.rb +19 -17
  103. metadata +46 -16
  104. data/.github/workflows/push_gem.yml +0 -37
  105. data/.github/workflows/selenium.yml +0 -22
  106. data/.github/workflows/watir.yml +0 -22
  107. data/lib/generators/automation/templates/partials/android_caps.tt +0 -17
  108. data/lib/generators/automation/templates/partials/cross_platform_caps.tt +0 -25
  109. data/lib/generators/automation/templates/partials/ios_caps.tt +0 -18
  110. data/lib/generators/automation/templates/partials/selenium_account.tt +0 -9
  111. data/lib/generators/automation/templates/partials/selenium_login.tt +0 -34
  112. data/lib/generators/automation/templates/partials/watir_account.tt +0 -7
  113. data/lib/generators/automation/templates/partials/watir_login.tt +0 -32
@@ -0,0 +1,108 @@
1
+ name: Appium Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ <%- if ios? || cross_platform? -%>
11
+ ios-tests:
12
+ runs-on: macos-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: ruby/setup-ruby@v1
16
+ with:
17
+ ruby-version: 3.4.0
18
+ bundler-cache: true
19
+
20
+ - name: Download iOS app
21
+ uses: dawidd6/action-download-artifact@v6
22
+ with:
23
+ github_token: ${{ secrets.GITHUB_TOKEN }}
24
+ repo: RaiderHQ/raider_test_app
25
+ workflow: build-ios.yml
26
+ name: raider-test-app-ios
27
+ path: apps/
28
+
29
+ - name: Unzip iOS app
30
+ run: |
31
+ cd apps
32
+ unzip RaiderTestApp.app.zip
33
+
34
+ - name: Start iOS Simulator
35
+ uses: futureware-tech/simulator-action@v3
36
+ with:
37
+ model: 'iPhone 15'
38
+ os_version: '17'
39
+
40
+ - name: Install and start Appium
41
+ run: |
42
+ npm install -g appium
43
+ appium driver install xcuitest
44
+ appium &
45
+ sleep 5
46
+
47
+ - name: Run tests
48
+ <%- if cucumber? -%>
49
+ run: bundle exec cucumber features/ --format pretty
50
+ <%- elsif minitest? -%>
51
+ run: bundle exec ruby -Itest test/test_pdp_page.rb
52
+ <%- else -%>
53
+ run: bundle exec rspec spec/ --format documentation
54
+ <%- end -%>
55
+ env:
56
+ APP_PATH: ${{ github.workspace }}/apps/RaiderTestApp.app
57
+ <%- end -%>
58
+
59
+ <%- if android? || cross_platform? -%>
60
+ android-tests:
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - uses: actions/checkout@v4
64
+ - uses: ruby/setup-ruby@v1
65
+ with:
66
+ ruby-version: 3.4.0
67
+ bundler-cache: true
68
+
69
+ - name: Download Android app
70
+ uses: dawidd6/action-download-artifact@v6
71
+ with:
72
+ github_token: ${{ secrets.GITHUB_TOKEN }}
73
+ repo: RaiderHQ/raider_test_app
74
+ workflow: build-android.yml
75
+ name: raider-test-app-android
76
+ path: apps/
77
+
78
+ - name: Set up JDK 17
79
+ uses: actions/setup-java@v4
80
+ with:
81
+ java-version: '17'
82
+ distribution: 'temurin'
83
+
84
+ - name: Install and start Appium
85
+ run: |
86
+ npm install -g appium
87
+ appium driver install uiautomator2
88
+ appium &
89
+ sleep 5
90
+
91
+ - name: Run Android Emulator and Tests
92
+ uses: reactivecircus/android-emulator-runner@v2
93
+ with:
94
+ api-level: 34
95
+ target: google_apis
96
+ arch: x86_64
97
+ profile: pixel_8
98
+ script: |
99
+ <%- if cucumber? -%>
100
+ bundle exec cucumber features/ --format pretty
101
+ <%- elsif minitest? -%>
102
+ bundle exec ruby -Itest test/test_pdp_page.rb
103
+ <%- else -%>
104
+ bundle exec rspec spec/ --format documentation
105
+ <%- end -%>
106
+ env:
107
+ APP_PATH: ${{ github.workspace }}/apps/app-release.apk
108
+ <%- end -%>
@@ -5,6 +5,7 @@ stages:
5
5
 
6
6
  variables:
7
7
  RUBY_VERSION: "3.4.0"
8
+ HEADLESS: "true"
8
9
 
9
10
  setup_ruby:
10
11
  stage: setup
@@ -18,9 +19,11 @@ setup_ruby:
18
19
  run_tests:
19
20
  stage: test
20
21
  image: ruby:${RUBY_VERSION}
21
- script:
22
+ before_script:
23
+ - apt-get update -qq && apt-get install -y -qq chromium chromium-driver
22
24
  - mkdir -p allure-results
23
- - <%- if framework == 'cucumber' -%> cucumber features --format pretty <%- else -%> bundle exec rspec spec --format documentation <%- end%>
25
+ script:
26
+ - <%- if framework == 'cucumber' -%>bundle exec cucumber features --format pretty<%- elsif minitest? -%>bundle exec ruby -Itest test/test_login_page.rb<%- else -%>bundle exec rspec spec --format documentation<%- end %>
24
27
  artifacts:
25
28
  paths:
26
29
  - allure-results/
@@ -4,6 +4,7 @@ require_relative 'automation/automation_generator'
4
4
  require_relative 'common_generator'
5
5
  require_relative 'cucumber/cucumber_generator'
6
6
  require_relative 'helper_generator'
7
+ require_relative 'minitest/minitest_generator'
7
8
  require_relative 'rspec/rspec_generator'
8
9
 
9
10
  # :reek:FeatureEnvy { enabled: false }
@@ -53,6 +53,7 @@ class MenuGenerator
53
53
  prompt.select('Please select your test framework') do |menu|
54
54
  menu.choice :Cucumber, -> { select_ci_platform('Cucumber', automation) }
55
55
  menu.choice :Rspec, -> { select_ci_platform('Rspec', automation) }
56
+ menu.choice :Minitest, -> { select_ci_platform('Minitest', automation) }
56
57
  menu.choice :Quit, -> { exit }
57
58
  end
58
59
  end
@@ -92,6 +93,7 @@ class MenuGenerator
92
93
 
93
94
  def automation_options(menu)
94
95
  menu.choice :Selenium, -> { choose_test_framework('selenium') }
96
+ menu.choice :Capybara, -> { choose_test_framework('capybara') }
95
97
  menu.choice :Appium, -> { choose_test_framework('appium') }
96
98
  menu.choice :Watir, -> { choose_test_framework('watir') }
97
99
  menu.choice :Applitools, -> { choose_test_framework('applitools') }
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../generator'
4
+
5
+ class MinitestGenerator < Generator
6
+ def generate_login_test
7
+ return unless web? && !visual?
8
+
9
+ template('test.tt', "#{name}/test/test_login_page.rb")
10
+ end
11
+
12
+ def generate_pdp_test
13
+ return unless mobile?
14
+
15
+ template('test.tt', "#{name}/test/test_pdp_page.rb")
16
+ end
17
+
18
+ def generate_account_test
19
+ return unless visual?
20
+
21
+ template('test.tt', "#{name}/test/test_account_page.rb")
22
+ end
23
+ end
@@ -0,0 +1,93 @@
1
+ <%- if selenium_based? || watir? || capybara? -%>
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../helpers/test_helper'
5
+ require_relative '../models/model_factory'
6
+ require_relative '../page_objects/pages/account'
7
+ require_relative '../page_objects/pages/login'
8
+
9
+ <%- if axe? %>
10
+ class TestLogin < Minitest::Test
11
+ def setup
12
+ @login = Login.new(driver)
13
+ @account = Account.new(driver)
14
+ @user = ModelFactory.for('users')['registered user']
15
+ @login.visit
16
+ @login.log_as(@user['username'], @user['password'])
17
+ end
18
+
19
+ def teardown
20
+ driver.quit
21
+ end
22
+
23
+ def test_no_accessibility_errors_on_page
24
+ assert_axe_clean @account.page
25
+ end
26
+
27
+ def test_no_accessibility_errors_on_transaction_history
28
+ assert_axe_clean @account.page, within: '.account-card'
29
+ end
30
+
31
+ def test_no_accessibility_errors_on_heading
32
+ assert_axe_clean @account.page, within: '.page-title'
33
+ end
34
+ end
35
+ <%- elsif selenium_based? || watir? || capybara? -%>
36
+ class TestLogin < Minitest::Test
37
+ def setup
38
+ @user = ModelFactory.for('users')['registered user']
39
+ @login_page = <% if capybara? -%>Login.new<% elsif watir? -%>Login.new(browser)<% else -%>Login.new(driver)<% end %>
40
+ @account_page = <% if capybara? -%>Account.new<% elsif watir? -%>Account.new(browser)<% else -%>Account.new(driver)<% end %>
41
+ end
42
+
43
+ def teardown
44
+ <%= partial('quit_driver', strip: true) %>
45
+ end
46
+
47
+ def test_login_with_right_credentials
48
+ @login_page.<% if capybara? -%>visit_page<% else -%>visit<% end %>
49
+ @login_page.login(@user['username'], @user['password'])
50
+ <%- if visual? -%>
51
+ check_page @account_page
52
+ <%- else -%>
53
+ @account_page.<% if capybara? -%>visit_page<% else -%>visit<% end %>
54
+ assert_equal "Welcome back #{@user['name']}", @account_page.header.customer_name
55
+ <%- end -%>
56
+ end
57
+
58
+ def test_login_with_wrong_credentials
59
+ @login_page.<% if capybara? -%>visit_page<% else -%>visit<% end %>
60
+ @login_page.login(@user['username'], 'wrongPassword')
61
+ <%- if visual? -%>
62
+ check_page @login_page
63
+ <%- else -%>
64
+ @login_page.<% if capybara? -%>visit_page<% else -%>visit<% end %>
65
+ assert_equal 'Login or register', @account_page.header.customer_name
66
+ <%- end -%>
67
+ end
68
+ end
69
+ <%- end -%>
70
+ <%- else -%>
71
+ <% if cross_platform? -%>
72
+ require_relative '../helpers/appium_helper'
73
+ <%- end -%>
74
+ require_relative '../helpers/test_helper'
75
+ require_relative '../page_objects/pages/home'
76
+ require_relative '../page_objects/pages/pdp'
77
+
78
+ class TestPdp < Minitest::Test
79
+ def setup
80
+ @home_page = Home.new(driver)
81
+ @pdp_page = Pdp.new(driver)
82
+ end
83
+
84
+ def teardown
85
+ driver.quit_driver
86
+ end
87
+
88
+ def test_shows_add_to_cart_button
89
+ @home_page.go_to_product_detail
90
+ assert_equal 'Add to Cart', @pdp_page.add_to_cart_text
91
+ end
92
+ end
93
+ <%- end -%>
@@ -1,4 +1,4 @@
1
- <%- if selenium_based? || watir? -%>
1
+ <%- if selenium_based? || watir? || capybara? -%>
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative '../helpers/spec_helper'
@@ -23,26 +23,26 @@ describe 'Login' do
23
23
  end
24
24
 
25
25
  it 'no accessibility errors are present on the transaction history' do
26
- transaction_history = '.dash-tile.dash-tile-balloon.clearfix'
27
- expect(account.page).to be_axe_clean.within transaction_history
26
+ account_details = '.account-card'
27
+ expect(account.page).to be_axe_clean.within account_details
28
28
  end
29
29
 
30
30
  it 'no accessibility errors are present on the heading' do
31
- heading = '.maintext'
31
+ heading = '.page-title'
32
32
  expect(account.page).to be_axe_clean.within heading
33
33
  end
34
34
  end
35
35
  end
36
- <%- elsif selenium_based? || watir? -%>
36
+ <%- elsif selenium_based? || watir? || capybara? -%>
37
37
  describe 'Login' do
38
38
  subject(:header) { account_page.header.customer_name }
39
39
 
40
40
  let(:user) { ModelFactory.for('users')['registered user'] }
41
- let(:login_page) { Login.new(<% if watir? -%>browser<% else -%>driver<% end -%>) }
42
- let(:account_page) { Account.new(<% if watir? -%>browser<% else -%>driver<% end -%>) }
41
+ let(:login_page) { <% if capybara? -%>Login.new<% elsif watir? -%>Login.new(browser)<% else -%>Login.new(driver)<% end -%> }
42
+ let(:account_page) { <% if capybara? -%>Account.new<% elsif watir? -%>Account.new(browser)<% else -%>Account.new(driver)<% end -%> }
43
43
 
44
44
  before do
45
- login_page.visit
45
+ login_page.<% if capybara? -%>visit_page<% else -%>visit<% end %>
46
46
  login_page.login(user['username'], password)
47
47
  end
48
48
 
@@ -53,6 +53,7 @@ describe 'Login' do
53
53
  <%- if visual? -%>
54
54
  check_page account_page
55
55
  <%- else -%>
56
+ account_page.<% if capybara? -%>visit_page<% else -%>visit<% end %>
56
57
  expect(header).to eq "Welcome back #{user['name']}"
57
58
  <%- end -%>
58
59
  end
@@ -65,6 +66,7 @@ describe 'Login' do
65
66
  <%- if visual? -%>
66
67
  check_page login_page
67
68
  <%- else -%>
69
+ login_page.<% if capybara? -%>visit_page<% else -%>visit<% end %>
68
70
  expect(header).to eq 'Login or register'
69
71
  <%- end -%>
70
72
  end
@@ -86,8 +88,8 @@ class PdpSpec
86
88
  let(:pdp_page) { Pdp.new(driver) }
87
89
 
88
90
  it 'shows add to cart button' do
89
- home_page.go_to_backpack_pdp
90
- expect(pdp_page.add_to_cart_text).to eq 'Add To Cart'
91
+ home_page.go_to_product_detail
92
+ expect(pdp_page.add_to_cart_text).to eq 'Add to Cart'
91
93
  end
92
94
  end
93
95
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require_relative 'partial_resolver'
5
+ require_relative 'template_error'
6
+
7
+ module TemplateRenderer
8
+ # Caches compiled ERB template objects with mtime-based invalidation
9
+ #
10
+ # Cache structure:
11
+ # cache_key => { erb: ERB_object, mtime: Time, path: String }
12
+ #
13
+ # Cache keys include trim_mode to support different whitespace handling:
14
+ # "screenshot:-" => ERB object with trim_mode: '-'
15
+ # "screenshot:" => ERB object with no trim_mode
16
+ #
17
+ # Performance: ~10x speedup on cached renders (135ms → ~13.5ms)
18
+ class PartialCache
19
+ def initialize(generator_class)
20
+ @cache = {}
21
+ @resolver = PartialResolver.new(generator_class)
22
+ end
23
+
24
+ # Render a partial with caching
25
+ #
26
+ # @param name [String] Partial name (without .tt extension)
27
+ # @param binding [Binding] Binding context for ERB evaluation
28
+ # @param options [Hash] Rendering options
29
+ # @option options [String, nil] :trim_mode ERB trim mode ('-', '<>', etc.)
30
+ # @return [String] Rendered template content
31
+ # @raise [TemplateNotFoundError] If partial not found
32
+ # @raise [TemplateRenderError] If rendering fails
33
+ def render_partial(name, binding, options = {})
34
+ trim_mode = options[:trim_mode]
35
+ cache_key = build_cache_key(name, trim_mode)
36
+
37
+ # Resolve the partial path
38
+ path = @resolver.resolve(name, binding)
39
+
40
+ # Get from cache or compile
41
+ erb = get_or_compile(cache_key, path, trim_mode)
42
+
43
+ # Render with provided binding
44
+ erb.result(binding)
45
+ rescue Errno::ENOENT => e
46
+ raise TemplateNotFoundError.new(
47
+ "Partial '#{name}' not found",
48
+ partial_name: name,
49
+ searched_paths: @resolver.search_paths(name, binding),
50
+ original_error: e
51
+ )
52
+ rescue TemplateError
53
+ raise
54
+ rescue StandardError => e
55
+ # Catch ERB syntax errors or other rendering issues
56
+ raise TemplateRenderError.new(
57
+ e.message,
58
+ partial_name: name,
59
+ original_error: e
60
+ )
61
+ end
62
+
63
+ # Clear the entire cache (useful for testing)
64
+ def clear
65
+ @cache.clear
66
+ end
67
+
68
+ # Get cache statistics (useful for debugging/monitoring)
69
+ def stats
70
+ {
71
+ size: @cache.size,
72
+ entries: @cache.keys,
73
+ memory_estimate: estimate_cache_size
74
+ }
75
+ end
76
+
77
+ private
78
+
79
+ # Build cache key that includes trim_mode
80
+ def build_cache_key(name, trim_mode)
81
+ "#{name}:#{trim_mode}"
82
+ end
83
+
84
+ # Get cached ERB or compile and cache it
85
+ def get_or_compile(cache_key, path, trim_mode)
86
+ cached = @cache[cache_key]
87
+ current_mtime = File.mtime(path)
88
+
89
+ # Cache miss or stale cache (file modified)
90
+ if cached.nil? || cached[:mtime] < current_mtime
91
+ erb = compile_template(path, trim_mode)
92
+ @cache[cache_key] = {
93
+ erb:,
94
+ mtime: current_mtime,
95
+ path:
96
+ }
97
+ return erb
98
+ end
99
+
100
+ # Cache hit
101
+ cached[:erb]
102
+ end
103
+
104
+ # Compile an ERB template
105
+ def compile_template(path, trim_mode)
106
+ content = File.read(path)
107
+ ERB.new(content, trim_mode:)
108
+ end
109
+
110
+ # Rough estimate of cache memory usage
111
+ # Each entry: ~2-10 KB (ERB object + metadata)
112
+ def estimate_cache_size
113
+ @cache.size * 5 * 1024 # Rough estimate: 5 KB per entry
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'template_error'
4
+
5
+ module TemplateRenderer
6
+ # Resolves partial template paths with context-aware searching
7
+ #
8
+ # Resolution strategy:
9
+ # 1. Try relative to calling template: ./partials/{name}.tt
10
+ # 2. Fall back to all Generator.source_paths searching for partials/{name}.tt
11
+ class PartialResolver
12
+ PARTIAL_EXTENSION = '.tt'
13
+ PARTIALS_DIR = 'partials'
14
+
15
+ def initialize(generator_class)
16
+ @generator_class = generator_class
17
+ end
18
+
19
+ # Resolve a partial name to its absolute file path
20
+ #
21
+ # @param name [String] The partial name (without .tt extension)
22
+ # @param binding [Binding] The binding context from the caller
23
+ # @return [String] Absolute path to the partial file
24
+ # @raise [TemplateNotFoundError] If partial cannot be found
25
+ def resolve(name, binding)
26
+ caller_file = caller_file_from_binding(binding)
27
+ partial_filename = "#{name}#{PARTIAL_EXTENSION}"
28
+
29
+ # Try relative to caller first
30
+ if caller_file
31
+ relative_path = File.join(File.dirname(caller_file), PARTIALS_DIR, partial_filename)
32
+ return File.expand_path(relative_path) if File.exist?(relative_path)
33
+ end
34
+
35
+ # Fall back to searching all source paths
36
+ searched = search_source_paths(partial_filename)
37
+ return searched[:found] if searched[:found]
38
+
39
+ # Not found - raise with helpful error
40
+ raise TemplateNotFoundError.new(
41
+ "Partial '#{name}' not found",
42
+ partial_name: name,
43
+ searched_paths: search_paths(name, binding)
44
+ )
45
+ end
46
+
47
+ # Get all paths that were searched (for error messages)
48
+ def search_paths(name, binding)
49
+ paths = []
50
+ partial_filename = "#{name}#{PARTIAL_EXTENSION}"
51
+
52
+ # Add relative path if available
53
+ caller_file = caller_file_from_binding(binding)
54
+ if caller_file
55
+ relative_path = File.join(File.dirname(caller_file), PARTIALS_DIR, partial_filename)
56
+ paths << relative_path
57
+ end
58
+
59
+ # Add all source path possibilities (including subdirectories)
60
+ source_paths.each do |source_path|
61
+ paths << File.join(source_path, PARTIALS_DIR, partial_filename)
62
+
63
+ # Also include subdirectories (e.g., templates/common/partials/, templates/helpers/partials/)
64
+ Dir.glob(File.join(source_path, '*', PARTIALS_DIR)).each do |subdir|
65
+ paths << File.join(subdir, partial_filename)
66
+ end
67
+ end
68
+
69
+ paths
70
+ end
71
+
72
+ private
73
+
74
+ # Extract the calling template file from binding context
75
+ def caller_file_from_binding(binding)
76
+ binding.eval('__FILE__')
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ # Search all Generator.source_paths for the partial
82
+ # Checks both source_path/partials/ and source_path/*/partials/
83
+ def search_source_paths(partial_filename)
84
+ source_paths.each do |source_path|
85
+ # First try direct partials directory
86
+ full_path = File.join(source_path, PARTIALS_DIR, partial_filename)
87
+ return { found: File.expand_path(full_path), searched: [full_path] } if File.exist?(full_path)
88
+
89
+ # Then try subdirectories (e.g., templates/common/partials/, templates/helpers/partials/)
90
+ Dir.glob(File.join(source_path, '*', PARTIALS_DIR, partial_filename)).each do |path|
91
+ return { found: File.expand_path(path), searched: [path] } if File.exist?(path)
92
+ end
93
+ end
94
+
95
+ { found: nil, searched: [] }
96
+ end
97
+
98
+ # Get all configured source paths from the generator class
99
+ def source_paths
100
+ @source_paths ||= @generator_class.source_paths || []
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TemplateRenderer
4
+ # Base error class for all template-related errors
5
+ class TemplateError < StandardError
6
+ attr_reader :partial_name, :searched_paths, :original_error
7
+
8
+ def initialize(message, partial_name: nil, searched_paths: nil, original_error: nil)
9
+ @partial_name = partial_name
10
+ @searched_paths = searched_paths || []
11
+ @original_error = original_error
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ # Raised when a partial template cannot be found
17
+ class TemplateNotFoundError < TemplateError
18
+ def to_s
19
+ message_parts = ["Partial '#{@partial_name}' not found."]
20
+
21
+ if @searched_paths.any?
22
+ message_parts << "\nSearched in:"
23
+ message_parts.concat(@searched_paths.map { |path| " - #{path}" })
24
+ end
25
+
26
+ message_parts.join("\n")
27
+ end
28
+ end
29
+
30
+ # Raised when a template has syntax errors or rendering fails
31
+ class TemplateRenderError < TemplateError
32
+ def initialize(message, partial_name:, original_error: nil)
33
+ super(message, partial_name:, original_error:)
34
+ end
35
+
36
+ def to_s
37
+ message_parts = ["Error rendering partial '#{@partial_name}': #{super()}"]
38
+
39
+ if @original_error
40
+ message_parts << "\nOriginal error: #{@original_error.class}: #{@original_error.message}"
41
+ if @original_error.backtrace
42
+ message_parts << "\nBacktrace:"
43
+ message_parts.concat(@original_error.backtrace.first(5).map { |line| " #{line}" })
44
+ end
45
+ end
46
+
47
+ message_parts.join("\n")
48
+ end
49
+ end
50
+ end