diff --git a/Library/Homebrew/livecheck/strategy.rb b/Library/Homebrew/livecheck/strategy.rb index cb27336dd9..0040bc4821 100644 --- a/Library/Homebrew/livecheck/strategy.rb +++ b/Library/Homebrew/livecheck/strategy.rb @@ -75,6 +75,10 @@ module Homebrew # In rare cases, this can also be a double newline (`\n\n`). HTTP_HEAD_BODY_SEPARATOR = "\r\n\r\n" + # An error message to use when a `strategy` block returns a value of + # an inappropriate type. + INVALID_BLOCK_RETURN_VALUE_MSG = "Return value of a strategy block must be a string or array of strings." + # Creates and/or returns a `@strategies` `Hash`, which maps a snake # case strategy name symbol (e.g. `:page_match`) to the associated # {Strategy}. @@ -218,6 +222,25 @@ module Homebrew messages: [error_msg.presence || "cURL failed without an error"], } end + + # Handles the return value from a `strategy` block in a `livecheck` + # block. + # + # @param value [] the return value from a `strategy` block + # @return [Array] + sig { params(value: T.untyped).returns(T::Array[String]) } + def self.handle_block_return(value) + case value + when String + [value] + when Array + value.compact.uniq + when nil + [] + else + raise TypeError, INVALID_BLOCK_RETURN_VALUE_MSG + end + end end end end diff --git a/Library/Homebrew/livecheck/strategy/electron_builder.rb b/Library/Homebrew/livecheck/strategy/electron_builder.rb index 45626f315d..5c27824235 100644 --- a/Library/Homebrew/livecheck/strategy/electron_builder.rb +++ b/Library/Homebrew/livecheck/strategy/electron_builder.rb @@ -30,34 +30,28 @@ module Homebrew URL_MATCH_REGEX.match?(url) end - # Extract version information from page content. + # Parses YAML text and identifies versions in it. # - # @param content [String] the content to check - # @return [String] + # @param content [String] the YAML text to parse and check + # @return [Array] sig { params( content: String, - block: T.nilable(T.proc.params(arg0: T::Hash[String, T.untyped]).returns(T.nilable(String))), - ).returns(T.nilable(String)) + block: T.nilable( + T.proc.params(arg0: T::Hash[String, T.untyped]).returns(T.any(String, T::Array[String], NilClass)), + ), + ).returns(T::Array[String]) } - def self.version_from_content(content, &block) + def self.versions_from_content(content, &block) require "yaml" yaml = YAML.safe_load(content) - return if yaml.blank? + return [] if yaml.blank? - if block - case (value = block.call(yaml)) - when String - return value - when nil - return - else - raise TypeError, "Return value of `strategy :electron_builder` block must be a string." - end - end + return Strategy.handle_block_return(block.call(yaml)) if block - yaml["version"] + version = yaml["version"] + version.present? ? [version] : [] end # Checks the content at the URL for new versions. @@ -81,8 +75,9 @@ module Homebrew match_data.merge!(Strategy.page_content(url)) content = match_data.delete(:content) - version = version_from_content(content, &block) - match_data[:matches][version] = Version.new(version) if version + versions_from_content(content, &block).each do |version_text| + match_data[:matches][version_text] = Version.new(version_text) + end match_data end diff --git a/Library/Homebrew/livecheck/strategy/extract_plist.rb b/Library/Homebrew/livecheck/strategy/extract_plist.rb index 46b7d12e08..c9a12b0ce6 100644 --- a/Library/Homebrew/livecheck/strategy/extract_plist.rb +++ b/Library/Homebrew/livecheck/strategy/extract_plist.rb @@ -50,13 +50,37 @@ module Homebrew delegate short_version: :bundle_version end - # Checks the content at the URL for new versions. + # Identify versions from `Item`s produced using + # {UnversionedCaskChecker} version information. + # + # @param items [Hash] a hash of `Item`s containing version information + # @return [Array] + sig { + params( + items: T::Hash[String, Item], + block: T.nilable( + T.proc.params(arg0: T::Hash[String, Item]).returns(T.any(String, T::Array[String], NilClass)), + ), + ).returns(T::Array[String]) + } + def self.versions_from_items(items, &block) + return Strategy.handle_block_return(block.call(items)) if block + + items.map do |_key, item| + item.bundle_version.nice_version + end.compact.uniq + end + + # Uses {UnversionedCaskChecker} on the provided cask to identify + # versions from `plist` files. sig { params( url: String, regex: T.nilable(Regexp), cask: Cask::Cask, - block: T.nilable(T.proc.params(arg0: T::Hash[String, Item]).returns(T.nilable(String))), + block: T.nilable( + T.proc.params(arg0: T::Hash[String, Item]).returns(T.any(String, T::Array[String], NilClass)), + ), ).returns(T::Hash[Symbol, T.untyped]) } def self.find_versions(url, regex, cask:, &block) @@ -66,22 +90,10 @@ module Homebrew match_data = { matches: {}, regex: regex, url: url } unversioned_cask_checker = UnversionedCaskChecker.new(cask) - versions = unversioned_cask_checker.all_versions.transform_values { |v| Item.new(bundle_version: v) } + items = unversioned_cask_checker.all_versions.transform_values { |v| Item.new(bundle_version: v) } - if block - case (value = block.call(versions)) - when String - match_data[:matches][value] = Version.new(value) - when nil - return match_data - else - raise TypeError, "Return value of `strategy :extract_plist` block must be a string." - end - elsif versions.any? - versions.each_value do |item| - version = item.bundle_version.nice_version - match_data[:matches][version] = Version.new(version) - end + versions_from_items(items, &block).each do |version_text| + match_data[:matches][version_text] = Version.new(version_text) end match_data diff --git a/Library/Homebrew/livecheck/strategy/git.rb b/Library/Homebrew/livecheck/strategy/git.rb index 50a0e509a2..55c3c05562 100644 --- a/Library/Homebrew/livecheck/strategy/git.rb +++ b/Library/Homebrew/livecheck/strategy/git.rb @@ -30,6 +30,19 @@ module Homebrew # lowest to highest). PRIORITY = 8 + # The default regex used to naively identify numeric versions from tags + # when a regex isn't provided. + DEFAULT_REGEX = /\D*(.+)/.freeze + + # Whether the strategy can be applied to the provided URL. + # + # @param url [String] the URL to match against + # @return [Boolean] + sig { params(url: String).returns(T::Boolean) } + def self.match?(url) + (DownloadStrategyDetector.detect(url) <= GitDownloadStrategy) == true + end + # Fetches a remote Git repository's tags using `git ls-remote --tags` # and parses the command's output. If a regex is provided, it will be # used to filter out any tags that don't match it. @@ -61,12 +74,42 @@ module Homebrew tags_data end - # Whether the strategy can be applied to the provided URL. + # Identify versions from tag strings using a provided regex or the + # `DEFAULT_REGEX`. The regex is expected to use a capture group around + # the version text. # - # @param url [String] the URL to match against - # @return [Boolean] - def self.match?(url) - (DownloadStrategyDetector.detect(url) <= GitDownloadStrategy) == true + # @param tags [Array] the tags to identify versions from + # @param regex [Regexp, nil] a regex to identify versions + # @return [Array] + sig { + params( + tags: T::Array[String], + regex: T.nilable(Regexp), + block: T.nilable( + T.proc.params(arg0: T::Array[String], arg1: T.nilable(Regexp)) + .returns(T.any(String, T::Array[String], NilClass)), + ), + ).returns(T::Array[String]) + } + def self.versions_from_tags(tags, regex = nil, &block) + return Strategy.handle_block_return(block.call(tags, regex || DEFAULT_REGEX)) if block + + tags_only_debian = tags.all? { |tag| tag.start_with?("debian/") } + + tags.map do |tag| + # Skip tag if it has a 'debian/' prefix and upstream does not do + # only 'debian/' prefixed tags + next if tag =~ %r{^debian/} && !tags_only_debian + + if regex + # Use the first capture group (the version) + tag.scan(regex).first&.first + else + # Remove non-digits from the start of the tag and use that as the + # version text + tag[DEFAULT_REGEX, 1] + end + end.compact.uniq end # Checks the Git tags for new versions. When a regex isn't provided, @@ -82,54 +125,26 @@ module Homebrew regex: T.nilable(Regexp), cask: T.nilable(Cask::Cask), block: T.nilable( - T.proc.params(arg0: T::Array[String]).returns(T.any(String, T::Array[String], NilClass)), + T.proc.params(arg0: T::Array[String], arg1: T.nilable(Regexp)) + .returns(T.any(String, T::Array[String], NilClass)), ), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url, regex, cask: nil, &block) + def self.find_versions(url, regex = nil, cask: nil, &block) match_data = { matches: {}, regex: regex, url: url } tags_data = tag_info(url, regex) + tags = tags_data[:tags] if tags_data.key?(:messages) match_data[:messages] = tags_data[:messages] - return match_data if tags_data[:tags].blank? + return match_data if tags.blank? end - tags_only_debian = tags_data[:tags].all? { |tag| tag.start_with?("debian/") } - - if block - case (value = block.call(tags_data[:tags], regex)) - when String - match_data[:matches][value] = Version.new(value) - when Array - value.compact.uniq.each do |tag| - match_data[:matches][tag] = Version.new(tag) - end - when nil - return match_data - else - raise TypeError, "Return value of `strategy :git` block must be a string or array of strings." - end - - return match_data - end - - tags_data[:tags].each do |tag| - # Skip tag if it has a 'debian/' prefix and upstream does not do - # only 'debian/' prefixed tags - next if tag =~ %r{^debian/} && !tags_only_debian - - captures = regex.is_a?(Regexp) ? tag.scan(regex) : [] - tag_cleaned = if captures[0].is_a?(Array) - captures[0][0] # Use the first capture group (the version) - else - tag[/\D*(.*)/, 1] # Remove non-digits from the start of the tag - end - - match_data[:matches][tag] = Version.new(tag_cleaned) + versions_from_tags(tags, regex, &block).each do |version_text| + match_data[:matches][version_text] = Version.new(version_text) rescue TypeError - nil + next end match_data diff --git a/Library/Homebrew/livecheck/strategy/header_match.rb b/Library/Homebrew/livecheck/strategy/header_match.rb index af93c1f34d..66d7bccfbe 100644 --- a/Library/Homebrew/livecheck/strategy/header_match.rb +++ b/Library/Homebrew/livecheck/strategy/header_match.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require_relative "page_match" @@ -36,6 +36,39 @@ module Homebrew URL_MATCH_REGEX.match?(url) end + # Identify versions from HTTP headers. + # + # @param headers [Hash] a hash of HTTP headers to check for versions + # @param regex [Regexp, nil] a regex to use to identify versions + # @return [Array] + sig { + params( + headers: T::Hash[String, String], + regex: T.nilable(Regexp), + block: T.nilable( + T.proc.params( + arg0: T::Hash[String, String], + arg1: T.nilable(Regexp), + ).returns(T.any(String, T::Array[String], NilClass)), + ), + ).returns(T::Array[String]) + } + def self.versions_from_headers(headers, regex = nil, &block) + return Strategy.handle_block_return(block.call(headers, regex)) if block + + DEFAULT_HEADERS_TO_CHECK.map do |header_name| + header_value = headers[header_name] + next if header_value.blank? + + if regex + header_value[regex, 1] + else + v = Version.parse(header_value, detected_from_url: true) + v.null? ? nil : v.to_s + end + end.compact.uniq + end + # Checks the final URL for new versions after following all redirections, # using the provided regex for matching. sig { @@ -43,7 +76,9 @@ module Homebrew url: String, regex: T.nilable(Regexp), cask: T.nilable(Cask::Cask), - block: T.nilable(T.proc.params(arg0: T::Hash[String, String]).returns(T.nilable(String))), + block: T.nilable( + T.proc.params(arg0: T::Hash[String, String], arg1: T.nilable(Regexp)).returns(T.nilable(String)), + ), ).returns(T::Hash[Symbol, T.untyped]) } def self.find_versions(url, regex, cask: nil, &block) @@ -53,36 +88,12 @@ module Homebrew # Merge the headers from all responses into one hash merged_headers = headers.reduce(&:merge) + return match_data if merged_headers.blank? - version = if block - case (value = block.call(merged_headers, regex)) - when String - value - when nil - return match_data - else - raise TypeError, "Return value of `strategy :header_match` block must be a string." - end - else - value = nil - DEFAULT_HEADERS_TO_CHECK.each do |header_name| - header_value = merged_headers[header_name] - next if header_value.blank? - - if regex - value = header_value[regex, 1] - else - v = Version.parse(header_value, detected_from_url: true) - value = v.to_s unless v.null? - end - break if value.present? - end - - value + versions_from_headers(merged_headers, regex, &block).each do |version_text| + match_data[:matches][version_text] = Version.new(version_text) end - match_data[:matches][version] = Version.new(version) if version - match_data end end diff --git a/Library/Homebrew/livecheck/strategy/page_match.rb b/Library/Homebrew/livecheck/strategy/page_match.rb index b71ca9e9e7..873d54a4fa 100644 --- a/Library/Homebrew/livecheck/strategy/page_match.rb +++ b/Library/Homebrew/livecheck/strategy/page_match.rb @@ -54,19 +54,8 @@ module Homebrew ), ).returns(T::Array[String]) } - def self.page_matches(content, regex, &block) - if block - case (value = block.call(content, regex)) - when String - return [value] - when Array - return value.compact.uniq - when nil - return [] - else - raise TypeError, "Return value of `strategy :page_match` block must be a string or array of strings." - end - end + def self.versions_from_content(content, regex, &block) + return Strategy.handle_block_return(block.call(content, regex)) if block content.scan(regex).map do |match| case match @@ -109,7 +98,7 @@ module Homebrew end return match_data if content.blank? - page_matches(content, regex, &block).each do |match_text| + versions_from_content(content, regex, &block).each do |match_text| match_data[:matches][match_text] = Version.new(match_text) end diff --git a/Library/Homebrew/livecheck/strategy/sparkle.rb b/Library/Homebrew/livecheck/strategy/sparkle.rb index a34802405e..9e7cf4f1cf 100644 --- a/Library/Homebrew/livecheck/strategy/sparkle.rb +++ b/Library/Homebrew/livecheck/strategy/sparkle.rb @@ -138,6 +138,26 @@ module Homebrew items.max_by { |item| [item.pub_date, item.bundle_version] } end + # Identify versions from content + # + # @param content [String] the content to pull version information from + # @return [Array] + sig { + params( + content: String, + block: T.nilable(T.proc.params(arg0: Item).returns(T.any(String, T::Array[String], NilClass))), + ).returns(T::Array[String]) + } + def self.versions_from_content(content, &block) + item = item_from_content(content) + return [] if item.blank? + + return Strategy.handle_block_return(block.call(item)) if block + + version = item.bundle_version&.nice_version + version.present? ? [version] : [] + end + # Checks the content at the URL for new versions. sig { params( @@ -155,21 +175,8 @@ module Homebrew match_data.merge!(Strategy.page_content(url)) content = match_data.delete(:content) - if (item = item_from_content(content)) - version = if block - case (value = block.call(item)) - when String - value - when nil - return match_data - else - raise TypeError, "Return value of `strategy :sparkle` block must be a string." - end - else - item.bundle_version&.nice_version - end - - match_data[:matches][version] = Version.new(version) if version + versions_from_content(content, &block).each do |version_text| + match_data[:matches][version_text] = Version.new(version_text) end match_data diff --git a/Library/Homebrew/test/livecheck/strategy/electron_builder_spec.rb b/Library/Homebrew/test/livecheck/strategy/electron_builder_spec.rb index 43ac739c31..d76f53d670 100644 --- a/Library/Homebrew/test/livecheck/strategy/electron_builder_spec.rb +++ b/Library/Homebrew/test/livecheck/strategy/electron_builder_spec.rb @@ -1,7 +1,7 @@ # typed: false # frozen_string_literal: true -require "livecheck/strategy/electron_builder" +require "livecheck/strategy" describe Homebrew::Livecheck::Strategy::ElectronBuilder do subject(:electron_builder) { described_class } @@ -26,6 +26,8 @@ describe Homebrew::Livecheck::Strategy::ElectronBuilder do EOS } + let(:versions) { ["1.2.3"] } + describe "::match?" do it "returns true for any URL pointing to a YAML file" do expect(electron_builder.match?(valid_url)).to be true @@ -36,32 +38,34 @@ describe Homebrew::Livecheck::Strategy::ElectronBuilder do end end - describe "::version_from_content" do - let(:version_from_electron_builder_yaml) { electron_builder.version_from_content(electron_builder_yaml) } - - it "returns nil if content is blank" do - expect(electron_builder.version_from_content("")).to be nil + describe "::versions_from_content" do + it "returns an empty array if content is blank" do + expect(electron_builder.versions_from_content("")).to eq([]) end - it "returns a version string when given YAML data" do - expect(version_from_electron_builder_yaml).to be_a(String) + it "returns an array of version strings when given YAML text" do + expect(electron_builder.versions_from_content(electron_builder_yaml)).to eq(versions) end - it "returns a version string when given YAML data and a block" do - version = electron_builder.version_from_content(electron_builder_yaml) do |yaml| - yaml["version"].sub("3", "4") - end + it "returns an array of version strings when given YAML text and a block" do + # Returning a string from block + expect( + electron_builder.versions_from_content(electron_builder_yaml) do |yaml| + yaml["version"].sub("3", "4") + end, + ).to eq(["1.2.4"]) - expect(version).to eq "1.2.4" + # Returning an array of strings from block + expect(electron_builder.versions_from_content(electron_builder_yaml) { versions }).to eq(versions) end - it "allows a nil return from a strategy block" do - expect(electron_builder.version_from_content(electron_builder_yaml) { next }).to eq(nil) + it "allows a nil return from a block" do + expect(electron_builder.versions_from_content(electron_builder_yaml) { next }).to eq([]) end - it "errors on an invalid return type from a strategy block" do - expect { electron_builder.version_from_content(electron_builder_yaml) { 123 } } - .to raise_error(TypeError, "Return value of `strategy :electron_builder` block must be a string.") + it "errors on an invalid return type from a block" do + expect { electron_builder.versions_from_content(electron_builder_yaml) { 123 } } + .to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG) end end end diff --git a/Library/Homebrew/test/livecheck/strategy/extract_plist_spec.rb b/Library/Homebrew/test/livecheck/strategy/extract_plist_spec.rb new file mode 100644 index 0000000000..383f45b47a --- /dev/null +++ b/Library/Homebrew/test/livecheck/strategy/extract_plist_spec.rb @@ -0,0 +1,72 @@ +# typed: false +# frozen_string_literal: true + +require "livecheck/strategy" +require "bundle_version" + +describe Homebrew::Livecheck::Strategy::ExtractPlist do + subject(:extract_plist) { described_class } + + let(:http_url) { "https://brew.sh/blog/" } + let(:non_http_url) { "ftp://brew.sh/" } + + let(:items) do + { + "first" => extract_plist::Item.new( + bundle_version: Homebrew::BundleVersion.new(nil, "1.2"), + ), + "second" => extract_plist::Item.new( + bundle_version: Homebrew::BundleVersion.new(nil, "1.2.3"), + ), + } + end + + let(:versions) { ["1.2", "1.2.3"] } + + describe "::match?" do + it "returns true for an HTTP URL" do + expect(extract_plist.match?(http_url)).to be true + end + + it "returns false for a non-HTTP URL" do + expect(extract_plist.match?(non_http_url)).to be false + end + end + + describe "::versions_from_items" do + it "returns an empty array if Items hash is empty" do + expect(extract_plist.versions_from_items({})).to eq([]) + end + + it "returns an array of version strings when given Items" do + expect(extract_plist.versions_from_items(items)).to eq(versions) + end + + it "returns an array of version strings when given Items and a block" do + # Returning a string from block + expect( + extract_plist.versions_from_items(items) do |items| + items["first"].version + end, + ).to eq(["1.2"]) + + # Returning an array of strings from block + expect( + extract_plist.versions_from_items(items) do |items| + items.map do |_key, item| + item.bundle_version.nice_version + end + end, + ).to eq(versions) + end + + it "allows a nil return from a block" do + expect(extract_plist.versions_from_items(items) { next }).to eq([]) + end + + it "errors on an invalid return type from a block" do + expect { extract_plist.versions_from_items(items) { 123 } } + .to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG) + end + end +end diff --git a/Library/Homebrew/test/livecheck/strategy/git_spec.rb b/Library/Homebrew/test/livecheck/strategy/git_spec.rb index 9a0996a2d8..2a5f9e03b1 100644 --- a/Library/Homebrew/test/livecheck/strategy/git_spec.rb +++ b/Library/Homebrew/test/livecheck/strategy/git_spec.rb @@ -1,7 +1,7 @@ # typed: false # frozen_string_literal: true -require "livecheck/strategy/git" +require "livecheck/strategy" describe Homebrew::Livecheck::Strategy::Git do subject(:git) { described_class } @@ -9,10 +9,32 @@ describe Homebrew::Livecheck::Strategy::Git do let(:git_url) { "https://github.com/Homebrew/brew.git" } let(:non_git_url) { "https://brew.sh/test" } + let(:tags) { + { + normal: ["brew/1.2", "brew/1.2.1", "brew/1.2.2", "brew/1.2.3", "brew/1.2.4", "1.2.5"], + hyphens: ["brew/1-2", "brew/1-2-1", "brew/1-2-2", "brew/1-2-3", "brew/1-2-4", "1-2-5"], + } + } + + let(:regexes) { + { + standard: /^v?(\d+(?:\.\d+)+)$/i, + hyphens: /^v?(\d+(?:[.-]\d+)+)$/i, + brew: %r{^brew/v?(\d+(?:\.\d+)+)$}i, + } + } + + let(:versions) { + { + default: ["1.2", "1.2.1", "1.2.2", "1.2.3", "1.2.4", "1.2.5"], + standard_regex: ["1.2.5"], + brew_regex: ["1.2", "1.2.1", "1.2.2", "1.2.3", "1.2.4"], + } + } + describe "::tag_info", :needs_network do it "returns the Git tags for the provided remote URL that match the regex provided" do - expect(git.tag_info(git_url, /^v?(\d+(?:\.\d+))$/)) - .not_to be_empty + expect(git.tag_info(git_url, regexes[:standard])).not_to be_empty end end @@ -25,4 +47,46 @@ describe Homebrew::Livecheck::Strategy::Git do expect(git.match?(non_git_url)).to be false end end + + describe "::versions_from_tags" do + it "returns an empty array if tags array is empty" do + expect(git.versions_from_tags([])).to eq([]) + end + + it "returns an array of version strings when given tags" do + expect(git.versions_from_tags(tags[:normal])).to eq(versions[:default]) + expect(git.versions_from_tags(tags[:normal], regexes[:standard])).to eq(versions[:standard_regex]) + expect(git.versions_from_tags(tags[:normal], regexes[:brew])).to eq(versions[:brew_regex]) + end + + it "returns an array of version strings when given tags and a block" do + # Returning a string from block, default strategy regex + expect(git.versions_from_tags(tags[:normal]) { versions[:default].first }).to eq([versions[:default].first]) + + # Returning an array of strings from block, default strategy regex + expect( + git.versions_from_tags(tags[:hyphens]) do |tags, regex| + tags.map { |tag| tag[regex, 1]&.tr("-", ".") } + end, + ).to eq(versions[:default]) + + # Returning an array of strings from block, explicit regex + expect( + git.versions_from_tags(tags[:hyphens], regexes[:hyphens]) do |tags, regex| + tags.map { |tag| tag[regex, 1]&.tr("-", ".") } + end, + ).to eq(versions[:standard_regex]) + + expect(git.versions_from_tags(tags[:hyphens]) { "1.2.3" }).to eq(["1.2.3"]) + end + + it "allows a nil return from a block" do + expect(git.versions_from_tags(tags[:normal]) { next }).to eq([]) + end + + it "errors on an invalid return type from a block" do + expect { git.versions_from_tags(tags[:normal]) { 123 } } + .to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG) + end + end end diff --git a/Library/Homebrew/test/livecheck/strategy/header_match_spec.rb b/Library/Homebrew/test/livecheck/strategy/header_match_spec.rb index fbb38b8366..65b34c5ff4 100644 --- a/Library/Homebrew/test/livecheck/strategy/header_match_spec.rb +++ b/Library/Homebrew/test/livecheck/strategy/header_match_spec.rb @@ -1,16 +1,111 @@ # typed: false # frozen_string_literal: true -require "livecheck/strategy/header_match" +require "livecheck/strategy" describe Homebrew::Livecheck::Strategy::HeaderMatch do subject(:header_match) { described_class } let(:url) { "https://www.example.com/" } + let(:versions) { + versions = { + content_disposition: ["1.2.3"], + location: ["1.2.4"], + } + versions[:content_disposition_and_location] = versions[:content_disposition] + versions[:location] + + versions + } + + let(:headers) { + headers = { + content_disposition: { + "date" => "Fri, 01 Jan 2021 01:23:45 GMT", + "content-type" => "application/x-gzip", + "content-length" => "120", + "content-disposition" => "attachment; filename=brew-#{versions[:content_disposition].first}.tar.gz", + }, + location: { + "date" => "Fri, 01 Jan 2021 01:23:45 GMT", + "content-type" => "text/html; charset=utf-8", + "location" => "https://github.com/Homebrew/brew/releases/tag/#{versions[:location].first}", + "content-length" => "117", + }, + } + headers[:content_disposition_and_location] = headers[:content_disposition].merge(headers[:location]) + + headers + } + + let(:regexes) { + { + archive: /filename=brew[._-]v?(\d+(?:\.\d+)+)\.t/i, + latest: %r{.*?/tag/v?(\d+(?:\.\d+)+)$}i, + loose: /v?(\d+(?:\.\d+)+)/i, + } + } + describe "::match?" do it "returns true for any URL" do expect(header_match.match?(url)).to be true end end + + describe "::versions_from_headers" do + it "returns an empty array if headers hash is empty" do + expect(header_match.versions_from_headers({})).to eq([]) + end + + it "returns an array of version strings when given headers" do + expect(header_match.versions_from_headers(headers[:content_disposition])).to eq(versions[:content_disposition]) + expect(header_match.versions_from_headers(headers[:location])).to eq(versions[:location]) + expect(header_match.versions_from_headers(headers[:content_disposition_and_location])) + .to eq(versions[:content_disposition_and_location]) + + expect(header_match.versions_from_headers(headers[:content_disposition], regexes[:archive])) + .to eq(versions[:content_disposition]) + expect(header_match.versions_from_headers(headers[:location], regexes[:latest])).to eq(versions[:location]) + expect(header_match.versions_from_headers(headers[:content_disposition_and_location], regexes[:latest])) + .to eq(versions[:location]) + end + + it "returns an array of version strings when given headers and a block" do + # Returning a string from block, no regex + expect( + header_match.versions_from_headers(headers[:location]) do |headers| + v = Version.parse(headers["location"], detected_from_url: true) + v.null? ? nil : v.to_s + end, + ).to eq(versions[:location]) + + # Returning a string from block, explicit regex + expect( + header_match.versions_from_headers(headers[:location], regexes[:latest]) do |headers, regex| + headers["location"] ? headers["location"][regex, 1] : nil + end, + ).to eq(versions[:location]) + + # Returning an array of strings from block + # NOTE: Strategies runs `#compact` on an array from a block, so nil + # values are filtered out without needing to use `#compact` in the block. + expect( + header_match.versions_from_headers( + headers[:content_disposition_and_location], + regexes[:loose], + ) do |headers, regex| + headers.transform_values { |header| header[regex, 1] }.values + end, + ).to eq(versions[:content_disposition_and_location]) + end + + it "allows a nil return from a block" do + expect(header_match.versions_from_headers(headers[:location]) { next }).to eq([]) + end + + it "errors on an invalid return type from a block" do + expect { header_match.versions_from_headers(headers) { 123 } } + .to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG) + end + end end diff --git a/Library/Homebrew/test/livecheck/strategy/page_match_spec.rb b/Library/Homebrew/test/livecheck/strategy/page_match_spec.rb index 24e5705d0e..b9222ff2fc 100644 --- a/Library/Homebrew/test/livecheck/strategy/page_match_spec.rb +++ b/Library/Homebrew/test/livecheck/strategy/page_match_spec.rb @@ -1,7 +1,7 @@ # typed: false # frozen_string_literal: true -require "livecheck/strategy/page_match" +require "livecheck/strategy" describe Homebrew::Livecheck::Strategy::PageMatch do subject(:page_match) { described_class } @@ -9,7 +9,7 @@ describe Homebrew::Livecheck::Strategy::PageMatch do let(:url) { "https://brew.sh/blog/" } let(:regex) { %r{href=.*?/homebrew[._-]v?(\d+(?:\.\d+)+)/?["' >]}i } - let(:page_content) { + let(:content) { <<~EOS @@ -35,7 +35,7 @@ describe Homebrew::Livecheck::Strategy::PageMatch do EOS } - let(:page_content_matches) { ["2.6.0", "2.5.0", "2.4.0", "2.3.0", "2.2.0", "2.1.0", "2.0.0", "1.9.0"] } + let(:content_matches) { ["2.6.0", "2.5.0", "2.4.0", "2.3.0", "2.2.0", "2.1.0", "2.0.0", "1.9.0"] } let(:find_versions_return_hash) { { @@ -66,24 +66,41 @@ describe Homebrew::Livecheck::Strategy::PageMatch do end end - describe "::page_matches" do - it "finds matching text in page content using a regex" do - expect(page_match.page_matches(page_content, regex)).to eq(page_content_matches) + describe "::versions_from_content" do + it "returns an empty array if content is blank" do + expect(page_match.versions_from_content("", regex)).to eq([]) end - it "finds matching text in page content using a strategy block" do - expect(page_match.page_matches(page_content, regex) { |content, regex| content.scan(regex).map(&:first).uniq }) - .to eq(page_content_matches) + it "returns an array of version strings when given content" do + expect(page_match.versions_from_content(content, regex)).to eq(content_matches) + + # Regexes should use a capture group around the version but a regex + # without one should still be handled + expect(page_match.versions_from_content(content, /\d+(?:\.\d+)+/i)).to eq(content_matches) end - it "allows a nil return from a strategy block" do - expect(page_match.page_matches(page_content, regex) { next }).to eq([]) + it "returns an array of version strings when given content and a block" do + # Returning a string from block + expect(page_match.versions_from_content(content, regex) { "1.2.3" }).to eq(["1.2.3"]) + + # Returning an array of strings from block + expect(page_match.versions_from_content(content, regex) { |page, regex| page.scan(regex).map(&:first) }) + .to eq(content_matches) + end + + it "allows a nil return from a block" do + expect(page_match.versions_from_content(content, regex) { next }).to eq([]) + end + + it "errors on an invalid return type from a block" do + expect { page_match.versions_from_content(content, regex) { 123 } } + .to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG) end end describe "::find_versions?" do it "finds versions in provided_content" do - expect(page_match.find_versions(url, regex, provided_content: page_content)) + expect(page_match.find_versions(url, regex, provided_content: content)) .to eq(find_versions_cached_return_hash) end end diff --git a/Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb b/Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb index 47d7f00df5..0931c98ab2 100644 --- a/Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb +++ b/Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb @@ -1,7 +1,8 @@ # typed: false # frozen_string_literal: true -require "livecheck/strategy/sparkle" +require "livecheck/strategy" +require "bundle_version" describe Homebrew::Livecheck::Strategy::Sparkle do subject(:sparkle) { described_class } @@ -11,6 +12,7 @@ describe Homebrew::Livecheck::Strategy::Sparkle do let(:appcast_data) { { title: "Version 1.2.3", + pub_date: "Fri, 01 Jan 2021 01:23:45 +0000", url: "https://www.example.com/example/example.tar.gz", short_version: "1.2.3", version: "1234", @@ -30,6 +32,7 @@ describe Homebrew::Livecheck::Strategy::Sparkle do