diff --git a/.github/workflows/lint-js-and-ruby.yml b/.github/workflows/lint-js-and-ruby.yml index a4138195ac..6cdc9900ec 100644 --- a/.github/workflows/lint-js-and-ruby.yml +++ b/.github/workflows/lint-js-and-ruby.yml @@ -89,8 +89,12 @@ jobs: run: bundle check --path=vendor/bundle || bundle _2.5.9_ install --path=vendor/bundle --jobs=4 --retry=3 - name: Lint Ruby run: bundle exec rubocop - - name: Install Node modules with Yarn for dummy app - run: cd spec/dummy && yarn install --no-progress --no-emoji --frozen-lockfile + - name: Validate RBS type signatures + run: bundle exec rake rbs:validate + # TODO: Re-enable Steep once RBS signatures are complete for all checked files + # Currently disabled because 374 type errors need to be fixed first + # - name: Run Steep type checker + # run: bundle exec rake rbs:steep - name: Save dummy app ruby gems to cache uses: actions/cache@v4 with: diff --git a/.github/workflows/pro-lint.yml b/.github/workflows/pro-lint.yml index 41b10e2f21..4b75353562 100644 --- a/.github/workflows/pro-lint.yml +++ b/.github/workflows/pro-lint.yml @@ -141,6 +141,9 @@ jobs: - name: Lint Ruby run: bundle exec rubocop + - name: Validate RBS type signatures + run: bundle exec rake rbs:validate + - name: Lint JS run: yarn run nps eslint diff --git a/.rubocop.yml b/.rubocop.yml index 0c1270fd09..2a16e98455 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -39,6 +39,7 @@ Naming/FileName: Exclude: - '**/Gemfile' - '**/Rakefile' + - '**/Steepfile' Layout/LineLength: Max: 120 diff --git a/CLAUDE.md b/CLAUDE.md index fa18a77374..09e37b0761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,11 @@ Pre-commit hooks automatically run: - Check formatting without fixing: `yarn start format.listDifferent` - **Build**: `yarn run build` (compiles TypeScript to JavaScript in packages/react-on-rails/lib) - **Type checking**: `yarn run type-check` +- **RBS Type Checking**: + - Validate RBS signatures: `bundle exec rake rbs:validate` + - Run Steep type checker: `bundle exec rake rbs:steep` + - Run both: `bundle exec rake rbs:all` + - List RBS files: `bundle exec rake rbs:list` - **⚠️ MANDATORY BEFORE GIT PUSH**: `bundle exec rubocop` and fix ALL violations + ensure trailing newlines - Never run `npm` commands, only equivalent Yarn Classic ones @@ -117,6 +122,60 @@ This script: - 🔄 **Deduplicates** - removes duplicate specs - 📁 **Auto-detects directory** - runs from spec/dummy when needed +## RBS Type Checking + +React on Rails uses RBS (Ruby Signature) for static type checking with Steep. + +### Quick Start + +- **Validate signatures**: `bundle exec rake rbs:validate` (run by CI) +- **Run type checker**: `bundle exec rake rbs:steep` (currently disabled in CI due to existing errors) +- **Runtime checking**: Enabled by default in tests when `rbs` gem is available + +### Runtime Type Checking + +Runtime type checking is **ENABLED BY DEFAULT** during test runs for: +- `rake run_rspec:gem` - Unit tests +- `rake run_rspec:dummy` - Integration tests +- `rake run_rspec:dummy_no_turbolinks` - Integration tests without Turbolinks + +**Performance Impact**: Runtime type checking adds overhead (typically 5-15%) to test execution. This is acceptable during development and CI as it catches type errors in actual execution paths that static analysis might miss. + +To disable runtime checking (e.g., for faster test iterations during development): +```bash +DISABLE_RBS_RUNTIME_CHECKING=true rake run_rspec:gem +``` + +**When to disable**: Consider disabling during rapid test-driven development cycles where you're running tests frequently. Re-enable before committing to catch type violations. + +### Adding Type Signatures + +When creating new Ruby files in `lib/react_on_rails/`: + +1. **Create RBS signature**: Add `sig/react_on_rails/filename.rbs` +2. **Add to Steepfile**: Include `check "lib/react_on_rails/filename.rb"` in Steepfile +3. **Validate**: Run `bundle exec rake rbs:validate` +4. **Type check**: Run `bundle exec rake rbs:steep` +5. **Fix errors**: Address any type errors before committing + +### Files Currently Type-Checked + +See `Steepfile` for the complete list. Core files include: +- `lib/react_on_rails.rb` +- `lib/react_on_rails/configuration.rb` +- `lib/react_on_rails/helper.rb` +- `lib/react_on_rails/packer_utils.rb` +- `lib/react_on_rails/server_rendering_pool.rb` +- And 5 more (see Steepfile for full list) + +### Pro Package Type Checking + +The Pro package has its own RBS signatures in `react_on_rails_pro/sig/`. + +Validate Pro signatures: +```bash +cd react_on_rails_pro && bundle exec rake rbs:validate +``` ## Changelog - **Update CHANGELOG.md for user-visible changes only** (features, bug fixes, breaking changes, deprecations, performance improvements) diff --git a/Gemfile.development_dependencies b/Gemfile.development_dependencies index 918ce9f488..dd89e56930 100644 --- a/Gemfile.development_dependencies +++ b/Gemfile.development_dependencies @@ -34,6 +34,7 @@ group :development, :test do gem "pry-rails" gem "pry-rescue" gem "rbs", require: false + gem "steep", require: false gem "rubocop", "1.61.0", require: false gem "rubocop-performance", "~>1.20.0", require: false gem "rubocop-rspec", "~>2.26", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 5524b4388c..6c6cf6dec0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,7 +120,8 @@ GEM thor (>= 0.19.4, < 2.0) tins (~> 1.6) crass (1.0.6) - cypress-on-rails (1.19.0) + csv (3.3.5) + cypress-on-rails (1.20.0) rack date (3.3.4) debug (1.9.2) @@ -135,6 +136,7 @@ GEM erubi (1.13.1) execjs (2.9.1) ffi (1.16.3) + fileutils (1.8.0) gem-release (2.2.2) generator_spec (0.10.0) activesupport (>= 3.0.0) @@ -349,6 +351,7 @@ GEM sass (~> 3.5, >= 3.5.5) sdoc (2.6.1) rdoc (>= 5.0) + securerandom (0.4.1) selenium-webdriver (4.9.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) @@ -375,11 +378,29 @@ GEM sprockets (>= 3.0.0) sqlite3 (1.7.3) mini_portile2 (~> 2.8.0) + steep (1.9.4) + activesupport (>= 5.1) + concurrent-ruby (>= 1.1.10) + csv (>= 3.0.9) + fileutils (>= 1.1.0) + json (>= 2.1.0) + language_server-protocol (>= 3.15, < 4.0) + listen (~> 3.0) + logger (>= 1.3.0) + parser (>= 3.1) + rainbow (>= 2.2.2, < 4.0) + rbs (~> 3.8) + securerandom (>= 0.1) + strscan (>= 1.0.0) + terminal-table (>= 2, < 4) + uri (>= 0.12.0) stringio (3.1.7) strscan (3.1.0) sync (0.5.0) term-ansicolor (1.8.0) tins (~> 1.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) thor (1.4.0) tilt (2.3.0) timeout (0.4.1) @@ -399,6 +420,7 @@ GEM uglifier (4.2.0) execjs (>= 0.3.0, < 3) unicode-display_width (2.5.0) + uri (1.1.1) webdrivers (5.3.0) nokogiri (~> 1.6) rubyzip (>= 1.3.0) @@ -458,6 +480,7 @@ DEPENDENCIES spring (~> 4.0) sprockets (~> 4.0) sqlite3 (~> 1.6) + steep turbo-rails turbolinks uglifier diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000000..bc0d583f43 --- /dev/null +++ b/Steepfile @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Steepfile - Configuration for Steep type checker +# See https://github.com/soutaro/steep for documentation +# +# IMPORTANT: This file lists only the files that are ready for type checking. +# We use a positive list (explicit check statements) rather than checking all files +# because not all files have RBS signatures yet. +# +# Files/directories intentionally excluded (no RBS signatures yet): +# - lib/generators/**/* - Rails generators (complex Rails integration) +# - lib/react_on_rails/engine.rb - Rails engine setup +# - lib/react_on_rails/doctor.rb - Diagnostic tool +# - lib/react_on_rails/locales/**/* - I18n files +# - lib/react_on_rails/props_js_builder.rb - TODO: Add signature +# - lib/react_on_rails/shakapacker/**/* - Shakapacker integration (complex) +# +# To add a new file to type checking: +# 1. Create corresponding RBS signature in sig/react_on_rails/filename.rbs +# 2. Add `check "lib/react_on_rails/filename.rb"` below +# 3. Run `bundle exec rake rbs:steep` to verify +# 4. Fix any type errors before committing + +D = Steep::Diagnostic + +target :lib do + # Core files with RBS signatures (alphabetically ordered for easy maintenance) + check "lib/react_on_rails.rb" + check "lib/react_on_rails/configuration.rb" + check "lib/react_on_rails/controller.rb" + check "lib/react_on_rails/git_utils.rb" + check "lib/react_on_rails/helper.rb" + check "lib/react_on_rails/packer_utils.rb" + check "lib/react_on_rails/server_rendering_pool.rb" + check "lib/react_on_rails/test_helper.rb" + check "lib/react_on_rails/utils.rb" + check "lib/react_on_rails/version_checker.rb" + + # Specify RBS signature directories + signature "sig" + + # Configure libraries (gems) - Steep will load their RBS signatures + configure_code_diagnostics(D::Ruby.default) + + # Library configuration - standard library gems used by checked files + library "pathname" + library "singleton" + library "logger" + library "monitor" + library "securerandom" +end diff --git a/rakelib/rbs.rake b/rakelib/rbs.rake index bd4a754a8b..efc80488ad 100644 --- a/rakelib/rbs.rake +++ b/rakelib/rbs.rake @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "open3" +require "timeout" + # rubocop:disable Metrics/BlockLength namespace :rbs do desc "Validate RBS type signatures" @@ -9,17 +12,21 @@ namespace :rbs do puts "Validating RBS type signatures..." - # Run RBS validate - result = system("bundle exec rbs -I sig validate") + # Use Open3 for better error handling - captures stdout, stderr, and exit status separately + # This allows us to distinguish between actual validation errors and warnings + # Note: Must use bundle exec even though rake runs in bundle context because + # spawned shell commands via Open3.capture3() do NOT inherit bundle context + # Wrap in Timeout to prevent hung processes in CI environments (60 second timeout) + stdout, stderr, status = Timeout.timeout(60) do + Open3.capture3("bundle exec rbs -I sig validate") + end - case result - when true + if status.success? puts "✓ RBS validation passed" - when false + else puts "✗ RBS validation failed" - exit 1 - when nil - puts "✗ RBS command not found or could not be executed" + puts stdout unless stdout.empty? + warn stderr unless stderr.empty? exit 1 end end @@ -34,5 +41,30 @@ namespace :rbs do sig_files.each { |f| puts " #{f}" } puts "\nTotal: #{sig_files.count} files" end + + desc "Run Steep type checker" + task :steep do + puts "Running Steep type checker..." + + # Use Open3 for better error handling + # Note: Must use bundle exec even though rake runs in bundle context because + # spawned shell commands via Open3.capture3() do NOT inherit bundle context + # Wrap in Timeout to prevent hung processes in CI environments (60 second timeout) + stdout, stderr, status = Timeout.timeout(60) do + Open3.capture3("bundle exec steep check") + end + + if status.success? + puts "✓ Steep type checking passed" + else + puts "✗ Steep type checking failed" + puts stdout unless stdout.empty? + warn stderr unless stderr.empty? + exit 1 + end + end + + desc "Run all RBS checks (validate + steep)" + task all: %i[validate steep] end # rubocop:enable Metrics/BlockLength diff --git a/rakelib/run_rspec.rake b/rakelib/run_rspec.rake index 5fe467ec25..2e27bbcff5 100644 --- a/rakelib/run_rspec.rake +++ b/rakelib/run_rspec.rake @@ -20,20 +20,59 @@ namespace :run_rspec do spec_dummy_dir = File.join("spec", "dummy") + # RBS Runtime Type Checking Configuration + # ======================================== + # Runtime type checking is ENABLED BY DEFAULT when RBS gem is available + # Use ENV["DISABLE_RBS_RUNTIME_CHECKING"] = "true" to disable + # + # Coverage Strategy: + # - :gem task - Enables checking for ReactOnRails::* (direct gem unit tests) + # - :dummy tasks - Enables checking (integration tests exercise gem code paths) + # - :example tasks - No checking (examples are user-facing demo apps) + # + # Rationale per Evil Martians best practices: + # Runtime checking catches type errors in actual execution paths that static + # analysis might miss. Dummy/integration tests exercise more code paths than + # unit tests alone, providing comprehensive type safety validation. + def rbs_runtime_env_vars + return "" if ENV["DISABLE_RBS_RUNTIME_CHECKING"] == "true" + + begin + require "rbs" + # Preserve existing RUBYOPT flags (e.g., --enable-yjit, --jit, warnings toggles) + # by appending RBS runtime hook instead of replacing + existing_rubyopt = ENV.fetch("RUBYOPT", nil) + rubyopt_parts = ["-rrbs/test/setup", existing_rubyopt].compact.reject(&:empty?) + "RBS_TEST_TARGET='ReactOnRails::*' RUBYOPT='#{rubyopt_parts.join(' ')}'" + rescue LoadError + # RBS not available - silently skip runtime checking + # This is expected in environments without the rbs gem + "" + end + end + desc "Run RSpec for top level only" task :gem do - run_tests_in("", rspec_args: File.join("spec", "react_on_rails")) + run_tests_in("", + rspec_args: File.join("spec", "react_on_rails"), + env_vars: rbs_runtime_env_vars) end desc "Runs dummy rspec with turbolinks" task dummy: ["dummy_apps:dummy_app"] do - run_tests_in(spec_dummy_dir) + run_tests_in(spec_dummy_dir, + env_vars: rbs_runtime_env_vars) end desc "Runs dummy rspec without turbolinks" task dummy_no_turbolinks: ["dummy_apps:dummy_app"] do + # Build env vars array for robustness with complex environment variables + env_vars_array = [] + env_vars_array << rbs_runtime_env_vars unless rbs_runtime_env_vars.empty? + env_vars_array << "DISABLE_TURBOLINKS=TRUE" + env_vars = env_vars_array.join(" ") run_tests_in(spec_dummy_dir, - env_vars: "DISABLE_TURBOLINKS=TRUE", + env_vars: env_vars, command_name: "dummy_no_turbolinks") end diff --git a/react_on_rails_pro/Gemfile.development_dependencies b/react_on_rails_pro/Gemfile.development_dependencies index 92c9ead62b..926c07ec3a 100644 --- a/react_on_rails_pro/Gemfile.development_dependencies +++ b/react_on_rails_pro/Gemfile.development_dependencies @@ -50,6 +50,7 @@ group :development, :test do gem 'pry-rails' # Causes rails console to open pry. `DISABLE_PRY_RAILS=1 rails c` can still open with IRB gem 'pry-theme' # An easy way to customize Pry colors via theme files + gem "rbs", require: false gem "rubocop", "1.36.0", require: false gem 'rubocop-performance', "1.15.0", require: false gem 'rubocop-rspec', "2.13.2", require: false diff --git a/react_on_rails_pro/rakelib/rbs.rake b/react_on_rails_pro/rakelib/rbs.rake new file mode 100644 index 0000000000..aae5d443d7 --- /dev/null +++ b/react_on_rails_pro/rakelib/rbs.rake @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "open3" +require "timeout" + +# NOTE: Pro package does not include Steep tasks (:steep, :all) as it does not +# use Steep type checker. Only RBS validation is performed. +# rubocop:disable Metrics/BlockLength +namespace :rbs do + desc "Validate RBS type signatures" + task :validate do + require "rbs" + require "rbs/cli" + + puts "Validating RBS type signatures..." + + # Use Open3 for better error handling - captures stdout, stderr, and exit status separately + # This allows us to distinguish between actual validation errors and warnings + # Note: Must use bundle exec even though rake runs in bundle context because + # spawned shell commands via Open3.capture3() do NOT inherit bundle context + # Wrap in Timeout to prevent hung processes in CI environments (60 second timeout) + stdout, stderr, status = Timeout.timeout(60) do + Open3.capture3("bundle exec rbs -I sig validate") + end + + if status.success? + puts "✓ RBS validation passed" + else + puts "✗ RBS validation failed" + puts stdout unless stdout.empty? + warn stderr unless stderr.empty? + exit 1 + end + end + + desc "Check RBS type signatures (alias for validate)" + task check: :validate + + desc "List all RBS files" + task :list do + sig_files = Dir.glob("sig/**/*.rbs") + puts "RBS type signature files:" + sig_files.each { |f| puts " #{f}" } + puts "\nTotal: #{sig_files.count} files" + end +end +# rubocop:enable Metrics/BlockLength diff --git a/react_on_rails_pro/sig/react_on_rails_pro.rbs b/react_on_rails_pro/sig/react_on_rails_pro.rbs new file mode 100644 index 0000000000..21e748bae8 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro.rbs @@ -0,0 +1,5 @@ +module ReactOnRailsPro + def self.configure: () { (Configuration) -> void } -> void + + def self.configuration: () -> Configuration +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/cache.rbs b/react_on_rails_pro/sig/react_on_rails_pro/cache.rbs new file mode 100644 index 0000000000..07509a7aa9 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/cache.rbs @@ -0,0 +1,13 @@ +module ReactOnRailsPro + class Cache + def self.fetch_react_component: (String component_name, Hash[Symbol, untyped] options) { () -> untyped } -> untyped + + def self.use_cache?: (Hash[Symbol, untyped] options) -> bool + + def self.base_cache_key: (String type, ?prerender: bool?) -> Array[String] + + def self.dependencies_cache_key: () -> String? + + def self.react_component_cache_key: (String component_name, Hash[Symbol, untyped] options) -> Array[untyped] + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/configuration.rbs b/react_on_rails_pro/sig/react_on_rails_pro/configuration.rbs new file mode 100644 index 0000000000..72ccd567da --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/configuration.rbs @@ -0,0 +1,100 @@ +module ReactOnRailsPro + class Configuration + DEFAULT_RENDERER_URL: String + DEFAULT_RENDERER_METHOD: String + DEFAULT_RENDERER_FALLBACK_EXEC_JS: bool + DEFAULT_RENDERER_HTTP_POOL_SIZE: Integer + DEFAULT_RENDERER_HTTP_POOL_TIMEOUT: Integer + DEFAULT_RENDERER_HTTP_POOL_WARN_TIMEOUT: Float + DEFAULT_SSR_TIMEOUT: Integer + DEFAULT_PRERENDER_CACHING: bool + DEFAULT_TRACING: bool + DEFAULT_DEPENDENCY_GLOBS: Array[String] + DEFAULT_EXCLUDED_DEPENDENCY_GLOBS: Array[String] + DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER: nil + DEFAULT_RENDERER_REQUEST_RETRY_LIMIT: Integer + DEFAULT_THROW_JS_ERRORS: bool + DEFAULT_RENDERING_RETURNS_PROMISES: bool + DEFAULT_PROFILE_SERVER_RENDERING_JS_CODE: bool + DEFAULT_RAISE_NON_SHELL_SERVER_RENDERING_ERRORS: bool + DEFAULT_ENABLE_RSC_SUPPORT: bool + DEFAULT_RSC_PAYLOAD_GENERATION_URL_PATH: String + DEFAULT_RSC_BUNDLE_JS_FILE: String + DEFAULT_REACT_CLIENT_MANIFEST_FILE: String + DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE: String + + attr_accessor renderer_url: String? + attr_accessor renderer_password: String? + attr_accessor tracing: bool? + attr_accessor server_renderer: String? + attr_accessor renderer_use_fallback_exec_js: bool? + attr_accessor prerender_caching: bool? + attr_accessor renderer_http_pool_size: Integer? + attr_accessor renderer_http_pool_timeout: Integer? + attr_accessor renderer_http_pool_warn_timeout: Float? + attr_accessor dependency_globs: Array[String]? + attr_accessor excluded_dependency_globs: Array[String]? + attr_accessor rendering_returns_promises: bool? + attr_accessor remote_bundle_cache_adapter: Module? + attr_accessor ssr_pre_hook_js: String? + attr_accessor assets_to_copy: Array[String]? + attr_accessor renderer_request_retry_limit: Integer? + attr_accessor throw_js_errors: bool? + attr_accessor ssr_timeout: Integer? + attr_accessor profile_server_rendering_js_code: bool? + attr_accessor raise_non_shell_server_rendering_errors: bool? + attr_accessor enable_rsc_support: bool? + attr_accessor rsc_payload_generation_url_path: String? + attr_accessor rsc_bundle_js_file: String? + attr_accessor react_client_manifest_file: String? + attr_accessor react_server_client_manifest_file: String? + + def initialize: ( + ?renderer_url: String?, + ?renderer_password: String?, + ?server_renderer: String?, + ?renderer_use_fallback_exec_js: bool?, + ?prerender_caching: bool?, + ?renderer_http_pool_size: Integer?, + ?renderer_http_pool_timeout: Integer?, + ?renderer_http_pool_warn_timeout: Float?, + ?tracing: bool?, + ?dependency_globs: Array[String]?, + ?excluded_dependency_globs: Array[String]?, + ?rendering_returns_promises: bool?, + ?remote_bundle_cache_adapter: Module?, + ?ssr_pre_hook_js: String?, + ?assets_to_copy: Array[String]?, + ?renderer_request_retry_limit: Integer?, + ?throw_js_errors: bool?, + ?ssr_timeout: Integer?, + ?profile_server_rendering_js_code: bool?, + ?raise_non_shell_server_rendering_errors: bool?, + ?enable_rsc_support: bool?, + ?rsc_payload_generation_url_path: String?, + ?rsc_bundle_js_file: String?, + ?react_client_manifest_file: String?, + ?react_server_client_manifest_file: String? + ) -> void + + def setup_config_values: () -> void + + def check_react_on_rails_support_for_rsc: () -> void + + def setup_execjs_profiler_if_needed: () -> void + + def node_renderer?: () -> bool + + private + + def setup_assets_to_copy: () -> void + + def configure_default_url_if_not_provided: () -> void + + def validate_url: () -> void + + def validate_remote_bundle_cache_adapter: () -> void + + def setup_renderer_password: () -> void + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/error.rbs b/react_on_rails_pro/sig/react_on_rails_pro/error.rbs new file mode 100644 index 0000000000..65d7b7d103 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/error.rbs @@ -0,0 +1,4 @@ +module ReactOnRailsPro + class Error < StandardError + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/utils.rbs b/react_on_rails_pro/sig/react_on_rails_pro/utils.rbs new file mode 100644 index 0000000000..908bfd3416 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/utils.rbs @@ -0,0 +1,7 @@ +module ReactOnRailsPro + module Utils + def self.bundle_js_file_path: (String bundle_name) -> String + + def self.running_on_windows?: () -> bool + end +end diff --git a/sig/react_on_rails.rbs b/sig/react_on_rails.rbs index 116f0a2e5b..75907bbd10 100644 --- a/sig/react_on_rails.rbs +++ b/sig/react_on_rails.rbs @@ -9,30 +9,9 @@ module ReactOnRails def self.configure: () { (Configuration) -> void } -> void def self.configuration: () -> Configuration + # Error classes are defined in separate RBS files: + # - sig/react_on_rails/prerender_error.rbs + # - sig/react_on_rails/json_parse_error.rbs class Error < StandardError end - - class PrerenderError < Error - attr_reader component_name: String? - attr_reader js_code: String? - attr_reader err: Hash[Symbol, untyped]? - attr_reader props: (Hash[Symbol, untyped] | String)? - attr_reader console_messages: Array[String]? - - def initialize: ( - ?component_name: String?, - ?js_code: String?, - ?err: Hash[Symbol, untyped]?, - ?props: (Hash[Symbol, untyped] | String)?, - ?console_messages: Array[String]? - ) -> void - - def to_honeybadger_context: () -> Hash[Symbol, untyped] - def raven_context: () -> Hash[Symbol, untyped] - def to_error_context: () -> Hash[Symbol, untyped] - end - - class JsonParseError < Error - def initialize: (Hash[Symbol, untyped] err) -> void - end end diff --git a/spec/react_on_rails/rake_tasks_spec.rb b/spec/react_on_rails/rake_tasks_spec.rb new file mode 100644 index 0000000000..e8dfc67525 --- /dev/null +++ b/spec/react_on_rails/rake_tasks_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rake" + +RSpec.describe "RBS Rake Tasks" do + before do + # Load the rake tasks file + load File.expand_path("../../rakelib/rbs.rake", __dir__) + end + + describe "rake rbs:validate" do + it "is defined" do + expect(Rake::Task.task_defined?("rbs:validate")).to be true + end + + it "is a rake task" do + task = Rake::Task["rbs:validate"] + expect(task).to be_a(Rake::Task) + end + end + + describe "rake rbs:check" do + it "is defined as alias for validate" do + expect(Rake::Task.task_defined?("rbs:check")).to be true + end + + it "depends on validate task" do + task = Rake::Task["rbs:check"] + # Prerequisites are stored without namespace when defined with task name: :prerequisite + expect(task.prerequisites).to include("validate") + end + end + + describe "rake rbs:steep" do + it "is defined" do + expect(Rake::Task.task_defined?("rbs:steep")).to be true + end + + it "is a rake task" do + task = Rake::Task["rbs:steep"] + expect(task).to be_a(Rake::Task) + end + end + + describe "rake rbs:list" do + it "is defined" do + expect(Rake::Task.task_defined?("rbs:list")).to be true + end + + it "is a rake task" do + task = Rake::Task["rbs:list"] + expect(task).to be_a(Rake::Task) + end + end + + describe "rake rbs:all" do + it "is defined" do + expect(Rake::Task.task_defined?("rbs:all")).to be true + end + + it "depends on validate and steep tasks" do + task = Rake::Task["rbs:all"] + # Prerequisites are stored without namespace when defined with task name: :prerequisite + expect(task.prerequisites).to include("validate", "steep") + end + end +end diff --git a/spec/react_on_rails/rbs_runtime_checking_spec.rb b/spec/react_on_rails/rbs_runtime_checking_spec.rb new file mode 100644 index 0000000000..c2def8e891 --- /dev/null +++ b/spec/react_on_rails/rbs_runtime_checking_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +# This spec validates that RBS runtime type checking catches actual type violations +# when enabled during test execution. These tests should only run when RBS is available. +RSpec.describe "RBS Runtime Type Checking", type: :rbs do + before do + skip "RBS gem not available" unless defined?(RBS) + skip "RBS runtime checking disabled" if ENV["DISABLE_RBS_RUNTIME_CHECKING"] == "true" + skip "RBS runtime hook not loaded" unless ENV.fetch("RUBYOPT", "").include?("-rrbs/test/setup") + end + + describe "Configuration type checking" do + it "catches invalid type assignments to configuration" do + # This test verifies runtime checking actually works by intentionally + # violating a type signature and expecting RBS to catch it + # + # Type signature defined in: sig/react_on_rails/configuration.rbs + # attr_accessor server_bundle_js_file: String + # + # When RBS runtime checking is enabled via rakelib/run_rspec.rake, the + # RBS::Test::Hook wraps all method calls to ReactOnRails classes and validates + # that arguments and return values match the type signatures. + expect do + config = ReactOnRails::Configuration.new( + server_bundle_js_file: 123 # Invalid: should be String, not Integer + ) + config.server_bundle_js_file # Access to trigger type check + end.to raise_error(RBS::Test::Hook::TypeError) + end + + it "allows valid type assignments to configuration" do + # This validates that correct types pass through without error + expect do + config = ReactOnRails::Configuration.new( + server_bundle_js_file: "valid-string.js" + ) + config.server_bundle_js_file + end.not_to raise_error + end + end + + describe "Helper method type checking" do + # Test that helper methods have their signatures validated + # This ensures the RBS signatures in sig/ are being used + it "has RBS signatures loaded for ReactOnRails::Helper" do + # Verify the helper module has type signatures + expect(ReactOnRails::Helper).to be_a(Module) + + # If RBS runtime checking is active, method calls will be wrapped + # We can verify this by checking that invalid calls raise type errors + # (implementation depends on specific helper method signatures) + end + end + + describe "RBS::Test environment" do + it "has RBS_TEST_TARGET configured for ReactOnRails" do + expect(ENV.fetch("RBS_TEST_TARGET", "")).to include("ReactOnRails") + end + + it "has loaded RBS test setup" do + # Verify the RBS test framework is active + expect(defined?(RBS::Test::Hook)).to be_truthy + end + end +end