Standardize valid strategy block return types

Valid `strategy` block return types currently vary between
strategies. Some only accept a string whereas others accept a string
or array of strings. [`strategy` blocks also accept a `nil` return
(to simplify early returns) but this was already standardized across
strategies.]

While some strategies only identify one version by default (where a
string is an appropriate return type), it could be that a strategy
block identifies more than one version. In this situation, the
strategy would need to be modified to accept (and work with) an
array from a `strategy` block.

Rather than waiting for this to become a problem, this modifies all
strategies to standardize on allowing `strategy` blocks to return a
string or array of strings (even if only one of these is currently
used in practice). Standardizing valid return types helps to further
simplify the mental model for `strategy` blocks and reduce cognitive
load.

This commit extracts related logic from `#find_versions` into
methods like `#versions_from_content`, which is conceptually similar
to `PageMatch#page_matches` (renamed to `#versions_from_content`
for consistency). This allows us to write tests for the related code
without having to make network requests (or stub them) at this point.
In general, this also helps to better align the structure of
strategies and how the various `#find_versions` methods work with
versions.

There's still more planned work to be done here but this is a step
in the right direction.
This commit is contained in:
Sam Ford 2021-08-10 11:09:55 -04:00
parent cf79ced740
commit 56dd89114d
No known key found for this signature in database
GPG Key ID: 95209E46C7FFDEFE
14 changed files with 534 additions and 171 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
<!DOCTYPE html>
<html>
@ -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

View File

@ -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
<title>#{appcast_data[:title]}</title>
<sparkle:minimumSystemVersion>10.10</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>https://www.example.com/example/1.2.3.html</sparkle:releaseNotesLink>
<pubDate>#{appcast_data[:pub_date]}</pubDate>
<enclosure url="#{appcast_data[:url]}" sparkle:shortVersionString="#{appcast_data[:short_version]}" sparkle:version="#{appcast_data[:version]}" length="12345678" type="application/octet-stream" sparkle:dsaSignature="ABCDEF+GHIJKLMNOPQRSTUVWXYZab/cdefghijklmnopqrst/uvwxyz1234567==" />
</item>
</channel>
@ -37,6 +40,17 @@ describe Homebrew::Livecheck::Strategy::Sparkle do
EOS
}
let(:item) {
Homebrew::Livecheck::Strategy::Sparkle::Item.new(
title: appcast_data[:title],
pub_date: Time.parse(appcast_data[:pub_date]),
url: appcast_data[:url],
bundle_version: Homebrew::BundleVersion.new(appcast_data[:short_version], appcast_data[:version]),
)
}
let(:versions) { [item.bundle_version.nice_version] }
describe "::match?" do
it "returns true for any URL" do
expect(sparkle.match?(url)).to be true
@ -52,10 +66,39 @@ describe Homebrew::Livecheck::Strategy::Sparkle do
it "returns an Item when given XML data" do
expect(item_from_appcast_xml).to be_a(Homebrew::Livecheck::Strategy::Sparkle::Item)
expect(item_from_appcast_xml).to eq(item)
expect(item_from_appcast_xml.title).to eq(appcast_data[:title])
expect(item_from_appcast_xml.pub_date).to eq(Time.parse(appcast_data[:pub_date]))
expect(item_from_appcast_xml.url).to eq(appcast_data[:url])
expect(item_from_appcast_xml.short_version).to eq(appcast_data[:short_version])
expect(item_from_appcast_xml.version).to eq(appcast_data[:version])
end
end
describe "::versions_from_content" do
it "returns an array of version strings when given content" do
expect(sparkle.versions_from_content(appcast_xml)).to eq(versions)
end
it "returns an array of version strings when given content and a block" do
# Returning a string from block
expect(
sparkle.versions_from_content(appcast_xml) do |item|
item.bundle_version&.nice_version&.sub("3", "4")
end,
).to eq([item.bundle_version.nice_version.sub("3", "4")])
# Returning an array of strings from block
expect(sparkle.versions_from_content(appcast_xml) { versions }).to eq(versions)
end
it "allows a nil return from a block" do
expect(sparkle.versions_from_content(appcast_xml) { next }).to eq([])
end
it "errors on an invalid return type from a block" do
expect { sparkle.versions_from_content(appcast_xml) { 123 } }
.to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG)
end
end
end

View File

@ -30,4 +30,20 @@ describe Homebrew::Livecheck::Strategy do
end
end
end
describe "::handle_block_return" do
it "returns an array of version strings when given a valid value" do
expect(strategy.handle_block_return("1.2.3")).to eq(["1.2.3"])
expect(strategy.handle_block_return(["1.2.3", "1.2.4"])).to eq(["1.2.3", "1.2.4"])
end
it "returns an empty array when given a nil value" do
expect(strategy.handle_block_return(nil)).to eq([])
end
it "errors when given an invalid value" do
expect { strategy.handle_block_return(123) }
.to raise_error(TypeError, strategy::INVALID_BLOCK_RETURN_VALUE_MSG)
end
end
end