diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a2fed942..dba4662a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,10 @@ Changes since the last non-beta release. - **Cleaner generated code**: Streamlined templates following modern React and TypeScript best practices - **Improved helper methods**: Added reusable `component_extension` helper for consistent file extension handling +#### Added + +- **`react_on_rails:doctor` rake task**: New diagnostic command to validate React on Rails setup and identify configuration issues. Provides comprehensive checks for environment prerequisites, dependencies, Rails integration, and Webpack configuration. Use `rake react_on_rails:doctor` to diagnose your setup, with optional `VERBOSE=true` for detailed output. + ### [16.0.0] - 2025-09-16 **React on Rails v16 is a major release that modernizes the library with ESM support, removes legacy Webpacker compatibility, and introduces significant performance improvements. This release builds on the foundation of v14 with enhanced RSC (React Server Components) support and streamlined configuration.** diff --git a/CLAUDE.md b/CLAUDE.md index 87f1480804..dd5c622141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## ⚠️ CRITICAL REQUIREMENTS + +**BEFORE EVERY COMMIT/PUSH:** + +1. **ALWAYS run `bundle exec rubocop` and fix ALL violations** +2. **ALWAYS ensure files end with a newline character** +3. **NEVER push without running full lint check first** + +These requirements are non-negotiable. CI will fail if not followed. + ## Development Commands ### Essential Commands @@ -11,15 +21,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Ruby tests: `rake run_rspec` - JavaScript tests: `yarn run test` or `rake js_tests` - All tests: `rake` (default task runs lint and all tests except examples) -- **Linting**: +- **Linting** (MANDATORY BEFORE EVERY COMMIT): + - **REQUIRED**: `bundle exec rubocop` - Must pass with zero offenses - All linters: `rake lint` (runs ESLint and RuboCop) - ESLint only: `yarn run lint` or `rake lint:eslint` - RuboCop only: `rake lint:rubocop` - **Code Formatting**: - - Format code with Prettier: `yarn start format` + - Format code with Prettier: `rake autofix` - Check formatting without fixing: `yarn start format.listDifferent` - **Build**: `yarn run build` (compiles TypeScript to JavaScript in node_package/lib) - **Type checking**: `yarn run type-check` +- **⚠️ MANDATORY BEFORE GIT PUSH**: `bundle exec rubocop` and fix ALL violations + ensure trailing newlines ### Development Setup Commands diff --git a/README.md b/README.md index 62d8cf5d86..3f3835d561 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,16 @@ rails generate react_on_rails:install - `shakapacker.yml` settings - other configuration files +### Troubleshooting Setup Issues + +If you encounter issues during installation or after upgrading, use the doctor command to diagnose your setup: + +```bash +rake react_on_rails:doctor +``` + +The doctor command checks your environment, dependencies, and configuration files to identify potential issues. Use `VERBOSE=true rake react_on_rails:doctor` for detailed output. + For detailed upgrade instructions, see [upgrade guide documentation](docs/guides/upgrading-react-on-rails.md). ## React on Rails Pro diff --git a/lib/generators/react_on_rails/USAGE b/lib/generators/react_on_rails/USAGE new file mode 100644 index 0000000000..1c7a708e66 --- /dev/null +++ b/lib/generators/react_on_rails/USAGE @@ -0,0 +1,65 @@ +Description: + The `react_on_rails:doctor` generator diagnoses your React on Rails setup + and identifies potential configuration issues. It performs comprehensive + checks on your environment, dependencies, and configuration files. + + This command is especially useful for: + • Troubleshooting setup issues + • Verifying installation after running react_on_rails:install + • Ensuring compatibility after upgrades + • Getting help with configuration problems + +Example: + # Basic diagnosis + rails generate react_on_rails:doctor + + # Verbose output showing all checks + rails generate react_on_rails:doctor --verbose + + # Show help + rails generate react_on_rails:doctor --help + +Checks performed: + Environment Prerequisites: + • Node.js installation and version compatibility + • JavaScript package manager availability (npm, yarn, pnpm, bun) + • Git working directory status + + React on Rails Packages: + • React on Rails gem installation + • react-on-rails NPM package installation + • Version synchronization between gem and NPM package + • Shakapacker configuration and installation + + Dependencies: + • React and React DOM installation + • Babel preset configuration + • Required development dependencies + + Rails Integration: + • React on Rails initializer configuration + • Route and controller setup (Hello World example) + • View helper integration + + Webpack Configuration: + • Webpack config file existence and structure + • React on Rails compatibility checks + • Environment-specific configuration validation + + Development Environment: + • JavaScript bundle files + • Procfile.dev for development workflow + • .gitignore configuration for generated files + +Options: + --verbose, -v: Show detailed output for all checks, including successful ones + --fix, -f: Attempt to fix simple issues automatically (planned feature) + +Exit codes: + 0: All checks passed or only warnings found + 1: Critical errors found that prevent React on Rails from working + +For more help: + • Documentation: https://github.com/shakacode/react_on_rails + • Issues: https://github.com/shakacode/react_on_rails/issues + • Discord: https://discord.gg/reactrails diff --git a/lib/react_on_rails/doctor.rb b/lib/react_on_rails/doctor.rb new file mode 100644 index 0000000000..54ed06dbec --- /dev/null +++ b/lib/react_on_rails/doctor.rb @@ -0,0 +1,1149 @@ +# frozen_string_literal: true + +require "json" +require_relative "utils" +require_relative "system_checker" + +begin + require "rainbow" +rescue LoadError + # Fallback if Rainbow is not available - define Kernel-level Rainbow method + # rubocop:disable Naming/MethodName + def Rainbow(text) + SimpleColorWrapper.new(text) + end + # rubocop:enable Naming/MethodName + + class SimpleColorWrapper + def initialize(text) + @text = text + end + + def method_missing(_method, *_args) + self + end + + def respond_to_missing?(_method, _include_private = false) + true + end + + def to_s + @text + end + end +end + +module ReactOnRails + # rubocop:disable Metrics/ClassLength, Metrics/AbcSize + class Doctor + MESSAGE_COLORS = { + error: :red, + warning: :yellow, + success: :green, + info: :blue + }.freeze + + def initialize(verbose: false, fix: false) + @verbose = verbose + @fix = fix + @checker = SystemChecker.new + end + + def run_diagnosis + print_header + run_all_checks + print_summary + print_recommendations if should_show_recommendations? + + exit_with_status + end + + private + + attr_reader :verbose, :fix, :checker + + def print_header + puts Rainbow("\n#{'=' * 80}").cyan + puts Rainbow("🩺 REACT ON RAILS DOCTOR").cyan.bold + puts Rainbow("Diagnosing your React on Rails setup...").cyan + puts Rainbow("=" * 80).cyan + puts + print_doctor_feature_info + puts + end + + def print_doctor_feature_info + puts Rainbow("ℹ️ Doctor Feature Information:").blue + puts " • This diagnostic tool is available in React on Rails v16.0.0+" + puts " • For older versions, upgrade your gem to access this feature" + puts " • Run: bundle update react_on_rails" + puts " • Documentation: https://www.shakacode.com/react-on-rails/docs/" + end + + def run_all_checks + checks = [ + ["Environment Prerequisites", :check_environment], + ["React on Rails Versions", :check_react_on_rails_versions], + ["React on Rails Packages", :check_packages], + ["JavaScript Package Dependencies", :check_dependencies], + ["Key Configuration Files", :check_key_files], + ["Configuration Analysis", :check_configuration_details], + ["bin/dev Launcher Setup", :check_bin_dev_launcher], + ["Rails Integration", :check_rails], + ["Webpack Configuration", :check_webpack], + ["Testing Setup", :check_testing_setup], + ["Development Environment", :check_development] + ] + + checks.each do |section_name, check_method| + initial_message_count = checker.messages.length + send(check_method) + + # Only print header if messages were added + next unless checker.messages.length > initial_message_count + + print_section_header(section_name) + print_recent_messages(initial_message_count) + puts + end + end + + def print_section_header(section_name) + puts Rainbow("#{section_name}:").blue.bold + puts Rainbow("-" * (section_name.length + 1)).blue + end + + def print_recent_messages(start_index) + checker.messages[start_index..].each do |message| + color = MESSAGE_COLORS[message[:type]] || :blue + puts Rainbow(message[:content]).send(color) + end + end + + def check_environment + checker.check_node_installation + checker.check_package_manager + end + + def check_react_on_rails_versions + # Use system_checker for comprehensive package validation instead of duplicating + checker.check_react_on_rails_packages + check_version_wildcards + end + + def check_packages + checker.check_shakapacker_configuration + end + + def check_dependencies + checker.check_react_dependencies + end + + def check_rails + checker.check_react_on_rails_initializer + end + + def check_webpack + checker.check_webpack_configuration + end + + def check_key_files + check_key_configuration_files + end + + def check_configuration_details + check_shakapacker_configuration_details + check_react_on_rails_configuration_details + end + + def check_bin_dev_launcher + checker.add_info("🚀 bin/dev Launcher:") + check_bin_dev_launcher_setup + + checker.add_info("\n📄 Launcher Procfiles:") + check_launcher_procfiles + end + + def check_testing_setup + check_rspec_helper_setup + end + + def check_development + check_javascript_bundles + check_procfile_dev + check_bin_dev_script + check_gitignore + end + + def check_javascript_bundles + server_bundle_path = determine_server_bundle_path + if File.exist?(server_bundle_path) + checker.add_success("✅ Server bundle file exists at #{server_bundle_path}") + else + checker.add_warning(<<~MSG.strip) + ⚠️ Server bundle not found: #{server_bundle_path} + + This is required for server-side rendering. + Check your Shakapacker configuration and ensure the bundle is compiled. + MSG + end + end + + def check_procfile_dev + check_procfiles + end + + def check_procfiles + procfiles = { + "Procfile.dev" => { + description: "HMR development with webpack-dev-server", + required_for: "bin/dev (default/hmr mode)", + should_contain: ["shakapacker-dev-server", "rails server"] + }, + "Procfile.dev-static-assets" => { + description: "Static development with webpack --watch", + required_for: "bin/dev static", + should_contain: ["shakapacker", "rails server"] + }, + "Procfile.dev-prod-assets" => { + description: "Production-optimized assets development", + required_for: "bin/dev prod", + should_contain: ["rails server"] + } + } + + procfiles.each do |filename, config| + check_individual_procfile(filename, config) + end + + # Check if at least Procfile.dev exists + if File.exist?("Procfile.dev") + checker.add_success("✅ Essential Procfiles available for bin/dev script") + else + checker.add_warning(<<~MSG.strip) + ⚠️ Procfile.dev missing - required for bin/dev development server + Run 'rails generate react_on_rails:install' to generate required Procfiles + MSG + end + end + + def check_individual_procfile(filename, config) + if File.exist?(filename) + checker.add_success("✅ #{filename} exists (#{config[:description]})") + + # Only check for critical missing components, not optional suggestions + content = File.read(filename) + if filename == "Procfile.dev" && !content.include?("shakapacker-dev-server") + checker.add_warning(" ⚠️ Missing shakapacker-dev-server for HMR development") + elsif filename == "Procfile.dev-static-assets" && !content.include?("shakapacker") + checker.add_warning(" ⚠️ Missing shakapacker for static asset compilation") + end + else + checker.add_info("ℹ️ #{filename} not found (needed for #{config[:required_for]})") + end + end + + def check_bin_dev_script + bin_dev_path = "bin/dev" + if File.exist?(bin_dev_path) + checker.add_success("✅ bin/dev script exists") + check_bin_dev_content(bin_dev_path) + else + checker.add_warning(<<~MSG.strip) + ⚠️ bin/dev script missing + This script provides an enhanced development workflow with HMR, static, and production modes. + Run 'rails generate react_on_rails:install' to generate the script. + MSG + end + end + + def check_bin_dev_content(bin_dev_path) + return unless File.exist?(bin_dev_path) + + content = File.read(bin_dev_path) + + # Check if it's using the new ReactOnRails::Dev::ServerManager + if content.include?("ReactOnRails::Dev::ServerManager") + checker.add_success(" ✓ Uses enhanced ReactOnRails development server") + elsif content.include?("foreman") || content.include?("overmind") + checker.add_info(" ℹ️ Using basic foreman/overmind - consider upgrading to ReactOnRails enhanced dev script") + else + checker.add_info(" ℹ️ Custom bin/dev script detected") + end + + # Check if it's executable + if File.executable?(bin_dev_path) + checker.add_success(" ✓ Script is executable") + else + checker.add_warning(" ⚠️ Script is not executable - run 'chmod +x bin/dev'") + end + end + + def check_gitignore + gitignore_path = ".gitignore" + return unless File.exist?(gitignore_path) + + content = File.read(gitignore_path) + if content.include?("**/generated/**") + checker.add_success("✅ .gitignore excludes generated files") + else + checker.add_info("ℹ️ Consider adding '**/generated/**' to .gitignore") + end + end + + def print_summary + print_summary_header + counts = calculate_message_counts + print_summary_message(counts) + print_detailed_results_if_needed(counts) + end + + def print_summary_header + puts Rainbow("DIAGNOSIS COMPLETE").cyan.bold + puts Rainbow("=" * 80).cyan + puts + end + + def calculate_message_counts + { + error: checker.messages.count { |msg| msg[:type] == :error }, + warning: checker.messages.count { |msg| msg[:type] == :warning }, + success: checker.messages.count { |msg| msg[:type] == :success } + } + end + + def print_summary_message(counts) + if counts[:error].zero? && counts[:warning].zero? + puts Rainbow("🎉 Excellent! Your React on Rails setup looks perfect!").green.bold + elsif counts[:error].zero? + puts Rainbow("✅ Good! Your setup is functional with #{counts[:warning]} minor issue(s).").yellow + else + puts Rainbow("❌ Issues found: #{counts[:error]} error(s), #{counts[:warning]} warning(s)").red + end + + summary_text = "📊 Summary: #{counts[:success]} checks passed, " \ + "#{counts[:warning]} warnings, #{counts[:error]} errors" + puts Rainbow(summary_text).blue + end + + def print_detailed_results_if_needed(_counts) + # Skip detailed results since messages are now printed under section headers + # Only show detailed results in verbose mode for debugging + return unless verbose + + puts "\nDetailed Results (Verbose Mode):" + print_all_messages + end + + def print_all_messages + checker.messages.each do |message| + color = MESSAGE_COLORS[message[:type]] || :blue + + puts Rainbow(message[:content]).send(color) + puts + end + end + + def print_recommendations + puts Rainbow("RECOMMENDATIONS").cyan.bold + puts Rainbow("=" * 80).cyan + + if checker.errors? + puts Rainbow("Critical Issues:").red.bold + puts "• Fix the errors above before proceeding" + puts "• Run 'rails generate react_on_rails:install' to set up missing components" + puts "• Ensure all prerequisites (Node.js, package manager) are installed" + puts + end + + if checker.warnings? + puts Rainbow("Suggested Improvements:").yellow.bold + puts "• Review warnings above for optimization opportunities" + + # Enhanced development workflow recommendations + unless File.exist?("bin/dev") && File.read("bin/dev").include?("ReactOnRails::Dev::ServerManager") + puts "• #{Rainbow('Upgrade to enhanced bin/dev script').yellow}:" + puts " - Run #{Rainbow('rails generate react_on_rails:install').cyan} for latest development tools" + puts " - Provides HMR, static, and production-like asset modes" + puts " - Better error handling and debugging capabilities" + end + + missing_procfiles = ["Procfile.dev-static-assets", "Procfile.dev-prod-assets"].reject { |f| File.exist?(f) } + unless missing_procfiles.empty? + puts "• #{Rainbow('Complete development workflow setup').yellow}:" + puts " - Missing: #{missing_procfiles.join(', ')}" + puts " - Run #{Rainbow('rails generate react_on_rails:install').cyan} to generate missing files" + end + + puts "• Consider updating packages to latest compatible versions" + puts "• Check documentation for best practices" + puts + end + + print_next_steps + end + + def should_show_recommendations? + # Only show recommendations if there are actual issues or actionable improvements + checker.errors? || checker.warnings? + end + + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def print_next_steps + puts Rainbow("Next Steps:").blue.bold + + if checker.errors? + puts "• Fix critical errors above before proceeding" + puts "• Run doctor again to verify fixes: rake react_on_rails:doctor" + elsif checker.warnings? + puts "• Address warnings above for optimal setup" + puts "• Run doctor again to verify improvements: rake react_on_rails:doctor" + else + puts "• Your setup is healthy! Consider these development workflow steps:" + end + + # Enhanced contextual suggestions based on what exists + if File.exist?("bin/dev") && File.exist?("Procfile.dev") + puts "• Start development with HMR: #{Rainbow('./bin/dev').cyan}" + puts "• Try static mode: #{Rainbow('./bin/dev static').cyan}" + puts "• Test production assets: #{Rainbow('./bin/dev prod').cyan}" + puts "• See all options: #{Rainbow('./bin/dev help').cyan}" + elsif File.exist?("Procfile.dev") + puts "• Start development with: #{Rainbow('./bin/dev').cyan} (or foreman start -f Procfile.dev)" + else + puts "• Start Rails server: bin/rails server" + puts "• Start webpack dev server: bin/shakapacker-dev-server (in separate terminal)" + end + + # Test suggestions based on what's available + test_suggestions = [] + test_suggestions << "bundle exec rspec" if File.exist?("spec") + test_suggestions << "npm test" if npm_test_script? + test_suggestions << "yarn test" if yarn_test_script? + + puts "• Run tests: #{test_suggestions.join(' or ')}" if test_suggestions.any? + + # Build suggestions + if checker.messages.any? { |msg| msg[:content].include?("server bundle") } + puts "• Build assets: bin/shakapacker or npm run build" + end + + puts "• Documentation: https://github.com/shakacode/react_on_rails" + puts + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def check_gem_version + gem_version = ReactOnRails::VERSION + checker.add_success("✅ React on Rails gem version: #{gem_version}") + rescue StandardError + checker.add_error("🚫 Unable to determine React on Rails gem version") + end + + def check_npm_package_version + return unless File.exist?("package.json") + + begin + package_json = JSON.parse(File.read("package.json")) + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + + npm_version = all_deps["react-on-rails"] + if npm_version + checker.add_success("✅ react-on-rails npm package version: #{npm_version}") + else + checker.add_warning("⚠️ react-on-rails npm package not found in package.json") + end + rescue JSON::ParserError + checker.add_error("🚫 Unable to parse package.json") + rescue StandardError + checker.add_error("🚫 Error reading package.json") + end + end + + def check_version_wildcards + check_gem_wildcards + check_npm_wildcards + end + + # rubocop:disable Metrics/CyclomaticComplexity + def check_gem_wildcards + gemfile_path = ENV["BUNDLE_GEMFILE"] || "Gemfile" + return unless File.exist?(gemfile_path) + + begin + content = File.read(gemfile_path) + react_line = content.lines.find { |line| line.match(/^\s*gem\s+['"]react_on_rails['"]/) } + + if react_line + if /['"][~^]/.match?(react_line) + checker.add_warning("⚠️ Gemfile uses wildcard version pattern (~, ^) for react_on_rails") + elsif />=\s*/.match?(react_line) + checker.add_warning("⚠️ Gemfile uses version range (>=) for react_on_rails") + else + checker.add_success("✅ Gemfile uses exact version for react_on_rails") + end + end + rescue StandardError + # Ignore errors reading Gemfile + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + # rubocop:disable Metrics/CyclomaticComplexity + def check_npm_wildcards + return unless File.exist?("package.json") + + begin + package_json = JSON.parse(File.read("package.json")) + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + + npm_version = all_deps["react-on-rails"] + if npm_version + if /[~^]/.match?(npm_version) + checker.add_warning("⚠️ package.json uses wildcard version pattern (~, ^) for react-on-rails") + else + checker.add_success("✅ package.json uses exact version for react-on-rails") + end + end + rescue JSON::ParserError + # Ignore JSON parsing errors + rescue StandardError + # Ignore other errors + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + def check_key_configuration_files + files_to_check = { + "config/shakapacker.yml" => "Shakapacker configuration", + "config/initializers/react_on_rails.rb" => "React on Rails initializer", + "bin/dev" => "Development server launcher", + "bin/shakapacker" => "Shakapacker binary", + "bin/shakapacker-dev-server" => "Shakapacker dev server binary", + "config/webpack/webpack.config.js" => "Webpack configuration" + } + + files_to_check.each do |file_path, description| + if File.exist?(file_path) + checker.add_success("✅ #{description}: #{file_path}") + else + checker.add_warning("⚠️ Missing #{description}: #{file_path}") + end + end + + check_layout_files + check_server_rendering_engine + end + + # rubocop:disable Metrics/CyclomaticComplexity + def check_layout_files + layout_files = Dir.glob("app/views/layouts/**/*.erb") + return if layout_files.empty? + + checker.add_info("\n📄 Layout Files Analysis:") + + layout_files.each do |layout_file| + next unless File.exist?(layout_file) + + content = File.read(layout_file) + has_stylesheet = content.include?("stylesheet_pack_tag") + has_javascript = content.include?("javascript_pack_tag") + + layout_name = File.basename(layout_file, ".html.erb") + + if has_stylesheet && has_javascript + checker.add_info(" ✅ #{layout_name}: has both stylesheet_pack_tag and javascript_pack_tag") + elsif has_stylesheet + checker.add_warning(" ⚠️ #{layout_name}: has stylesheet_pack_tag but missing javascript_pack_tag") + elsif has_javascript + checker.add_warning(" ⚠️ #{layout_name}: has javascript_pack_tag but missing stylesheet_pack_tag") + else + checker.add_info(" ℹ️ #{layout_name}: no pack tags found") + end + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + # rubocop:disable Metrics/CyclomaticComplexity + def check_server_rendering_engine + return unless defined?(ReactOnRails) + + checker.add_info("\n🖥️ Server Rendering Engine:") + + begin + # Check if ExecJS is available and what runtime is being used + if defined?(ExecJS) + runtime_name = ExecJS.runtime.name if ExecJS.runtime + if runtime_name + checker.add_info(" ExecJS Runtime: #{runtime_name}") + + # Provide more specific information about the runtime + case runtime_name + when /MiniRacer/ + checker.add_info(" ℹ️ Using V8 via mini_racer gem (fast, isolated)") + when /Node/ + checker.add_info(" ℹ️ Using Node.js runtime (requires Node.js)") + when /Duktape/ + checker.add_info(" ℹ️ Using Duktape runtime (pure Ruby, slower)") + else + checker.add_info(" ℹ️ JavaScript runtime: #{runtime_name}") + end + else + checker.add_warning(" ⚠️ ExecJS runtime not detected") + end + else + checker.add_warning(" ⚠️ ExecJS not available") + end + rescue StandardError => e + checker.add_warning(" ⚠️ Could not determine server rendering engine: #{e.message}") + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + # rubocop:disable Metrics/CyclomaticComplexity + def check_shakapacker_configuration_details + return unless File.exist?("config/shakapacker.yml") + + checker.add_info("📋 Shakapacker Configuration:") + + begin + # Run shakapacker:info to get detailed configuration + stdout, stderr, status = Open3.capture3("bundle", "exec", "rake", "shakapacker:info") + + if status.success? + # Parse and display relevant info from shakapacker:info + lines = stdout.lines.map(&:strip) + + lines.each do |line| + next if line.empty? + + # Show only Shakapacker-specific configuration lines, not general environment info + checker.add_info(" #{line}") if line.match?(%r{^Is bin/shakapacker}) + end + else + checker.add_info(" Configuration file: config/shakapacker.yml") + checker.add_warning(" ⚠️ Could not run 'rake shakapacker:info': #{stderr.strip}") + end + rescue StandardError => e + checker.add_info(" Configuration file: config/shakapacker.yml") + checker.add_warning(" ⚠️ Could not run 'rake shakapacker:info': #{e.message}") + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + def check_react_on_rails_configuration_details + check_react_on_rails_initializer + check_deprecated_configuration_settings + check_breaking_changes_warnings + end + + def check_react_on_rails_initializer + config_path = "config/initializers/react_on_rails.rb" + + unless File.exist?(config_path) + checker.add_warning("⚠️ React on Rails configuration file not found: #{config_path}") + checker.add_info("💡 Run 'rails generate react_on_rails:install' to create configuration file") + return + end + + begin + content = File.read(config_path) + + checker.add_info("📋 React on Rails Configuration:") + checker.add_info("📍 Documentation: https://www.shakacode.com/react-on-rails/docs/guides/configuration/") + + # Analyze configuration settings + analyze_server_rendering_config(content) + analyze_performance_config(content) + analyze_development_config(content) + analyze_i18n_config(content) + analyze_component_loading_config(content) + analyze_custom_extensions(content) + rescue StandardError => e + checker.add_warning("⚠️ Unable to read react_on_rails.rb: #{e.message}") + end + end + + def analyze_server_rendering_config(content) + checker.add_info("\n🖥️ Server Rendering:") + + # Server bundle file + server_bundle_match = content.match(/config\.server_bundle_js_file\s*=\s*["']([^"']+)["']/) + if server_bundle_match + checker.add_info(" server_bundle_js_file: #{server_bundle_match[1]}") + else + checker.add_info(" server_bundle_js_file: server-bundle.js (default)") + end + + # RSC bundle file (Pro feature) + rsc_bundle_match = content.match(/config\.rsc_bundle_js_file\s*=\s*["']([^"']+)["']/) + if rsc_bundle_match + checker.add_info(" rsc_bundle_js_file: #{rsc_bundle_match[1]} (React Server Components - Pro)") + end + + # Prerender setting + prerender_match = content.match(/config\.prerender\s*=\s*([^\s\n,]+)/) + prerender_value = prerender_match ? prerender_match[1] : "false (default)" + checker.add_info(" prerender: #{prerender_value}") + + # Server renderer pool settings + pool_size_match = content.match(/config\.server_renderer_pool_size\s*=\s*([^\s\n,]+)/) + checker.add_info(" server_renderer_pool_size: #{pool_size_match[1]}") if pool_size_match + + timeout_match = content.match(/config\.server_renderer_timeout\s*=\s*([^\s\n,]+)/) + checker.add_info(" server_renderer_timeout: #{timeout_match[1]} seconds") if timeout_match + + # Error handling + raise_on_error_match = content.match(/config\.raise_on_prerender_error\s*=\s*([^\s\n,]+)/) + return unless raise_on_error_match + + checker.add_info(" raise_on_prerender_error: #{raise_on_error_match[1]}") + end + # rubocop:enable Metrics/AbcSize + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + def analyze_performance_config(content) + checker.add_info("\n⚡ Performance & Loading:") + + # Component loading strategy + loading_strategy_match = content.match(/config\.generated_component_packs_loading_strategy\s*=\s*:([^\s\n,]+)/) + if loading_strategy_match + strategy = loading_strategy_match[1] + checker.add_info(" generated_component_packs_loading_strategy: :#{strategy}") + + case strategy + when "async" + checker.add_info(" ℹ️ Async loading requires Shakapacker >= 8.2.0") + when "defer" + checker.add_info(" ℹ️ Deferred loading provides good performance balance") + when "sync" + checker.add_info(" ℹ️ Synchronous loading ensures immediate availability") + end + end + + # Deprecated defer setting + defer_match = content.match(/config\.defer_generated_component_packs\s*=\s*([^\s\n,]+)/) + if defer_match + checker.add_warning(" ⚠️ defer_generated_component_packs: #{defer_match[1]} (DEPRECATED)") + checker.add_info(" 💡 Use generated_component_packs_loading_strategy = :defer instead") + end + + # Auto load bundle + auto_load_match = content.match(/config\.auto_load_bundle\s*=\s*([^\s\n,]+)/) + checker.add_info(" auto_load_bundle: #{auto_load_match[1]}") if auto_load_match + + # Immediate hydration (Pro feature) + immediate_hydration_match = content.match(/config\.immediate_hydration\s*=\s*([^\s\n,]+)/) + if immediate_hydration_match + checker.add_info(" immediate_hydration: #{immediate_hydration_match[1]} (React on Rails Pro)") + end + + # Component registry timeout + timeout_match = content.match(/config\.component_registry_timeout\s*=\s*([^\s\n,]+)/) + return unless timeout_match + + checker.add_info(" component_registry_timeout: #{timeout_match[1]}ms") + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + + # rubocop:disable Metrics/AbcSize + def analyze_development_config(content) + checker.add_info("\n🔧 Development & Debugging:") + + # Development mode + dev_mode_match = content.match(/config\.development_mode\s*=\s*([^\s\n,]+)/) + if dev_mode_match + checker.add_info(" development_mode: #{dev_mode_match[1]}") + else + checker.add_info(" development_mode: Rails.env.development? (default)") + end + + # Trace setting + trace_match = content.match(/config\.trace\s*=\s*([^\s\n,]+)/) + if trace_match + checker.add_info(" trace: #{trace_match[1]}") + else + checker.add_info(" trace: Rails.env.development? (default)") + end + + # Logging + logging_match = content.match(/config\.logging_on_server\s*=\s*([^\s\n,]+)/) + logging_value = logging_match ? logging_match[1] : "true (default)" + checker.add_info(" logging_on_server: #{logging_value}") + + # Console replay + replay_match = content.match(/config\.replay_console\s*=\s*([^\s\n,]+)/) + replay_value = replay_match ? replay_match[1] : "true (default)" + checker.add_info(" replay_console: #{replay_value}") + + # Build commands + build_test_match = content.match(/config\.build_test_command\s*=\s*["']([^"']+)["']/) + checker.add_info(" build_test_command: #{build_test_match[1]}") if build_test_match + + build_prod_match = content.match(/config\.build_production_command\s*=\s*["']([^"']+)["']/) + return unless build_prod_match + + checker.add_info(" build_production_command: #{build_prod_match[1]}") + end + # rubocop:enable Metrics/AbcSize + + def analyze_i18n_config(content) + i18n_configs = [] + + i18n_dir_match = content.match(/config\.i18n_dir\s*=\s*["']([^"']+)["']/) + i18n_configs << "i18n_dir: #{i18n_dir_match[1]}" if i18n_dir_match + + i18n_yml_dir_match = content.match(/config\.i18n_yml_dir\s*=\s*["']([^"']+)["']/) + i18n_configs << "i18n_yml_dir: #{i18n_yml_dir_match[1]}" if i18n_yml_dir_match + + i18n_format_match = content.match(/config\.i18n_output_format\s*=\s*["']([^"']+)["']/) + i18n_configs << "i18n_output_format: #{i18n_format_match[1]}" if i18n_format_match + + return unless i18n_configs.any? + + checker.add_info("\n🌍 Internationalization:") + i18n_configs.each { |config| checker.add_info(" #{config}") } + end + + def analyze_component_loading_config(content) + component_configs = [] + + components_subdir_match = content.match(/config\.components_subdirectory\s*=\s*["']([^"']+)["']/) + if components_subdir_match + component_configs << "components_subdirectory: #{components_subdir_match[1]}" + checker.add_info(" ℹ️ File-system based component registry enabled") + end + + same_bundle_match = content.match(/config\.same_bundle_for_client_and_server\s*=\s*([^\s\n,]+)/) + component_configs << "same_bundle_for_client_and_server: #{same_bundle_match[1]}" if same_bundle_match + + random_dom_match = content.match(/config\.random_dom_id\s*=\s*([^\s\n,]+)/) + component_configs << "random_dom_id: #{random_dom_match[1]}" if random_dom_match + + return unless component_configs.any? + + checker.add_info("\n📦 Component Loading:") + component_configs.each { |config| checker.add_info(" #{config}") } + end + + def analyze_custom_extensions(content) + # Check for rendering extension + if /config\.rendering_extension\s*=\s*([^\s\n,]+)/.match?(content) + checker.add_info("\n🔌 Custom Extensions:") + checker.add_info(" rendering_extension: Custom rendering logic detected") + checker.add_info(" ℹ️ See: https://www.shakacode.com/react-on-rails/docs/guides/rendering-extensions") + end + + # Check for rendering props extension + if /config\.rendering_props_extension\s*=\s*([^\s\n,]+)/.match?(content) + checker.add_info(" rendering_props_extension: Custom props logic detected") + end + + # Check for server render method + server_method_match = content.match(/config\.server_render_method\s*=\s*["']([^"']+)["']/) + return unless server_method_match + + checker.add_info(" server_render_method: #{server_method_match[1]}") + end + + def check_deprecated_configuration_settings + return unless File.exist?("config/initializers/react_on_rails.rb") + + content = File.read("config/initializers/react_on_rails.rb") + deprecated_settings = [] + + # Check for deprecated settings + if content.include?("config.generated_assets_dirs") + deprecated_settings << "generated_assets_dirs (use generated_assets_dir)" + end + if content.include?("config.skip_display_none") + deprecated_settings << "skip_display_none (remove from configuration)" + end + if content.include?("config.defer_generated_component_packs") + deprecated_settings << "defer_generated_component_packs (use generated_component_packs_loading_strategy)" + end + + return unless deprecated_settings.any? + + checker.add_info("\n⚠️ Deprecated Configuration Settings:") + deprecated_settings.each do |setting| + checker.add_warning(" #{setting}") + end + checker.add_info("📖 Migration guide: https://www.shakacode.com/react-on-rails/docs/guides/upgrading-react-on-rails") + end + + def check_breaking_changes_warnings + return unless defined?(ReactOnRails::VERSION) + + # Parse version - handle pre-release versions like "16.0.0.beta.1" + current_version = ReactOnRails::VERSION.split(".").map(&:to_i) + major_version = current_version[0] + + # Check for major version breaking changes + if major_version >= 16 + check_v16_breaking_changes + elsif major_version >= 14 + check_v14_breaking_changes + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def check_v16_breaking_changes + issues_found = [] + + # Check for Webpacker usage (breaking change: removed in v16) + if File.exist?("config/webpacker.yml") || File.exist?("bin/webpacker") + issues_found << "• Webpacker support removed - migrate to Shakapacker >= 6.0" + end + + # Check for CommonJS require() usage (breaking change: ESM-only) + commonjs_files = [] + begin + # Check JavaScript/TypeScript files for require() usage + js_files = Dir.glob(%w[app/javascript/**/*.{js,ts,jsx,tsx} client/**/*.{js,ts,jsx,tsx}]) + js_files.each do |file| + next unless File.exist?(file) + + content = File.read(file) + commonjs_files << file if content.match?(/require\s*\(\s*['"]react-on-rails['"]/) + end + rescue StandardError + # Ignore file read errors + end + + unless commonjs_files.empty? + issues_found << "• CommonJS require() found - update to ESM imports" + issues_found << " Files: #{commonjs_files.take(3).join(', ')}#{'...' if commonjs_files.length > 3}" + end + + # Check Node.js version (recommendation, not breaking) + begin + stdout, _stderr, status = Open3.capture3("node", "--version") + if status.success? + node_version = stdout.strip.gsub(/^v/, "") + version_parts = node_version.split(".").map(&:to_i) + major = version_parts[0] + minor = version_parts[1] || 0 + + if major < 20 || (major == 20 && minor < 19) + issues_found << "• Node.js #{node_version} detected - v20.19.0+ recommended for full ESM support" + end + end + rescue StandardError + # Ignore version check errors + end + + return if issues_found.empty? + + checker.add_info("\n🚨 React on Rails v16+ Breaking Changes Detected:") + issues_found.each { |issue| checker.add_warning(" #{issue}") } + checker.add_info("📖 Full migration guide: https://www.shakacode.com/react-on-rails/docs/guides/upgrading-react-on-rails#upgrading-to-version-16") + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def check_v14_breaking_changes + checker.add_info("\n📋 React on Rails v14+ Notes:") + checker.add_info(" • Enhanced React Server Components (RSC) support available in Pro") + checker.add_info(" • Improved component loading strategies") + checker.add_info(" • Modern React patterns recommended") + end + + def check_bin_dev_launcher_setup + bin_dev_path = "bin/dev" + + unless File.exist?(bin_dev_path) + checker.add_error(" 🚫 bin/dev script not found") + return + end + + content = File.read(bin_dev_path) + + if content.include?("ReactOnRails::Dev::ServerManager") + checker.add_success(" ✅ bin/dev uses ReactOnRails Launcher (ReactOnRails::Dev::ServerManager)") + elsif content.include?("run_from_command_line") + checker.add_success(" ✅ bin/dev uses ReactOnRails Launcher (run_from_command_line)") + else + checker.add_warning(" ⚠️ bin/dev exists but doesn't use ReactOnRails Launcher") + checker.add_info(" 💡 Consider upgrading: rails generate react_on_rails:install") + end + end + + def check_launcher_procfiles + procfiles = { + "Procfile.dev" => "HMR development (bin/dev default)", + "Procfile.dev-static-assets" => "Static development (bin/dev static)", + "Procfile.dev-prod-assets" => "Production assets (bin/dev prod)" + } + + missing_count = 0 + + procfiles.each do |filename, description| + if File.exist?(filename) + checker.add_success(" ✅ #{filename} - #{description}") + else + checker.add_warning(" ⚠️ Missing #{filename} - #{description}") + missing_count += 1 + end + end + + if missing_count.zero? + checker.add_success(" ✅ All Launcher Procfiles available") + else + checker.add_info(" 💡 Run: rails generate react_on_rails:install") + end + end + + # rubocop:disable Metrics/CyclomaticComplexity + def check_rspec_helper_setup + spec_helper_paths = [ + "spec/rails_helper.rb", + "spec/spec_helper.rb" + ] + + react_on_rails_test_helper_found = false + + spec_helper_paths.each do |helper_path| + next unless File.exist?(helper_path) + + content = File.read(helper_path) + + unless content.include?("ReactOnRails::TestHelper") || content.include?("configure_rspec_to_compile_assets") + next + end + + checker.add_success("✅ ReactOnRails RSpec helper configured in #{helper_path}") + react_on_rails_test_helper_found = true + + # Check specific configurations + checker.add_success(" ✓ Assets compilation enabled for tests") if content.include?("ensure_assets_compiled") + + checker.add_success(" ✓ RSpec configuration present") if content.include?("RSpec.configure") + end + + return if react_on_rails_test_helper_found + + if File.exist?("spec") + checker.add_warning("⚠️ ReactOnRails RSpec helper not found") + checker.add_info(" Add to spec/rails_helper.rb:") + checker.add_info(" require 'react_on_rails/test_helper'") + checker.add_info(" ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config)") + else + checker.add_info("ℹ️ No RSpec directory found - skipping RSpec helper check") + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + def npm_test_script? + return false unless File.exist?("package.json") + + begin + package_json = JSON.parse(File.read("package.json")) + test_script = package_json.dig("scripts", "test") + test_script && !test_script.empty? + rescue StandardError + false + end + end + + def yarn_test_script? + npm_test_script? && system("which yarn > /dev/null 2>&1") + end + + def determine_server_bundle_path + # Try to use Shakapacker gem API to get configuration + + require "shakapacker" + + # Get the source path relative to Rails root + source_path = Shakapacker.config.source_path.to_s + source_entry_path = Shakapacker.config.source_entry_path.to_s + bundle_filename = server_bundle_filename + rails_root = Dir.pwd + + # Convert absolute paths to relative paths + if source_path.start_with?("/") && source_path.start_with?(rails_root) + source_path = source_path.sub("#{rails_root}/", "") + end + + if source_entry_path.start_with?("/") && source_entry_path.start_with?(rails_root) + source_entry_path = source_entry_path.sub("#{rails_root}/", "") + end + + # If source_entry_path is already within source_path, just use the relative part + if source_entry_path.start_with?(source_path) + # Extract just the entry path part (e.g., "packs" from "client/app/packs") + source_entry_path = source_entry_path.sub("#{source_path}/", "") + end + + File.join(source_path, source_entry_path, bundle_filename) + rescue StandardError + # Handle missing Shakapacker gem or other configuration errors + bundle_filename = server_bundle_filename + "app/javascript/packs/#{bundle_filename}" + end + + def server_bundle_filename + # Try to read from React on Rails initializer + initializer_path = "config/initializers/react_on_rails.rb" + if File.exist?(initializer_path) + content = File.read(initializer_path) + match = content.match(/config\.server_bundle_js_file\s*=\s*["']([^"']+)["']/) + return match[1] if match + end + + # Default filename + "server-bundle.js" + end + + def exit_with_status + if checker.errors? + puts Rainbow("❌ Doctor found critical issues. Please address errors above.").red.bold + exit(1) + elsif checker.warnings? + puts Rainbow("⚠️ Doctor found some issues. Consider addressing warnings above.").yellow + exit(0) + else + puts Rainbow("🎉 All checks passed! Your React on Rails setup is healthy.").green.bold + exit(0) + end + end + + def relativize_path(absolute_path) + return absolute_path unless absolute_path.is_a?(String) + + project_root = Dir.pwd + if absolute_path.start_with?(project_root) + # Remove project root and leading slash to make it relative + relative = absolute_path.sub(project_root, "").sub(%r{^/}, "") + relative.empty? ? "." : relative + else + absolute_path + end + end + + def safe_display_config_path(label, path_value) + return unless path_value + + begin + # Convert to string and relativize + path_str = path_value.to_s + relative_path = relativize_path(path_str) + checker.add_info(" #{label}: #{relative_path}") + rescue StandardError => e + checker.add_info(" #{label}: ") + end + end + + def safe_display_config_value(label, config, method_name) + return unless config.respond_to?(method_name) + + begin + value = config.send(method_name) + checker.add_info(" #{label}: #{value}") + rescue StandardError => e + checker.add_info(" #{label}: ") + end + end + end + # rubocop:enable Metrics/ClassLength +end diff --git a/lib/react_on_rails/system_checker.rb b/lib/react_on_rails/system_checker.rb new file mode 100644 index 0000000000..891dad21b0 --- /dev/null +++ b/lib/react_on_rails/system_checker.rb @@ -0,0 +1,659 @@ +# frozen_string_literal: true + +require "open3" + +module ReactOnRails + # SystemChecker provides validation methods for React on Rails setup + # Used by install generator and doctor rake task + # rubocop:disable Metrics/ClassLength + class SystemChecker + attr_reader :messages + + def initialize + @messages = [] + end + + def add_error(message) + @messages << { type: :error, content: message } + end + + def add_warning(message) + @messages << { type: :warning, content: message } + end + + def add_success(message) + @messages << { type: :success, content: message } + end + + def add_info(message) + @messages << { type: :info, content: message } + end + + def errors? + @messages.any? { |msg| msg[:type] == :error } + end + + def warnings? + @messages.any? { |msg| msg[:type] == :warning } + end + + # Node.js validation + def check_node_installation + if node_missing? + add_error(<<~MSG.strip) + 🚫 Node.js is required but not found on your system. + + Please install Node.js before continuing: + • Download from: https://nodejs.org/en/ + • Recommended: Use a version manager like nvm, fnm, or volta + • Minimum required version: Node.js 18+ + + After installation, restart your terminal and try again. + MSG + return false + end + + check_node_version + true + end + + def check_node_version + stdout, stderr, status = Open3.capture3("node", "--version") + + # Use stdout if available, fallback to stderr if stdout is empty + node_version = stdout.strip + node_version = stderr.strip if node_version.empty? + + # Return early if node is not found (non-zero status) or no output + return if !status.success? || node_version.empty? + + # Extract major version number (e.g., "v18.17.0" -> 18) + major_version = node_version[/v(\d+)/, 1]&.to_i + return unless major_version + + if major_version < 18 + add_warning(<<~MSG.strip) + ⚠️ Node.js version #{node_version} detected. + + React on Rails recommends Node.js 18+ for best compatibility. + You may experience issues with older versions. + + Consider upgrading: https://nodejs.org/en/ + MSG + else + add_success("✅ Node.js #{node_version} is installed and compatible") + end + end + + # Package manager validation + def check_package_manager + package_managers = %w[npm pnpm yarn bun] + available_managers = package_managers.select { |pm| cli_exists?(pm) } + + if available_managers.empty? + add_error(<<~MSG.strip) + 🚫 No JavaScript package manager found on your system. + + React on Rails requires a JavaScript package manager to install dependencies. + Please install one of the following: + + • npm: Usually comes with Node.js (https://nodejs.org/en/) + • yarn: npm install -g yarn (https://yarnpkg.com/) + • pnpm: npm install -g pnpm (https://pnpm.io/) + • bun: Install from https://bun.sh/ + + After installation, restart your terminal and try again. + MSG + return false + end + + # Detect which package manager is actually being used + used_manager = detect_used_package_manager + if used_manager + version_info = get_package_manager_version(used_manager) + deprecation_note = get_deprecation_note(used_manager, version_info) + message = "✅ Package manager in use: #{used_manager} #{version_info}" + message += deprecation_note if deprecation_note + add_success(message) + else + add_success("✅ Package managers available: #{available_managers.join(', ')}") + add_info("ℹ️ No lock file detected - run npm/yarn/pnpm install to establish which manager is used") + end + true + end + + # Shakapacker validation + def check_shakapacker_configuration + unless shakapacker_configured? + add_error(<<~MSG.strip) + 🚫 Shakapacker is not properly configured. + + Missing one or more required files: + • bin/shakapacker + • bin/shakapacker-dev-server + • config/shakapacker.yml + • config/webpack/webpack.config.js + + Run: bundle exec rails shakapacker:install + MSG + return false + end + + report_shakapacker_version_with_threshold + check_shakapacker_in_gemfile + true + end + + def check_shakapacker_in_gemfile + if shakapacker_in_gemfile? + add_success("✅ Shakapacker is declared in Gemfile") + else + add_warning(<<~MSG.strip) + ⚠️ Shakapacker not found in Gemfile. + + While Shakapacker might be available as a dependency, + it's recommended to add it explicitly to your Gemfile: + + bundle add shakapacker --strict + MSG + end + end + + # React on Rails package validation + def check_react_on_rails_packages + check_react_on_rails_gem + check_react_on_rails_npm_package + check_package_version_sync + check_gemfile_version_patterns + end + + def check_react_on_rails_gem + require "react_on_rails" + add_success("✅ React on Rails gem #{ReactOnRails::VERSION} is loaded") + rescue LoadError + add_error(<<~MSG.strip) + 🚫 React on Rails gem is not available. + + Add to your Gemfile: + gem 'react_on_rails' + + Then run: bundle install + MSG + end + + def check_react_on_rails_npm_package + package_json_path = "package.json" + return unless File.exist?(package_json_path) + + package_json = JSON.parse(File.read(package_json_path)) + npm_version = package_json.dig("dependencies", "react-on-rails") || + package_json.dig("devDependencies", "react-on-rails") + + if npm_version + add_success("✅ react-on-rails NPM package #{npm_version} is declared") + else + add_warning(<<~MSG.strip) + ⚠️ react-on-rails NPM package not found in package.json. + + Install it with: + npm install react-on-rails + MSG + end + rescue JSON::ParserError + add_warning("⚠️ Could not parse package.json") + end + + def check_package_version_sync # rubocop:disable Metrics/CyclomaticComplexity + return unless File.exist?("package.json") + + begin + package_json = JSON.parse(File.read("package.json")) + npm_version = package_json.dig("dependencies", "react-on-rails") || + package_json.dig("devDependencies", "react-on-rails") + + return unless npm_version && defined?(ReactOnRails::VERSION) + + # Clean version strings for comparison (remove ^, ~, =, etc.) + clean_npm_version = npm_version.gsub(/[^0-9.]/, "") + gem_version = ReactOnRails::VERSION + + if clean_npm_version == gem_version + add_success("✅ React on Rails gem and NPM package versions match (#{gem_version})") + check_version_patterns(npm_version, gem_version) + else + # Check for major version differences + gem_major = gem_version.split(".")[0].to_i + npm_major = clean_npm_version.split(".")[0].to_i + + if gem_major != npm_major # rubocop:disable Style/NegatedIfElseCondition + add_error(<<~MSG.strip) + 🚫 Major version mismatch detected: + • Gem version: #{gem_version} (major: #{gem_major}) + • NPM version: #{npm_version} (major: #{npm_major}) + + Major version differences can cause serious compatibility issues. + Update both packages to use the same major version immediately. + MSG + else + add_warning(<<~MSG.strip) + ⚠️ Version mismatch detected: + • Gem version: #{gem_version} + • NPM version: #{npm_version} + + Consider updating to exact, fixed matching versions of gem and npm package for best compatibility. + MSG + end + end + rescue JSON::ParserError + # Ignore parsing errors, already handled elsewhere + rescue StandardError + # Handle other errors gracefully + end + end + + # React dependencies validation + def check_react_dependencies + return unless File.exist?("package.json") + + package_json = parse_package_json + return unless package_json + + # Check core React dependencies + required_deps = required_react_dependencies + missing_deps = find_missing_dependencies(package_json, required_deps) + report_dependency_status(required_deps, missing_deps, package_json) + + # Check additional build dependencies (informational) + check_build_dependencies(package_json) + + # Report versions + report_dependency_versions(package_json) + end + + # Rails integration validation + + def check_react_on_rails_initializer + initializer_path = "config/initializers/react_on_rails.rb" + if File.exist?(initializer_path) + add_success("✅ React on Rails initializer exists") + else + add_warning(<<~MSG.strip) + ⚠️ React on Rails initializer not found. + + Create: config/initializers/react_on_rails.rb + Or run: rails generate react_on_rails:install + MSG + end + end + + # Webpack configuration validation + def check_webpack_configuration + webpack_config_path = "config/webpack/webpack.config.js" + if File.exist?(webpack_config_path) + add_success("✅ Webpack configuration exists") + check_webpack_config_content + suggest_webpack_inspection + else + add_error(<<~MSG.strip) + 🚫 Webpack configuration not found. + + Expected: config/webpack/webpack.config.js + Run: rails generate react_on_rails:install + MSG + end + end + + def suggest_webpack_inspection + add_info("💡 To debug webpack builds:") + add_info(" bin/shakapacker --mode=development --progress") + add_info(" bin/shakapacker --mode=production --progress") + add_info(" bin/shakapacker --debug-shakapacker # Debug Shakapacker configuration") + + add_info("💡 Advanced webpack debugging:") + add_info(" 1. Add 'debugger;' before 'module.exports' in config/webpack/webpack.config.js") + add_info(" 2. Run: ./bin/shakapacker --debug-shakapacker") + add_info(" 3. Open Chrome DevTools to inspect config object") + add_info(" 📖 See: https://github.com/shakacode/shakapacker/blob/main/docs/troubleshooting.md#debugging-your-webpack-config") + + add_info("💡 To analyze bundle size:") + if bundle_analyzer_available? + add_info(" ANALYZE=true bin/shakapacker") + add_info(" This opens webpack-bundle-analyzer in your browser") + else + add_info(" 1. yarn add --dev webpack-bundle-analyzer") + add_info(" 2. Add to config/webpack/webpack.config.js:") + add_info(" const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');") + add_info(" // Add to plugins array when process.env.ANALYZE") + add_info(" 3. ANALYZE=true bin/shakapacker") + add_info(" Or use Shakapacker's built-in support if available") + end + + add_info("💡 Generate webpack stats for analysis:") + add_info(" bin/shakapacker --json > webpack-stats.json") + add_info(" Upload to webpack.github.io/analyse or webpack-bundle-analyzer.com") + end + + def bundle_analyzer_available? + return false unless File.exist?("package.json") + + begin + package_json = JSON.parse(File.read("package.json")) + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + all_deps["webpack-bundle-analyzer"] + rescue StandardError + false + end + end + + def check_webpack_config_content + webpack_config_path = "config/webpack/webpack.config.js" + content = File.read(webpack_config_path) + + if react_on_rails_config?(content) + add_success("✅ Webpack config includes React on Rails environment configuration") + add_info(" ℹ️ Environment-specific configs detected for optimal React on Rails integration") + elsif standard_shakapacker_config?(content) + add_warning(<<~MSG.strip) + ⚠️ Standard Shakapacker webpack config detected. + + React on Rails works better with environment-specific configuration. + Consider running: rails generate react_on_rails:install --force + This adds client and server environment configs for better performance. + MSG + else + add_info("ℹ️ Custom webpack config detected") + add_info(" 💡 Ensure config supports both client and server rendering") + add_info(" 💡 Verify React JSX transformation is configured") + add_info(" 💡 Check that asset output paths match Rails expectations") + end + end + + private + + def node_missing? + command = ReactOnRails::Utils.running_on_windows? ? "where" : "which" + _stdout, _stderr, status = Open3.capture3(command, "node") + !status.success? + end + + def cli_exists?(command) + which_command = ReactOnRails::Utils.running_on_windows? ? "where" : "which" + _stdout, _stderr, status = Open3.capture3(which_command, command) + status.success? + end + + def detect_used_package_manager + # Check for lock files to determine which package manager is being used + if File.exist?("yarn.lock") + "yarn" + elsif File.exist?("pnpm-lock.yaml") + "pnpm" + elsif File.exist?("bun.lockb") + "bun" + elsif File.exist?("package-lock.json") + "npm" + end + end + + def get_package_manager_version(manager) + begin + stdout, _stderr, status = Open3.capture3(manager, "--version") + return stdout.strip if status.success? && !stdout.strip.empty? + rescue StandardError + # Ignore errors + end + "(version unknown)" + end + + def get_deprecation_note(manager, version) + case manager + when "yarn" + " (Classic Yarn v1 - consider upgrading to Yarn Modern)" if /^1\./.match?(version) + end + end + + def shakapacker_configured? + File.exist?("bin/shakapacker") && + File.exist?("bin/shakapacker-dev-server") && + File.exist?("config/shakapacker.yml") && + File.exist?("config/webpack/webpack.config.js") + end + + def shakapacker_in_gemfile? + gemfile = ENV["BUNDLE_GEMFILE"] || "Gemfile" + File.file?(gemfile) && + File.foreach(gemfile).any? { |l| l.match?(/^\s*gem\s+['"]shakapacker['"]/) } + end + + def react_on_rails_config?(content) + content.include?("envSpecificConfig") || content.include?("env.nodeEnv") + end + + def standard_shakapacker_config?(content) + normalized = normalize_config_content(content) + shakapacker_patterns = [ + /generateWebpackConfig.*require.*shakapacker/, + /webpackConfig.*require.*shakapacker/ + ] + shakapacker_patterns.any? { |pattern| normalized.match?(pattern) } + end + + def normalize_config_content(content) + content.gsub(%r{//.*$}, "") # Remove single-line comments + .gsub(%r{/\*.*?\*/}m, "") # Remove multi-line comments + .gsub(/\s+/, " ") # Normalize whitespace + .strip + end + + def required_react_dependencies + { + "react" => "React library", + "react-dom" => "React DOM library", + "@babel/preset-react" => "Babel React preset" + } + end + + def additional_build_dependencies + { + "webpack" => "Webpack bundler", + "@babel/core" => "Babel compiler core", + "@babel/preset-env" => "Babel environment preset", + "css-loader" => "CSS loader for Webpack", + "style-loader" => "Style loader for Webpack", + "mini-css-extract-plugin" => "CSS extraction plugin", + "webpack-dev-server" => "Webpack development server" + } + end + + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def check_build_dependencies(package_json) + build_deps = additional_build_dependencies + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + + present_deps = [] + missing_deps = [] + + build_deps.each do |package, description| + if all_deps[package] + present_deps << "#{description} (#{package})" + else + missing_deps << "#{description} (#{package})" + end + end + + unless present_deps.empty? + short_list = present_deps.take(3).join(", ") + suffix = present_deps.length > 3 ? "..." : "" + add_info("✅ Build dependencies found: #{short_list}#{suffix}") + end + + return if missing_deps.empty? + + short_list = missing_deps.take(3).join(", ") + suffix = missing_deps.length > 3 ? "..." : "" + add_info("ℹ️ Optional build dependencies: #{short_list}#{suffix}") + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def parse_package_json + JSON.parse(File.read("package.json")) + rescue JSON::ParserError + add_warning("⚠️ Could not parse package.json to check React dependencies") + nil + end + + def find_missing_dependencies(package_json, required_deps) + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + required_deps.keys.reject { |dep| all_deps[dep] } + end + + def report_dependency_status(required_deps, missing_deps, package_json) + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + + required_deps.each do |dep, description| + add_success("✅ #{description} (#{dep}) is installed") if all_deps[dep] + end + + return unless missing_deps.any? + + add_warning(<<~MSG.strip) + ⚠️ Missing React dependencies: #{missing_deps.join(', ')} + + Install them with: + npm install #{missing_deps.join(' ')} + MSG + end + + def check_version_patterns(npm_version, gem_version) + # Check for version range patterns in package.json + return unless /^[\^~]/.match?(npm_version) + + pattern_type = npm_version[0] == "^" ? "caret (^)" : "tilde (~)" + add_warning(<<~MSG.strip) + ⚠️ NPM package uses #{pattern_type} version pattern: #{npm_version} + + While versions match, consider using exact version "#{gem_version}" in package.json + for guaranteed compatibility with the React on Rails gem. + MSG + end + + # rubocop:disable Metrics/CyclomaticComplexity + def check_gemfile_version_patterns + gemfile_path = ENV["BUNDLE_GEMFILE"] || "Gemfile" + return unless File.exist?(gemfile_path) + + begin + gemfile_content = File.read(gemfile_path) + react_on_rails_line = gemfile_content.lines.find { |line| line.match(/^\s*gem\s+['"]react_on_rails['"]/) } + + return unless react_on_rails_line + + # Check for version patterns in Gemfile + if /['"][~]/.match?(react_on_rails_line) + add_warning(<<~MSG.strip) + ⚠️ Gemfile uses version pattern for react_on_rails gem. + + Consider using exact version in Gemfile for guaranteed compatibility: + gem 'react_on_rails', '#{ReactOnRails::VERSION}' + MSG + elsif />=\s*/.match?(react_on_rails_line) + add_warning(<<~MSG.strip) + ⚠️ Gemfile uses version range (>=) for react_on_rails gem. + + Consider using exact version in Gemfile for guaranteed compatibility: + gem 'react_on_rails', '#{ReactOnRails::VERSION}' + MSG + end + rescue StandardError + # Ignore errors reading Gemfile + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + # rubocop:disable Metrics/CyclomaticComplexity + def report_dependency_versions(package_json) + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + + react_version = all_deps["react"] + react_dom_version = all_deps["react-dom"] + + if react_version && react_dom_version + add_success("✅ React #{react_version}, React DOM #{react_dom_version}") + elsif react_version + add_success("✅ React #{react_version}") + add_warning("⚠️ React DOM not found") + elsif react_dom_version + add_warning("⚠️ React not found") + add_success("✅ React DOM #{react_dom_version}") + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + def report_shakapacker_version + return unless File.exist?("Gemfile.lock") + + begin + lockfile_content = File.read("Gemfile.lock") + # Parse exact installed version from Gemfile.lock GEM section + shakapacker_match = lockfile_content.match(/^\s{4}shakapacker \(([^)>=<~]+)\)/) + if shakapacker_match + version = shakapacker_match[1].strip + add_info("📦 Shakapacker version: #{version}") + end + rescue StandardError + # Ignore errors in parsing Gemfile.lock + end + end + + def report_shakapacker_version_with_threshold + return unless File.exist?("Gemfile.lock") + + begin + lockfile_content = File.read("Gemfile.lock") + # Look for the exact installed version in the GEM section, not the dependency requirement + # This matches " shakapacker (8.0.0)" but not " shakapacker (>= 6.0)" + shakapacker_match = lockfile_content.match(/^\s{4}shakapacker \(([^)>=<~]+)\)/) + + if shakapacker_match + version = shakapacker_match[1].strip + + begin + # Use proper semantic version comparison + version_obj = Gem::Version.new(version) + threshold_version = Gem::Version.new("8.2") + + if version_obj >= threshold_version + add_success("✅ Shakapacker #{version} (supports React on Rails auto-registration)") + else + add_warning("⚠️ Shakapacker #{version} - Version 8.2+ needed for React on Rails auto-registration") + end + rescue ArgumentError + # Fallback for invalid version strings + add_success("✅ Shakapacker #{version}") + end + else + add_success("✅ Shakapacker is configured") + end + rescue StandardError + add_success("✅ Shakapacker is configured") + end + end + + def report_webpack_version + return unless File.exist?("package.json") + + begin + package_json = JSON.parse(File.read("package.json")) + all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {} + + webpack_version = all_deps["webpack"] + add_info("📦 Webpack version: #{webpack_version}") if webpack_version + rescue JSON::ParserError + # Handle JSON parsing errors + rescue StandardError + # Handle other file/access errors + end + end + end + # rubocop:enable Metrics/ClassLength +end diff --git a/lib/tasks/doctor.rake b/lib/tasks/doctor.rake new file mode 100644 index 0000000000..606fa319e6 --- /dev/null +++ b/lib/tasks/doctor.rake @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../../rakelib/task_helpers" +require_relative "../react_on_rails" +require_relative "../react_on_rails/doctor" + +begin + require "rainbow" +rescue LoadError + # Fallback if Rainbow is not available + class Rainbow + def self.method_missing(_method, text) + SimpleColorWrapper.new(text) + end + + def self.respond_to_missing?(_method, _include_private = false) + true + end + end + + class SimpleColorWrapper + def initialize(text) + @text = text + end + + def method_missing(_method, *_args) + self + end + + def respond_to_missing?(_method, _include_private = false) + true + end + + def to_s + @text + end + end +end + +namespace :react_on_rails do + include ReactOnRails::TaskHelpers + + desc "Diagnose React on Rails setup and configuration" + task :doctor do + verbose = ENV["VERBOSE"] == "true" + fix = ENV["FIX"] == "true" + + doctor = ReactOnRails::Doctor.new(verbose: verbose, fix: fix) + doctor.run_diagnosis + end +end diff --git a/spec/lib/react_on_rails/doctor_rake_task_spec.rb b/spec/lib/react_on_rails/doctor_rake_task_spec.rb new file mode 100644 index 0000000000..8729579c5d --- /dev/null +++ b/spec/lib/react_on_rails/doctor_rake_task_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../../react_on_rails/spec_helper" +require "rake" + +RSpec.describe "doctor rake task" do + let(:rake_file) { File.expand_path("../../../lib/tasks/doctor.rake", __dir__) } + + before do + Rake::Task.clear + load rake_file + end + + describe "rake react_on_rails:doctor task" do + it "exists" do + expect(Rake::Task.task_defined?("react_on_rails:doctor")).to be true + end + + it "can be invoked without errors" do + # Mock the Doctor class to avoid actual diagnosis + doctor_instance = instance_double(ReactOnRails::Doctor) + allow(ReactOnRails::Doctor).to receive(:new).and_return(doctor_instance) + allow(doctor_instance).to receive(:run_diagnosis) + + task = Rake::Task["react_on_rails:doctor"] + expect { task.invoke }.not_to raise_error + end + end +end diff --git a/spec/lib/react_on_rails/doctor_spec.rb b/spec/lib/react_on_rails/doctor_spec.rb new file mode 100644 index 0000000000..79ce410bfb --- /dev/null +++ b/spec/lib/react_on_rails/doctor_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/VerifiedDoubles + +require_relative "../../react_on_rails/spec_helper" +require_relative "../../../lib/react_on_rails/doctor" + +RSpec.describe ReactOnRails::Doctor do + let(:doctor) { described_class.new(verbose: false, fix: false) } + + describe "#initialize" do + it "initializes with default options" do + expect(doctor).to be_instance_of(described_class) + end + + it "accepts verbose and fix options" do + verbose_doctor = described_class.new(verbose: true, fix: true) + expect(verbose_doctor).to be_instance_of(described_class) + end + end + + describe "#run_diagnosis" do + before do + # Mock all output methods to avoid actual printing + allow(doctor).to receive(:puts) + allow(doctor).to receive(:exit) + + # Mock file system interactions + allow(File).to receive_messages(exist?: false, directory?: false) + + # Mock the new server bundle path methods + allow(doctor).to receive_messages( + "`": "", + determine_server_bundle_path: "app/javascript/packs/server-bundle.js", + server_bundle_filename: "server-bundle.js", + npm_test_script?: false, + yarn_test_script?: false + ) + + # Mock the checker to avoid actual system calls + checker = instance_double(ReactOnRails::SystemChecker) + allow(ReactOnRails::SystemChecker).to receive(:new).and_return(checker) + allow(checker).to receive_messages( + check_node_installation: true, + check_package_manager: true, + check_react_on_rails_packages: true, + check_shakapacker_configuration: true, + check_react_dependencies: true, + check_react_on_rails_initializer: true, + check_webpack_configuration: true, + report_dependency_versions: true, + report_shakapacker_version: true, + report_webpack_version: true, + add_success: true, + add_warning: true, + add_info: true, + errors?: false, + warnings?: false, + messages: [] + ) + end + + it "runs diagnosis without errors" do + expect { doctor.run_diagnosis }.not_to raise_error + end + + it "prints header" do + expect(doctor).to receive(:puts).with(/REACT ON RAILS DOCTOR/) + doctor.run_diagnosis + end + + it "runs all check sections" do + checker = doctor.instance_variable_get(:@checker) + + expect(checker).to receive(:check_node_installation) + expect(checker).to receive(:check_package_manager) + expect(checker).to receive(:check_react_on_rails_packages) + expect(checker).to receive(:check_shakapacker_configuration) + expect(checker).to receive(:check_react_dependencies) + expect(checker).to receive(:check_react_on_rails_initializer) + expect(checker).to receive(:check_webpack_configuration) + + doctor.run_diagnosis + end + end + + describe "server bundle path detection" do + let(:doctor) { described_class.new } + + describe "#determine_server_bundle_path" do + context "when Shakapacker gem is available with relative paths" do + let(:shakapacker_config) { double(source_path: "client/app", source_entry_path: "packs") } + + before do + shakapacker_module = double("Shakapacker", config: shakapacker_config) + stub_const("Shakapacker", shakapacker_module) + allow(doctor).to receive(:require).with("shakapacker").and_return(true) + allow(doctor).to receive(:get_server_bundle_filename).and_return("server-bundle.js") + end + + it "uses Shakapacker API configuration with relative paths" do + path = doctor.send(:determine_server_bundle_path) + expect(path).to eq("client/app/packs/server-bundle.js") + end + end + + context "when Shakapacker gem is available with absolute paths" do + let(:rails_root) { "/Users/test/myapp" } + let(:shakapacker_config) { double(source_path: "#{rails_root}/client/app", source_entry_path: "packs") } + + before do + shakapacker_module = double("Shakapacker", config: shakapacker_config) + stub_const("Shakapacker", shakapacker_module) + allow(doctor).to receive(:require).with("shakapacker").and_return(true) + allow(doctor).to receive(:get_server_bundle_filename).and_return("server-bundle.js") + allow(Dir).to receive(:pwd).and_return(rails_root) + end + + it "converts absolute paths to relative paths" do + path = doctor.send(:determine_server_bundle_path) + expect(path).to eq("client/app/packs/server-bundle.js") + end + end + + context "when Shakapacker gem returns nested absolute paths" do + let(:rails_root) { "/Users/test/myapp" } + let(:shakapacker_config) do + double(source_path: "#{rails_root}/client/app", source_entry_path: "#{rails_root}/client/app/packs") + end + + before do + shakapacker_module = double("Shakapacker", config: shakapacker_config) + stub_const("Shakapacker", shakapacker_module) + allow(doctor).to receive(:require).with("shakapacker").and_return(true) + allow(doctor).to receive(:get_server_bundle_filename).and_return("server-bundle.js") + allow(Dir).to receive(:pwd).and_return(rails_root) + end + + it "handles nested absolute paths correctly" do + path = doctor.send(:determine_server_bundle_path) + expect(path).to eq("client/app/packs/server-bundle.js") + end + end + + context "when Shakapacker gem is not available" do + before do + allow(doctor).to receive(:require).with("shakapacker").and_raise(LoadError) + allow(doctor).to receive(:get_server_bundle_filename).and_return("server-bundle.js") + end + + it "uses default path" do + path = doctor.send(:determine_server_bundle_path) + expect(path).to eq("app/javascript/packs/server-bundle.js") + end + end + end + + describe "#get_server_bundle_filename" do + context "when react_on_rails.rb has custom filename" do + let(:initializer_content) do + 'config.server_bundle_js_file = "custom-server-bundle.js"' + end + + before do + allow(File).to receive(:exist?).with("config/initializers/react_on_rails.rb").and_return(true) + allow(File).to receive(:read).with("config/initializers/react_on_rails.rb").and_return(initializer_content) + end + + it "extracts filename from initializer" do + filename = doctor.send(:get_server_bundle_filename) + expect(filename).to eq("custom-server-bundle.js") + end + end + + context "when no custom filename is configured" do + before do + allow(File).to receive(:exist?).with("config/initializers/react_on_rails.rb").and_return(false) + end + + it "returns default filename" do + filename = doctor.send(:get_server_bundle_filename) + expect(filename).to eq("server-bundle.js") + end + end + end + end +end + +# rubocop:enable RSpec/VerifiedDoubles diff --git a/spec/lib/react_on_rails/system_checker_spec.rb b/spec/lib/react_on_rails/system_checker_spec.rb new file mode 100644 index 0000000000..e8497443ec --- /dev/null +++ b/spec/lib/react_on_rails/system_checker_spec.rb @@ -0,0 +1,417 @@ +# frozen_string_literal: true + +require_relative "../../react_on_rails/spec_helper" +require_relative "../../../lib/react_on_rails/system_checker" +RSpec.describe ReactOnRails::SystemChecker do + let(:checker) { described_class.new } + + describe "#initialize" do + it "initializes with empty messages" do + expect(checker.messages).to eq([]) + end + end + + describe "message management" do + it "adds error messages" do + checker.add_error("Test error") + expect(checker.messages).to include({ type: :error, content: "Test error" }) + expect(checker.errors?).to be true + end + + it "adds warning messages" do + checker.add_warning("Test warning") + expect(checker.messages).to include({ type: :warning, content: "Test warning" }) + expect(checker.warnings?).to be true + end + + it "adds success messages" do + checker.add_success("Test success") + expect(checker.messages).to include({ type: :success, content: "Test success" }) + end + + it "adds info messages" do + checker.add_info("Test info") + expect(checker.messages).to include({ type: :info, content: "Test info" }) + end + end + + describe "#check_node_installation" do + context "when Node.js is missing" do + before do + allow(checker).to receive(:node_missing?).and_return(true) + end + + it "adds an error message" do + result = checker.check_node_installation + expect(result).to be false + expect(checker.errors?).to be true + expect(checker.messages.last[:content]).to include("Node.js is required") + end + end + + context "when Node.js is installed" do + before do + allow(checker).to receive(:node_missing?).and_return(false) + allow(checker).to receive(:check_node_version) + end + + it "returns true and checks version" do + result = checker.check_node_installation + expect(result).to be true + expect(checker).to have_received(:check_node_version) + end + end + end + + describe "#check_node_version" do + context "when Node.js version is too old" do + before do + allow(Open3).to receive(:capture3).with("node", "--version") + .and_return(["v16.14.0\n", "", instance_double(Process::Status, success?: true)]) + end + + it "adds a warning message" do + checker.check_node_version + expect(checker.warnings?).to be true + expect(checker.messages.last[:content]).to include("Node.js version v16.14.0 detected") + end + end + + context "when Node.js version is compatible" do + before do + allow(Open3).to receive(:capture3).with("node", "--version") + .and_return(["v18.17.0\n", "", instance_double(Process::Status, success?: true)]) + end + + it "adds a success message" do + checker.check_node_version + expect(checker.messages.any? do |msg| + msg[:type] == :success && msg[:content].include?("Node.js v18.17.0") + end).to be true + end + end + + context "when Node.js version cannot be determined" do + before do + allow(Open3).to receive(:capture3).with("node", "--version") + .and_return(["", "", instance_double(Process::Status, success?: false)]) + end + + it "does not add any messages" do + messages_count_before = checker.messages.count + checker.check_node_version + expect(checker.messages.count).to eq(messages_count_before) + end + end + end + + describe "#check_package_manager" do + context "when no package managers are available" do + before do + allow(checker).to receive(:cli_exists?).and_return(false) + end + + it "adds an error message" do + result = checker.check_package_manager + expect(result).to be false + expect(checker.errors?).to be true + expect(checker.messages.last[:content]).to include("No JavaScript package manager found") + end + end + + context "when package managers are available" do + before do + allow(checker).to receive(:cli_exists?).with("npm").and_return(true) + allow(checker).to receive(:cli_exists?).with("yarn").and_return(true) + allow(checker).to receive(:cli_exists?).with("pnpm").and_return(false) + allow(checker).to receive(:cli_exists?).with("bun").and_return(false) + # Mock file existence checks for lock files so detect_used_package_manager returns nil + allow(File).to receive(:exist?).with("yarn.lock").and_return(false) + allow(File).to receive(:exist?).with("pnpm-lock.yaml").and_return(false) + allow(File).to receive(:exist?).with("bun.lockb").and_return(false) + allow(File).to receive(:exist?).with("package-lock.json").and_return(false) + end + + it "adds a success message" do + result = checker.check_package_manager + expect(result).to be true + expect(checker.messages.any? do |msg| + msg[:type] == :success && msg[:content].include?("Package managers available: npm, yarn") + end).to be true + end + end + end + + describe "#check_shakapacker_configuration" do + context "when shakapacker is not configured" do + before do + allow(checker).to receive(:shakapacker_configured?).and_return(false) + end + + it "adds an error message" do + result = checker.check_shakapacker_configuration + expect(result).to be false + expect(checker.errors?).to be true + expect(checker.messages.last[:content]).to include("Shakapacker is not properly configured") + end + end + + context "when shakapacker is configured" do + before do + allow(checker).to receive(:shakapacker_configured?).and_return(true) + allow(checker).to receive(:check_shakapacker_in_gemfile) + allow(File).to receive(:exist?).with("Gemfile.lock").and_return(true) + lockfile_content = %(GEM\n remote: https://rubygems.org/\n specs:\n) + + %( activesupport (7.1.3.2)\n shakapacker (8.2.0)\n activesupport (>= 5.2)\n) + allow(File).to receive(:read).with("Gemfile.lock").and_return(lockfile_content) + end + + it "adds a success message and checks gemfile" do + result = checker.check_shakapacker_configuration + expect(result).to be true + expect(checker.messages.any? do |msg| + msg[:type] == :success && msg[:content].include?("Shakapacker 8.2.0") + end).to be true + expect(checker).to have_received(:check_shakapacker_in_gemfile) + end + end + end + + describe "#check_react_on_rails_gem" do + context "when gem is loaded" do + before do + # Mock the ReactOnRails constant and VERSION + stub_const("ReactOnRails", Module.new) + stub_const("ReactOnRails::VERSION", "16.0.0") + end + + it "adds a success message" do + checker.check_react_on_rails_gem + expect(checker.messages.any? do |msg| + msg[:type] == :success && msg[:content].include?("React on Rails gem 16.0.0") + end).to be true + end + end + + context "when gem is not available" do + before do + allow(checker).to receive(:require).with("react_on_rails").and_raise(LoadError) + end + + it "adds an error message" do + checker.check_react_on_rails_gem + expect(checker.errors?).to be true + expect(checker.messages.last[:content]).to include("React on Rails gem is not available") + end + end + end + + describe "#check_version_patterns" do + it "warns about caret version patterns" do + checker.send(:check_version_patterns, "^16.0.0", "16.0.0") + expect(checker.warnings?).to be true + expect(checker.messages.last[:content]).to include("caret (^) version pattern") + end + + it "warns about tilde version patterns" do + checker.send(:check_version_patterns, "~16.0.0", "16.0.0") + expect(checker.warnings?).to be true + expect(checker.messages.last[:content]).to include("tilde (~) version pattern") + end + + it "does not warn about exact versions" do + initial_message_count = checker.messages.count + checker.send(:check_version_patterns, "16.0.0", "16.0.0") + expect(checker.messages.count).to eq(initial_message_count) + end + end + + describe "#check_gemfile_version_patterns" do + context "when Gemfile has version patterns" do + let(:gemfile_content) do + <<~GEMFILE + gem 'rails', '~> 7.0' + gem 'react_on_rails', '~> 16.0' + gem 'other_gem' + GEMFILE + end + + before do + gemfile_path = ENV["BUNDLE_GEMFILE"] || "Gemfile" + allow(File).to receive(:exist?).with(gemfile_path).and_return(true) + allow(File).to receive(:read).with(gemfile_path).and_return(gemfile_content) + stub_const("ReactOnRails::VERSION", "16.0.0") + end + + it "warns about tilde version patterns" do + checker.send(:check_gemfile_version_patterns) + expect(checker.warnings?).to be true + expect(checker.messages.last[:content]).to include("Gemfile uses version pattern") + end + end + + context "when Gemfile has exact versions" do + let(:gemfile_content) do + <<~GEMFILE + gem 'rails', '7.0.0' + gem 'react_on_rails', '16.0.0' + GEMFILE + end + + before do + gemfile_path = ENV["BUNDLE_GEMFILE"] || "Gemfile" + allow(File).to receive(:exist?).with(gemfile_path).and_return(true) + allow(File).to receive(:read).with(gemfile_path).and_return(gemfile_content) + end + + it "does not warn about exact versions" do + initial_message_count = checker.messages.count + checker.send(:check_gemfile_version_patterns) + expect(checker.messages.count).to eq(initial_message_count) + end + end + end + + describe "#check_react_on_rails_npm_package" do + context "when package.json exists with react-on-rails" do + let(:package_json_content) do + { "dependencies" => { "react-on-rails" => "^16.0.0" } }.to_json + end + + before do + allow(File).to receive(:exist?).with("package.json").and_return(true) + allow(File).to receive(:read).with("package.json").and_return(package_json_content) + end + + it "adds a success message" do + checker.check_react_on_rails_npm_package + expect(checker.messages.any? do |msg| + msg[:type] == :success && msg[:content].include?("react-on-rails NPM package") + end).to be true + end + end + + context "when package.json exists without react-on-rails" do + let(:package_json_content) do + { "dependencies" => { "react" => "^18.0.0" } }.to_json + end + + before do + allow(File).to receive(:exist?).with("package.json").and_return(true) + allow(File).to receive(:read).with("package.json").and_return(package_json_content) + end + + it "adds a warning message" do + checker.check_react_on_rails_npm_package + expect(checker.warnings?).to be true + expect(checker.messages.last[:content]).to include("react-on-rails NPM package not found") + end + end + + context "when package.json does not exist" do + before do + allow(File).to receive(:exist?).with("package.json").and_return(false) + end + + it "does not add any messages" do + messages_count_before = checker.messages.count + checker.check_react_on_rails_npm_package + expect(checker.messages.count).to eq(messages_count_before) + end + end + end + + describe "private methods" do + describe "#cli_exists?" do + it "returns true when command exists" do + allow(Open3).to receive(:capture3).with("which", "npm") + .and_return(["", "", instance_double(Process::Status, success?: true)]) + expect(checker.send(:cli_exists?, "npm")).to be true + end + + it "returns false when command does not exist" do + allow(Open3).to receive(:capture3).with("which", "nonexistent") + .and_return(["", "", instance_double(Process::Status, success?: false)]) + expect(checker.send(:cli_exists?, "nonexistent")).to be false + end + end + + describe "#shakapacker_configured?" do + it "returns true when all required files exist" do + files = [ + "bin/shakapacker", + "bin/shakapacker-dev-server", + "config/shakapacker.yml", + "config/webpack/webpack.config.js" + ] + + files.each do |file| + allow(File).to receive(:exist?).with(file).and_return(true) + end + + expect(checker.send(:shakapacker_configured?)).to be true + end + + it "returns false when any required file is missing" do + allow(File).to receive(:exist?).with("bin/shakapacker").and_return(false) + allow(File).to receive(:exist?).with("bin/shakapacker-dev-server").and_return(true) + allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(true) + allow(File).to receive(:exist?).with("config/webpack/webpack.config.js").and_return(true) + + expect(checker.send(:shakapacker_configured?)).to be false + end + end + end + + describe "version reporting" do + describe "#report_dependency_versions" do + let(:package_json) do + { + "dependencies" => { "react" => "^18.2.0" }, + "devDependencies" => { "react-dom" => "^18.2.0" } + } + end + + it "reports React and React DOM versions" do + checker.send(:report_dependency_versions, package_json) + + messages = checker.messages + expect(messages.any? do |msg| + msg[:type] == :success && msg[:content].include?("React ^18.2.0, React DOM ^18.2.0") + end).to be true + end + end + + describe "#report_shakapacker_version" do + context "when Gemfile.lock exists with shakapacker" do + let(:gemfile_lock_content) do + <<~LOCK + GEM + remote: https://rubygems.org/ + specs: + shakapacker (7.1.0) + railties (>= 5.2) + LOCK + end + + before do + allow(File).to receive(:exist?).with("Gemfile.lock").and_return(true) + allow(File).to receive(:read).with("Gemfile.lock").and_return(gemfile_lock_content) + end + + it "reports shakapacker version" do + checker.send(:report_shakapacker_version) + expect(checker.messages.any? do |msg| + msg[:type] == :info && msg[:content].include?("Shakapacker version: 7.1.0") + end).to be true + end + end + end + + describe "#report_webpack_version" do + it "can be called without errors" do + expect { checker.send(:report_webpack_version) }.not_to raise_error + end + end + end +end