Sam Ford 86c702abcd
Sparkle: Surface more Item values
Historically, the `Sparkle` strategy's `Item` struct has only
included basic values from the appcast that are commonly useful.
Over time we've selectively added/surfaced more values as we've
encountered outliers that require use of different values in a
`strategy` block.

We now need to use `minimumSystemValue`, so this expands the `Item`
struct to include any appcast value that we could conceivably want
to use in the future. This will hopefully save us from having to make
more modifications to the struct (and related tests) before we can
use a previously-unused value in a `strategy` block.
2023-10-28 14:55:47 -04:00

388 lines
16 KiB
Ruby

# frozen_string_literal: true
require "livecheck/strategy"
require "bundle_version"
describe Homebrew::Livecheck::Strategy::Sparkle do
subject(:sparkle) { described_class }
def create_appcast_xml(items_str = "")
<<~EOS
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Example Changelog</title>
<link>#{appcast_url}</link>
<description>Most recent changes with links to updates.</description>
<language>en</language>
#{items_str}
</channel>
</rss>
EOS
end
let(:appcast_url) { "https://www.example.com/example/appcast.xml" }
let(:non_http_url) { "ftp://brew.sh/" }
# The `item_hashes` data is used to create test appcast XML and expected
# `Sparkle::Item` objects.
let(:item_hashes) do
{
v123: {
title: "Version 1.2.3",
release_notes_link: "https://www.example.com/example/1.2.3.html",
pub_date: "Fri, 01 Jan 2021 01:23:45 +0000",
url: "https://www.example.com/example/example-1.2.3.tar.gz",
short_version: "1.2.3",
version: "123",
minimum_system_version: "10.10",
},
v122: {
title: "Version 1.2.2",
release_notes_link: "https://www.example.com/example/1.2.2.html",
pub_date: "Not a parseable date string",
link: "https://www.example.com/example/example-1.2.2.tar.gz",
short_version: "1.2.2",
version: "122",
minimum_system_version: "10.10",
},
v121: {
title: "Version 1.2.1",
release_notes_link: "https://www.example.com/example/1.2.1.html",
pub_date: "Thu, 31 Dec 2020 01:23:45 +0000",
os: "osx",
url: "https://www.example.com/example/example-1.2.1.tar.gz",
short_version: "1.2.1",
version: "121",
minimum_system_version: "10.10",
},
v120: {
title: "Version 1.2.0",
release_notes_link: "https://www.example.com/example/1.2.0.html",
pub_date: "Wed, 30 Dec 2020 01:23:45 +0000",
os: "macos",
url: "https://www.example.com/example/example-1.2.0.tar.gz",
short_version: "1.2.0",
version: "120",
minimum_system_version: "10.10",
},
}
end
let(:xml) do
v123_item = <<~EOS
<item>
<title>#{item_hashes[:v123][:title]}</title>
<sparkle:minimumSystemVersion>#{item_hashes[:v123][:minimum_system_version]}</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>#{item_hashes[:v123][:release_notes_link]}</sparkle:releaseNotesLink>
<pubDate>#{item_hashes[:v123][:pub_date]}</pubDate>
<enclosure url="#{item_hashes[:v123][:url]}" sparkle:shortVersionString="#{item_hashes[:v123][:short_version]}" sparkle:version="#{item_hashes[:v123][:version]}" length="12345678" type="application/octet-stream" sparkle:dsaSignature="ABCDEF+GHIJKLMNOPQRSTUVWXYZab/cdefghijklmnopqrst/uvwxyz1234567==" />
</item>
EOS
v122_item = <<~EOS
<item>
<title>#{item_hashes[:v122][:title]}</title>
<link>#{item_hashes[:v122][:link]}</link>
<sparkle:minimumSystemVersion>#{item_hashes[:v122][:minimum_system_version]}</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>#{item_hashes[:v122][:release_notes_link]}</sparkle:releaseNotesLink>
<pubDate>#{item_hashes[:v122][:pub_date]}</pubDate>
<sparkle:version>#{item_hashes[:v122][:version]}</sparkle:version>
<sparkle:shortVersionString>#{item_hashes[:v122][:short_version]}</sparkle:shortVersionString>
</item>
EOS
v121_item_with_osx_os = <<~EOS
<item>
<title>#{item_hashes[:v121][:title]}</title>
<sparkle:minimumSystemVersion>#{item_hashes[:v121][:minimum_system_version]}</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>#{item_hashes[:v121][:release_notes_link]}</sparkle:releaseNotesLink>
<pubDate>#{item_hashes[:v121][:pub_date]}</pubDate>
<enclosure os="#{item_hashes[:v121][:os]}" url="#{item_hashes[:v121][:url]}" sparkle:shortVersionString="#{item_hashes[:v121][:short_version]}" sparkle:version="#{item_hashes[:v121][:version]}" length="12345678" type="application/octet-stream" sparkle:dsaSignature="ABCDEF+GHIJKLMNOPQRSTUVWXYZab/cdefghijklmnopqrst/uvwxyz1234567==" />
</item>
EOS
v120_item_with_macos_os = <<~EOS
<item>
<title>#{item_hashes[:v120][:title]}</title>
<sparkle:minimumSystemVersion>#{item_hashes[:v120][:minimum_system_version]}</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>#{item_hashes[:v120][:release_notes_link]}</sparkle:releaseNotesLink>
<pubDate>#{item_hashes[:v120][:pub_date]}</pubDate>
<enclosure os="#{item_hashes[:v120][:os]}" url="#{item_hashes[:v120][:url]}" sparkle:shortVersionString="#{item_hashes[:v120][:short_version]}" sparkle:version="#{item_hashes[:v120][:version]}" length="12345678" type="application/octet-stream" sparkle:dsaSignature="ABCDEF+GHIJKLMNOPQRSTUVWXYZab/cdefghijklmnopqrst/uvwxyz1234567==" />
</item>
EOS
# This main `appcast` data is intended as a relatively normal example.
# As such, it also serves as a base for some other test data.
appcast = create_appcast_xml <<~EOS
#{v123_item}
#{v122_item}
#{v121_item_with_osx_os}
#{v120_item_with_macos_os}
EOS
omitted_items = create_appcast_xml <<~EOS
#{v123_item.sub(%r{<(enclosure[^>]+?)\s*?/>}, '<\1 os="not-osx-or-macos" />')}
#{v123_item.sub(/(<sparkle:minimumSystemVersion>)[^<]+?</m, '\1100<')}
<item>
</item>
EOS
# Set the first item in a copy of `appcast` to the "beta" channel, to test
# filtering items by channel using a `strategy` block.
beta_channel_item = appcast.sub(
v123_item,
v123_item.sub(
"</title>",
"</title>\n<sparkle:channel>beta</sparkle:channel>",
),
)
no_versions_item = create_appcast_xml <<~EOS
<item>
<title>Version</title>
<sparkle:minimumSystemVersion>#{item_hashes[:v123][:minimum_system_version]}</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>#{item_hashes[:v123][:release_notes_link]}</sparkle:releaseNotesLink>
<pubDate>#{item_hashes[:v123][:pub_date]}</pubDate>
<enclosure url="#{item_hashes[:v123][:url]}" length="12345678" type="application/octet-stream" sparkle:dsaSignature="ABCDEF+GHIJKLMNOPQRSTUVWXYZab/cdefghijklmnopqrst/uvwxyz1234567==" />
</item>
EOS
no_items = create_appcast_xml
undefined_namespace = appcast.sub(/\s*xmlns:sparkle="[^"]+"/, "")
{
appcast: appcast,
omitted_items: omitted_items,
beta_channel_item: beta_channel_item,
no_versions_item: no_versions_item,
no_items: no_items,
undefined_namespace: undefined_namespace,
}
end
let(:title_regex) { /Version\s+v?(\d+(?:\.\d+)+)\s*$/i }
let(:items) do
{
v123: Homebrew::Livecheck::Strategy::Sparkle::Item.new(
title: item_hashes[:v123][:title],
release_notes_link: item_hashes[:v123][:release_notes_link],
pub_date: Time.parse(item_hashes[:v123][:pub_date]),
url: item_hashes[:v123][:url],
bundle_version: Homebrew::BundleVersion.new(
item_hashes[:v123][:short_version],
item_hashes[:v123][:version],
),
minimum_system_version: item_hashes[:v123][:minimum_system_version],
),
v122: Homebrew::Livecheck::Strategy::Sparkle::Item.new(
title: item_hashes[:v122][:title],
link: item_hashes[:v122][:link],
release_notes_link: item_hashes[:v122][:release_notes_link],
# `#items_from_content` falls back to a default `pub_date` when
# one isn't provided or can't be successfully parsed.
pub_date: Time.new(0),
url: item_hashes[:v122][:link],
bundle_version: Homebrew::BundleVersion.new(
item_hashes[:v122][:short_version],
item_hashes[:v122][:version],
),
minimum_system_version: item_hashes[:v122][:minimum_system_version],
),
v121: Homebrew::Livecheck::Strategy::Sparkle::Item.new(
title: item_hashes[:v121][:title],
release_notes_link: item_hashes[:v121][:release_notes_link],
pub_date: Time.parse(item_hashes[:v121][:pub_date]),
os: item_hashes[:v121][:os],
url: item_hashes[:v121][:url],
bundle_version: Homebrew::BundleVersion.new(
item_hashes[:v121][:short_version],
item_hashes[:v121][:version],
),
minimum_system_version: item_hashes[:v121][:minimum_system_version],
),
v120: Homebrew::Livecheck::Strategy::Sparkle::Item.new(
title: item_hashes[:v120][:title],
release_notes_link: item_hashes[:v120][:release_notes_link],
pub_date: Time.parse(item_hashes[:v120][:pub_date]),
os: item_hashes[:v120][:os],
url: item_hashes[:v120][:url],
bundle_version: Homebrew::BundleVersion.new(
item_hashes[:v120][:short_version],
item_hashes[:v120][:version],
),
minimum_system_version: item_hashes[:v120][:minimum_system_version],
),
}
end
let(:item_arrays) do
item_arrays = {
appcast: [
items[:v123],
items[:v122],
items[:v121],
items[:v120],
],
appcast_sorted: [
items[:v123],
items[:v121],
items[:v120],
items[:v122],
],
}
beta_channel_item = items[:v123].clone
beta_channel_item.channel = "beta"
item_arrays[:beta_channel_item] = [
beta_channel_item,
items[:v122],
items[:v121],
items[:v120],
]
no_versions_item = items[:v123].clone
no_versions_item.title = "Version"
no_versions_item.bundle_version = nil
item_arrays[:no_versions_item] = [no_versions_item]
item_arrays
end
let(:versions) { [items[:v123].nice_version] }
describe "::match?" do
it "returns true for an HTTP URL" do
expect(sparkle.match?(appcast_url)).to be true
end
it "returns false for a non-HTTP URL" do
expect(sparkle.match?(non_http_url)).to be false
end
end
describe "::items_from_content" do
let(:items_from_appcast) { sparkle.items_from_content(xml[:appcast]) }
it "returns nil if content is blank" do
expect(sparkle.items_from_content("")).to eq([])
end
it "returns an array of Items when given XML data" do
expect(items_from_appcast).to eq(item_arrays[:appcast])
expect(items_from_appcast[0].title).to eq(item_hashes[:v123][:title])
expect(items_from_appcast[0].pub_date).to eq(Time.parse(item_hashes[:v123][:pub_date]))
expect(items_from_appcast[0].url).to eq(item_hashes[:v123][:url])
expect(items_from_appcast[0].short_version).to eq(item_hashes[:v123][:short_version])
expect(items_from_appcast[0].version).to eq(item_hashes[:v123][:version])
expect(sparkle.items_from_content(xml[:beta_channel_item])).to eq(item_arrays[:beta_channel_item])
expect(sparkle.items_from_content(xml[:no_versions_item])).to eq(item_arrays[:no_versions_item])
end
end
# `#versions_from_content` sorts items by `pub_date` and `bundle_version`, so
# these tests have to account for this behavior in the expected output.
# For example, the version 122 item doesn't have a parseable `pub_date` and
# the substituted default will cause it to be sorted last.
describe "::versions_from_content" do
let(:subbed_items) { item_arrays[:appcast_sorted].map { |item| item.nice_version.sub("1", "0") } }
it "returns an array of version strings when given content" do
expect(sparkle.versions_from_content(xml[:appcast])).to eq(versions)
expect(sparkle.versions_from_content(xml[:omitted_items])).to eq([])
expect(sparkle.versions_from_content(xml[:beta_channel_item])).to eq(versions)
expect(sparkle.versions_from_content(xml[:no_versions_item])).to eq([])
expect(sparkle.versions_from_content(xml[:undefined_namespace])).to eq(versions)
end
it "returns an empty array if no items are found" do
expect(sparkle.versions_from_content(xml[:no_items])).to eq([])
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(xml[:appcast]) do |item|
item.nice_version&.sub("1", "0")
end,
).to eq([subbed_items[0]])
# Returning an array of strings from block
expect(
sparkle.versions_from_content(xml[:appcast]) do |items|
items.map { |item| item.nice_version&.sub("1", "0") }
end,
).to eq(subbed_items)
expect(
sparkle.versions_from_content(xml[:beta_channel_item]) do |items|
items.find { |item| item.channel.nil? }&.nice_version
end,
).to eq([items[:v121].nice_version])
end
it "returns an array of version strings when given content, a regex, and a block" do
# Returning a string from the block
expect(
sparkle.versions_from_content(xml[:appcast], title_regex) do |item, regex|
item.title[regex, 1]
end,
).to eq([item_hashes[:v123][:short_version]])
expect(
sparkle.versions_from_content(xml[:appcast], title_regex) do |items, regex|
next if (item = items[0]).blank?
match = item&.title&.match(regex)
next if match.blank?
"#{match[1]},#{item.version}"
end,
).to eq(["#{item_hashes[:v123][:short_version]},#{item_hashes[:v123][:version]}"])
# Returning an array of strings from the block
expect(
sparkle.versions_from_content(xml[:appcast], title_regex) do |item, regex|
[item.title[regex, 1]]
end,
).to eq([item_hashes[:v123][:short_version]])
expect(
sparkle.versions_from_content(xml[:appcast], &:short_version),
).to eq([item_hashes[:v123][:short_version]])
expect(
sparkle.versions_from_content(xml[:appcast], title_regex) do |items, regex|
items.map { |item| item.title[regex, 1] }
end,
).to eq(item_arrays[:appcast_sorted].map(&:short_version))
end
it "allows a nil return from a block" do
expect(
sparkle.versions_from_content(xml[:appcast]) do |item|
_ = item # To appease `brew style` without modifying arg name
next
end,
).to eq([])
end
it "errors on an invalid return type from a block" do
expect do
sparkle.versions_from_content(xml[:appcast]) do |item|
_ = item # To appease `brew style` without modifying arg name
123
end
end.to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG)
end
it "errors if the first block argument uses an unhandled name" do
expect { sparkle.versions_from_content(xml[:appcast]) { |something| something } }
.to raise_error("First argument of Sparkle `strategy` block must be `item` or `items`")
end
end
end