diff --git a/lib/react_on_rails/version_checker.rb b/lib/react_on_rails/version_checker.rb index 34e4e1ed95..6e74fe37ab 100644 --- a/lib/react_on_rails/version_checker.rb +++ b/lib/react_on_rails/version_checker.rb @@ -212,19 +212,36 @@ def package_json_location "Package.json location: #{VersionChecker::NodePackageVersion.package_json_path}" end + # rubocop:disable Metrics/ClassLength class NodePackageVersion - attr_reader :package_json + attr_reader :package_json, :yarn_lock, :package_lock def self.build - new(package_json_path) + new(package_json_path, yarn_lock_path, package_lock_path) end def self.package_json_path Rails.root.join(ReactOnRails.configuration.node_modules_location, "package.json") end - def initialize(package_json) + def self.yarn_lock_path + # Lockfiles are in the same directory as package.json + # If node_modules_location is empty, use Rails.root + base_dir = ReactOnRails.configuration.node_modules_location.presence || "" + Rails.root.join(base_dir, "yarn.lock").to_s + end + + def self.package_lock_path + # Lockfiles are in the same directory as package.json + # If node_modules_location is empty, use Rails.root + base_dir = ReactOnRails.configuration.node_modules_location.presence || "" + Rails.root.join(base_dir, "package-lock.json").to_s + end + + def initialize(package_json, yarn_lock = nil, package_lock = nil) @package_json = package_json + @yarn_lock = yarn_lock + @package_lock = package_lock end def raw @@ -238,10 +255,16 @@ def raw deps = parsed["dependencies"] # Check for react-on-rails-pro first (Pro takes precedence) - return @raw = deps["react-on-rails-pro"] if deps.key?("react-on-rails-pro") + if deps.key?("react-on-rails-pro") + @raw = resolve_version(deps["react-on-rails-pro"], "react-on-rails-pro") + return @raw + end # Fall back to react-on-rails - return @raw = deps["react-on-rails"] if deps.key?("react-on-rails") + if deps.key?("react-on-rails") + @raw = resolve_version(deps["react-on-rails"], "react-on-rails") + return @raw + end # Neither package found msg = "No 'react-on-rails' or 'react-on-rails-pro' entry in the dependencies of " \ @@ -314,6 +337,105 @@ def parts private + # Resolve version from lockfiles if available, otherwise use package.json version + # rubocop:disable Metrics/CyclomaticComplexity + def resolve_version(package_json_version, package_name) + # If package.json specifies a local path or URL, don't try to resolve from lockfiles + # Lockfiles may contain placeholder versions like "0.0.0" for local links + return package_json_version if local_path_or_url_version?(package_json_version) + + # Try yarn.lock first + if yarn_lock && File.exist?(yarn_lock) + lockfile_version = version_from_yarn_lock(package_name) + return lockfile_version if lockfile_version + end + + # Try package-lock.json + if package_lock && File.exist?(package_lock) + lockfile_version = version_from_package_lock(package_name) + return lockfile_version if lockfile_version + end + + # Fall back to package.json version + package_json_version + end + # rubocop:enable Metrics/CyclomaticComplexity + + # Check if a version string represents a local path or URL + def local_path_or_url_version?(version) + return false if version.nil? + + version.include?("/") && !version.start_with?("npm:") + end + + # Parse version from yarn.lock + # Looks for entries like: + # react-on-rails@^16.1.1: + # version "16.1.1" + # The pattern ensures exact package name match to avoid matching similar names + # (e.g., "react-on-rails" won't match "react-on-rails-pro") + # rubocop:disable Metrics/CyclomaticComplexity + def version_from_yarn_lock(package_name) + return nil unless yarn_lock && File.exist?(yarn_lock) + + in_package_block = false + File.foreach(yarn_lock) do |line| + # Check if we're starting the block for our package + # Pattern: optionally quoted package name, followed by @, ensuring it's not followed by more word chars + # This prevents "react-on-rails" from matching "react-on-rails-pro" + if line.match?(/^"?#{Regexp.escape(package_name)}@/) + in_package_block = true + next + end + + # If we're in the package block, look for the version line + if in_package_block + # Version line looks like: version "16.1.1" + if (match = line.match(/^\s+version\s+"([^"]+)"/)) + return match[1] + end + + # If we hit a blank line or new package, we've left the block + break if line.strip.empty? || (line[0] != " " && line[0] != "\t") + end + end + + nil + end + # rubocop:enable Metrics/CyclomaticComplexity + + # Parse version from package-lock.json + # Supports both v1 (dependencies) and v2/v3 (packages) formats + # rubocop:disable Metrics/CyclomaticComplexity + def version_from_package_lock(package_name) + return nil unless package_lock && File.exist?(package_lock) + + begin + parsed = JSON.parse(File.read(package_lock)) + + # Try v2/v3 format first (packages) + if parsed["packages"] + # Look for node_modules/package-name entry + node_modules_key = "node_modules/#{package_name}" + package_data = parsed["packages"][node_modules_key] + return package_data["version"] if package_data&.key?("version") + end + + # Fall back to v1 format (dependencies) + if parsed["dependencies"] + dependency_data = parsed["dependencies"][package_name] + # In v1, the dependency can be a hash with a "version" key + return dependency_data["version"] if dependency_data.is_a?(Hash) && dependency_data.key?("version") + end + rescue JSON::ParserError + # If we can't parse the lockfile, fall back to package.json version + nil + end + + nil + end + # rubocop:enable Metrics/CyclomaticComplexity + def package_installed?(package_name) return false unless File.exist?(package_json) @@ -348,5 +470,6 @@ def parsed_package_contents end end end + # rubocop:enable Metrics/ClassLength end end diff --git a/spec/react_on_rails/fixtures/malformed_package-lock.txt b/spec/react_on_rails/fixtures/malformed_package-lock.txt new file mode 100644 index 0000000000..94dae94a7f --- /dev/null +++ b/spec/react_on_rails/fixtures/malformed_package-lock.txt @@ -0,0 +1,5 @@ +{ + "name": "test-app", + "version": "1.0.0", + "this is not valid JSON because of the trailing comma", +} diff --git a/spec/react_on_rails/fixtures/malformed_yarn.lock b/spec/react_on_rails/fixtures/malformed_yarn.lock new file mode 100644 index 0000000000..1a900e1ed1 --- /dev/null +++ b/spec/react_on_rails/fixtures/malformed_yarn.lock @@ -0,0 +1,5 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +This is not a valid yarn.lock format +Just some random text here diff --git a/spec/react_on_rails/fixtures/pro_semver_caret_package-lock.json b/spec/react_on_rails/fixtures/pro_semver_caret_package-lock.json new file mode 100644 index 0000000000..28762c1806 --- /dev/null +++ b/spec/react_on_rails/fixtures/pro_semver_caret_package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "test-app", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "test-app", + "version": "1.0.0", + "dependencies": { + "react-on-rails-pro": "^16.1.1" + } + }, + "node_modules/react-on-rails-pro": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-16.1.1.tgz", + "integrity": "sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=" + } + }, + "dependencies": { + "react-on-rails-pro": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-16.1.1.tgz", + "integrity": "sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=" + } + } +} diff --git a/spec/react_on_rails/fixtures/pro_semver_caret_yarn.lock b/spec/react_on_rails/fixtures/pro_semver_caret_yarn.lock new file mode 100644 index 0000000000..21d166d68f --- /dev/null +++ b/spec/react_on_rails/fixtures/pro_semver_caret_yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +react-on-rails-pro@^16.1.1: + version "16.1.1" + resolved "https://registry.yarnpkg.com/react-on-rails-pro/-/react-on-rails-pro-16.1.1.tgz" + integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz= diff --git a/spec/react_on_rails/fixtures/semver_caret_package-lock.json b/spec/react_on_rails/fixtures/semver_caret_package-lock.json new file mode 100644 index 0000000000..7e93cf7f98 --- /dev/null +++ b/spec/react_on_rails/fixtures/semver_caret_package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "test-app", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "test-app", + "version": "1.0.0", + "dependencies": { + "babel": "^6.3.26", + "react-on-rails": "^1.2.3", + "webpack": "^1.12.8" + } + }, + "node_modules/babel": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel/-/babel-6.23.0.tgz", + "integrity": "sha1-0NHn2APpdHZb7qMjLU4VPA77kPQ=" + }, + "node_modules/react-on-rails": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/react-on-rails/-/react-on-rails-1.2.3.tgz", + "integrity": "sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=" + }, + "node_modules/webpack": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.15.0.tgz", + "integrity": "sha1-v4SbvGJWkYqkKVBKjNJlJQQNqZg=" + } + }, + "dependencies": { + "babel": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel/-/babel-6.23.0.tgz", + "integrity": "sha1-0NHn2APpdHZb7qMjLU4VPA77kPQ=" + }, + "react-on-rails": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/react-on-rails/-/react-on-rails-1.2.3.tgz", + "integrity": "sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=" + }, + "webpack": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.15.0.tgz", + "integrity": "sha1-v4SbvGJWkYqkKVBKjNJlJQQNqZg=" + } + } +} diff --git a/spec/react_on_rails/fixtures/semver_caret_package-lock_v1.json b/spec/react_on_rails/fixtures/semver_caret_package-lock_v1.json new file mode 100644 index 0000000000..7a37c30215 --- /dev/null +++ b/spec/react_on_rails/fixtures/semver_caret_package-lock_v1.json @@ -0,0 +1,23 @@ +{ + "name": "test-app", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "babel": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel/-/babel-6.23.0.tgz", + "integrity": "sha1-0NHn2APpdHZb7qMjLU4VPA77kPQ=" + }, + "react-on-rails": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/react-on-rails/-/react-on-rails-1.2.3.tgz", + "integrity": "sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=" + }, + "webpack": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.15.0.tgz", + "integrity": "sha1-v4SbvGJWkYqkKVBKjNJlJQQNqZg=" + } + } +} diff --git a/spec/react_on_rails/fixtures/semver_caret_yarn.lock b/spec/react_on_rails/fixtures/semver_caret_yarn.lock new file mode 100644 index 0000000000..30253bb854 --- /dev/null +++ b/spec/react_on_rails/fixtures/semver_caret_yarn.lock @@ -0,0 +1,18 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +babel@^6.3.26: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel/-/babel-6.23.0.tgz" + integrity sha1-0NHn2APpdHZb7qMjLU4VPA77kPQ= + +react-on-rails@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-1.2.3.tgz" + integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz= + +webpack@^1.12.8: + version "1.15.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-1.15.0.tgz" + integrity sha1-v4SbvGJWkYqkKVBKjNJlJQQNqZg= diff --git a/spec/react_on_rails/fixtures/semver_exact_package-lock.json b/spec/react_on_rails/fixtures/semver_exact_package-lock.json new file mode 100644 index 0000000000..b414c5f028 --- /dev/null +++ b/spec/react_on_rails/fixtures/semver_exact_package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "test-app", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "test-app", + "version": "1.0.0", + "dependencies": { + "react-on-rails": "16.1.1" + } + }, + "node_modules/react-on-rails": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-on-rails/-/react-on-rails-16.1.1.tgz", + "integrity": "sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=" + } + }, + "dependencies": { + "react-on-rails": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-on-rails/-/react-on-rails-16.1.1.tgz", + "integrity": "sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=" + } + } +} diff --git a/spec/react_on_rails/fixtures/semver_exact_package.json b/spec/react_on_rails/fixtures/semver_exact_package.json new file mode 100644 index 0000000000..e1f313d064 --- /dev/null +++ b/spec/react_on_rails/fixtures/semver_exact_package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "react-on-rails": "16.1.1" + } +} diff --git a/spec/react_on_rails/fixtures/semver_exact_yarn.lock b/spec/react_on_rails/fixtures/semver_exact_yarn.lock new file mode 100644 index 0000000000..9805b945e8 --- /dev/null +++ b/spec/react_on_rails/fixtures/semver_exact_yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +react-on-rails@16.1.1: + version "16.1.1" + resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-16.1.1.tgz" + integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz= diff --git a/spec/react_on_rails/fixtures/similar_packages_package.json b/spec/react_on_rails/fixtures/similar_packages_package.json new file mode 100644 index 0000000000..4cfea6a1f9 --- /dev/null +++ b/spec/react_on_rails/fixtures/similar_packages_package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "react-on-rails": "^1.2.3", + "react-on-rails-pro": "^16.1.1" + } +} diff --git a/spec/react_on_rails/fixtures/similar_packages_yarn.lock b/spec/react_on_rails/fixtures/similar_packages_yarn.lock new file mode 100644 index 0000000000..e7fe1b46f7 --- /dev/null +++ b/spec/react_on_rails/fixtures/similar_packages_yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +react-on-rails-pro@^16.1.1: + version "16.1.1" + resolved "https://registry.yarnpkg.com/react-on-rails-pro/-/react-on-rails-pro-16.1.1.tgz" + integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz= + +react-on-rails@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-1.2.3.tgz" + integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz= diff --git a/spec/react_on_rails/version_checker_spec.rb b/spec/react_on_rails/version_checker_spec.rb index 8f1f9676b1..15b63742b1 100644 --- a/spec/react_on_rails/version_checker_spec.rb +++ b/spec/react_on_rails/version_checker_spec.rb @@ -428,6 +428,140 @@ def check_version_and_raise(node_package_version) end end + describe "Lockfile version resolution" do + context "with semver caret in package.json and yarn.lock" do + let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) } + let(:yarn_lock) { File.expand_path("fixtures/semver_caret_yarn.lock", __dir__) } + let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) } + + describe "#raw" do + it "returns exact version from yarn.lock instead of semver range" do + expect(node_package_version.raw).to eq("1.2.3") + end + end + end + + context "with similar package names in yarn.lock" do + let(:package_json) { File.expand_path("fixtures/similar_packages_package.json", __dir__) } + let(:yarn_lock) { File.expand_path("fixtures/similar_packages_yarn.lock", __dir__) } + let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) } + + describe "#raw" do + it "returns exact version for react-on-rails-pro, not react-on-rails" do + expect(node_package_version.raw).to eq("16.1.1") + end + end + end + + context "with semver caret in package.json and package-lock.json v2" do + let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) } + let(:package_lock) { File.expand_path("fixtures/semver_caret_package-lock.json", __dir__) } + let(:node_package_version) { described_class.new(package_json, nil, package_lock) } + + describe "#raw" do + it "returns exact version from package-lock.json v2 instead of semver range" do + expect(node_package_version.raw).to eq("1.2.3") + end + end + end + + context "with semver caret in package.json and package-lock.json v1" do + let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) } + let(:package_lock) { File.expand_path("fixtures/semver_caret_package-lock_v1.json", __dir__) } + let(:node_package_version) { described_class.new(package_json, nil, package_lock) } + + describe "#raw" do + it "returns exact version from package-lock.json v1 instead of semver range" do + expect(node_package_version.raw).to eq("1.2.3") + end + end + end + + context "with pro package semver caret and yarn.lock" do + let(:package_json) { File.expand_path("fixtures/pro_semver_caret_package.json", __dir__) } + let(:yarn_lock) { File.expand_path("fixtures/pro_semver_caret_yarn.lock", __dir__) } + let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) } + + describe "#raw" do + it "returns exact version from yarn.lock for pro package" do + expect(node_package_version.raw).to eq("16.1.1") + end + end + end + + context "with pro package semver caret and package-lock.json" do + let(:package_json) { File.expand_path("fixtures/pro_semver_caret_package.json", __dir__) } + let(:package_lock) { File.expand_path("fixtures/pro_semver_caret_package-lock.json", __dir__) } + let(:node_package_version) { described_class.new(package_json, nil, package_lock) } + + describe "#raw" do + it "returns exact version from package-lock.json for pro package" do + expect(node_package_version.raw).to eq("16.1.1") + end + end + end + + context "with exact version and yarn.lock" do + let(:package_json) { File.expand_path("fixtures/semver_exact_package.json", __dir__) } + let(:yarn_lock) { File.expand_path("fixtures/semver_exact_yarn.lock", __dir__) } + let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) } + + describe "#raw" do + it "returns exact version from yarn.lock matching package.json" do + expect(node_package_version.raw).to eq("16.1.1") + end + end + end + + context "with semver caret but no lockfile" do + let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) } + let(:node_package_version) { described_class.new(package_json, nil, nil) } + + describe "#raw" do + it "falls back to package.json version when no lockfile exists" do + expect(node_package_version.raw).to eq("^1.2.3") + end + end + end + + context "when both yarn.lock and package-lock.json exist" do + let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) } + let(:yarn_lock) { File.expand_path("fixtures/semver_caret_yarn.lock", __dir__) } + let(:package_lock) { File.expand_path("fixtures/semver_caret_package-lock.json", __dir__) } + let(:node_package_version) { described_class.new(package_json, yarn_lock, package_lock) } + + describe "#raw" do + it "prefers yarn.lock over package-lock.json" do + expect(node_package_version.raw).to eq("1.2.3") + end + end + end + + context "with malformed yarn.lock" do + let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) } + let(:yarn_lock) { File.expand_path("fixtures/malformed_yarn.lock", __dir__) } + let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) } + + describe "#raw" do + it "falls back to package.json version when yarn.lock is malformed" do + expect(node_package_version.raw).to eq("^1.2.3") + end + end + end + + context "with malformed package-lock.json" do + let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) } + let(:package_lock) { File.expand_path("fixtures/malformed_package-lock.txt", __dir__) } + let(:node_package_version) { described_class.new(package_json, nil, package_lock) } + + describe "#raw" do + it "falls back to package.json version when package-lock.json is malformed" do + expect(node_package_version.raw).to eq("^1.2.3") + end + end + end + end + describe "Pro package detection" do context "with react-on-rails package" do let(:package_json) { File.expand_path("fixtures/normal_package.json", __dir__) }