From 851b2a1cfbce6d54d4f27c2f9e5bc13fd8c8fe4c Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Fri, 31 Jul 2026 13:07:38 +0100 Subject: [PATCH] Switch test suite from Minitest to RSpec --- .github/workflows/regenerate.yml | 6 +- .github/workflows/validate.yml | 4 +- .gitignore | 2 + .rspec | 1 + Gemfile | 6 + Gemfile.lock | 39 +++ Rakefile | 11 +- {test => spec}/fixtures/repology_page1.json | 0 {test => spec}/fixtures/repology_page2.json | 0 spec/repology_index_spec.rb | 328 ++++++++++++++++++++ spec/spec_helper.rb | 23 ++ test/repology_index_test.rb | 324 ------------------- test/test_helper.rb | 16 - 13 files changed, 409 insertions(+), 351 deletions(-) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 Gemfile create mode 100644 Gemfile.lock rename {test => spec}/fixtures/repology_page1.json (100%) rename {test => spec}/fixtures/repology_page2.json (100%) create mode 100644 spec/repology_index_spec.rb create mode 100644 spec/spec_helper.rb delete mode 100644 test/repology_index_test.rb delete mode 100644 test/test_helper.rb diff --git a/.github/workflows/regenerate.yml b/.github/workflows/regenerate.yml index d0404743c2..777b865362 100644 --- a/.github/workflows/regenerate.yml +++ b/.github/workflows/regenerate.yml @@ -23,12 +23,14 @@ jobs: - name: Set up Ruby uses: Homebrew/actions/setup-ruby@df4b09108a1de9d6f995fe68f302b3f68bd6d2ef # 2026.07.20.1 + with: + bundler-cache: true - name: Run tests - run: rake test + run: bundle exec rake spec - name: Build Repology index - run: rake repology:build + run: bundle exec rake repology:build - name: Regenerate advisories env: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7356a84268..02f7e28940 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -40,9 +40,11 @@ jobs: - name: Set up Ruby uses: Homebrew/actions/setup-ruby@df4b09108a1de9d6f995fe68f302b3f68bd6d2ef # 2026.07.20.1 + with: + bundler-cache: true - name: Run tests - run: rake test + run: bundle exec rake spec # RuboCop is not run: the org-synced .rubocop.yml sets # `AllCops: Include: ["**/*.rbi"]` which replaces (not extends) the diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..f305d48855 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.bundle/ +/vendor/bundle/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000000..c99d2e7396 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..6eba972731 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "rake" +gem "rspec" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000..6afb3c08db --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,39 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.6.2) + rake (13.4.2) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + rake + rspec + +CHECKSUMS + bundler (4.0.17) sha256=214e21431b5665dd2f99df8a5511c6b151d7a72e8015c8b38f8b775b61cbb6c1 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + +BUNDLED WITH + 4.0.17 diff --git a/Rakefile b/Rakefile index 608c620ce4..aaadd17d93 100644 --- a/Rakefile +++ b/Rakefile @@ -1,15 +1,10 @@ # frozen_string_literal: true -require "rake/testtask" +require "rspec/core/rake_task" -Rake::TestTask.new(:test) do |t| - t.libs << "lib" - t.libs << "test" - t.test_files = FileList["test/**/*_test.rb"] - t.warning = false -end +RSpec::Core::RakeTask.new(:spec) -task default: :test +task default: :spec namespace :repology do desc "Build data/repology.json from the Repology API" diff --git a/test/fixtures/repology_page1.json b/spec/fixtures/repology_page1.json similarity index 100% rename from test/fixtures/repology_page1.json rename to spec/fixtures/repology_page1.json diff --git a/test/fixtures/repology_page2.json b/spec/fixtures/repology_page2.json similarity index 100% rename from test/fixtures/repology_page2.json rename to spec/fixtures/repology_page2.json diff --git a/spec/repology_index_spec.rb b/spec/repology_index_spec.rb new file mode 100644 index 0000000000..7930502735 --- /dev/null +++ b/spec/repology_index_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require "repology_index" + +RSpec.describe RepologyIndex do + def recording_fetcher(pages_by_path) + calls = [] + fetcher = lambda do |path| + calls << path + pages_by_path.fetch(path) { raise "unexpected fetch: #{path}" } + end + [fetcher, calls] + end + + def new_index(fetcher:, **opts) + described_class.new(fetcher:, sleeper: ->(_) {}, logger: nil, **opts) + end + + def full_page(base) + padding = (1..(described_class::PAGE_SIZE - base.size)).to_h { |i| ["~pad#{i}", []] } + base.merge(padding) + end + + describe ".osv_distro" do + it "maps known prefixes" do + { + "debian_12" => "Debian", + "ubuntu_24_04" => "Ubuntu", + "alpine_edge" => "Alpine", + "opensuse_tumbleweed" => "openSUSE", + "rocky_9" => "Rocky Linux", + "almalinux_9" => "AlmaLinux", + "mageia_cauldron" => "Mageia", + "openeuler_24_03" => "openEuler", + "ubi_8" => "Red Hat", + "freebsd" => "FreeBSD", + }.each do |repo, eco| + expect(described_class.osv_distro(repo).fetch(:ecosystem)).to eq(eco), "repo=#{repo}" + end + end + + it "returns nil for unmapped repos" do + %w[scoop homebrew fedora_44 centos_8].each do |repo| + expect(described_class.osv_distro(repo)).to be_nil + end + end + + it "rejects openSUSE addon repos" do + expect(described_class.osv_distro("opensuse_games_tumbleweed")).to be_nil + expect(described_class.osv_distro("opensuse_network_tumbleweed")).to be_nil + expect(described_class.osv_distro("opensuse_leap_15_6").fetch(:ecosystem)).to eq("openSUSE") + end + end + + describe ".homebrew_entries" do + it "returns name to preferred flag" do + entries = fixture("repology_page1.json")["apache-ant"] + expect(described_class.homebrew_entries(entries)).to eq("ant" => true, "ant@1.9" => true) + end + + it "marks rolling as not preferred" do + entries = [ + { "repo" => "homebrew", "srcname" => "allegro", "status" => "rolling" }, + { "repo" => "homebrew", "srcname" => "allegro", "status" => "legacy" }, + ] + expect(described_class.homebrew_entries(entries)).to eq("allegro" => false) + end + + it "is empty without a homebrew entry" do + entries = [{ "repo" => "debian_12", "srcname" => "foo" }] + expect(described_class.homebrew_entries(entries)).to be_empty + end + end + + describe ".ambiguous_homebrew_set?" do + it "is false for versioned variants" do + expect(described_class.ambiguous_homebrew_set?(["ant", "ant@1.9"])).to be(false) + expect(described_class.ambiguous_homebrew_set?(["postgresql@16", "postgresql@17"])).to be(false) + expect(described_class.ambiguous_homebrew_set?(["curl"])).to be(false) + end + + it "is true for distinct upstreams" do + expect(described_class.ambiguous_homebrew_set?(["antlr", "antlr4-cpp-runtime"])).to be(true) + expect(described_class.ambiguous_homebrew_set?(["ansible", "ansible-lint"])).to be(true) + end + end + + describe ".distil" do + it "collapses versioned repos to sorted arrays per ecosystem" do + entries = fixture("repology_page1.json")["curl"] + result = described_class.distil(entries) + expect(result).to eq( + "Alpine" => ["curl"], + "Debian" => ["curl"], + "FreeBSD" => ["curl"], + "Red Hat" => ["curl"], + "Ubuntu" => ["curl"], + "openEuler" => ["curl"], + "openSUSE" => ["curl"], + ) + expect(result.keys).to eq(result.keys.sort) + end + + it "uses binname for FreeBSD" do + entries = [{ "repo" => "freebsd", "srcname" => "ftp/curl", "binname" => "curl" }] + expect(described_class.distil(entries)).to eq("FreeBSD" => ["curl"]) + end + + it "collects all distinct srcnames per ecosystem" do + entries = fixture("repology_page1.json")["ack"] + expect(described_class.distil(entries)).to eq("Ubuntu" => ["ack", "ack-grep"]) + end + + it "drops legacy status" do + entries = [ + { "repo" => "alpine_3_17", "srcname" => "old-name", "status" => "legacy" }, + { "repo" => "alpine_3_22", "srcname" => "curl", "status" => "newest" }, + ] + expect(described_class.distil(entries)).to eq("Alpine" => ["curl"]) + end + + it "falls back to binname when srcname absent" do + entries = fixture("repology_page2.json")["zstd"] + expect(described_class.distil(entries)["Mageia"]).to eq(["zstd"]) + end + + it "uses distro-specific srcname" do + entries = fixture("repology_page1.json")["libgee"] + expect(described_class.distil(entries)).to eq("Alpine" => ["libgee"], "Debian" => ["libgee-0.8"]) + end + + it "skips entries without a name" do + entries = [{ "repo" => "debian_12", "status" => "newest" }] + expect(described_class.distil(entries)).to be_empty + end + end + + describe "#build" do + it "resolves cross-project collision via preferred status" do + page = { + "allegro" => [{ "repo" => "homebrew", "srcname" => "allegro", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "allegro5" }], + "allegro4" => [{ "repo" => "homebrew", "srcname" => "allegro", "status" => "rolling" }, + { "repo" => "debian_12", "srcname" => "allegro4.4" }], + } + fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => page) + result = new_index(fetcher:).build + expect(result["formulae"]["allegro"]).to eq("Debian" => ["allegro5"]) + expect(result["meta"]["colliding_formulae"]).to be_empty + end + + it "dedups boundary project by name even when payload differs" do + boundary = "~z-boundary" + page1 = full_page(boundary => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "foo" }]) + page2 = { boundary => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "foo" }, + { "repo" => "alpine_3_22", "srcname" => "foo" }] } + fetcher, = recording_fetcher( + "/projects/?inrepo=homebrew" => page1, + "/projects/#{URI.encode_uri_component(boundary)}/?inrepo=homebrew" => page2, + ) + result = new_index(fetcher:).build + expect(result["formulae"]["foo"]).to eq("Debian" => ["foo"]) + expect(result["meta"]["colliding_formulae"]).to be_empty + end + + it "records unresolvable cross-project collision" do + page = { + "p1" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "foo1" }], + "p2" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "outdated" }, + { "repo" => "debian_12", "srcname" => "foo2" }], + } + fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => page) + result = new_index(fetcher:).build + expect(result["formulae"]).not_to include("foo") + expect(result["meta"]["colliding_formulae"]).to eq("foo" => ["p1", "p2"]) + end + + it "paginates by max key and aggregates all homebrew names" do + page1 = full_page(fixture("repology_page1.json")) + page2 = fixture("repology_page2.json") + max_key = page1.keys.max + fetcher, calls = recording_fetcher( + "/projects/?inrepo=homebrew" => page1, + "/projects/#{URI.encode_uri_component(max_key)}/?inrepo=homebrew" => page2, + ) + + result = new_index(fetcher:).build + + expect(calls).to eq( + ["/projects/?inrepo=homebrew", + "/projects/#{URI.encode_uri_component(max_key)}/?inrepo=homebrew"], + ) + expect(result["formulae"].keys) + .to eq(["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee", "zstd"]) + expect(result["formulae"]["ant@1.9"]).to eq(result["formulae"]["ant"]) + expect(result["formulae"]["zstd"]) + .to eq("AlmaLinux" => ["zstd"], "Mageia" => ["zstd"], "Rocky Linux" => ["zstd"]) + end + + it "stops on first short page" do + fetcher, calls = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) + result = new_index(fetcher:).build + expect(calls.size).to eq(1) + expect(result["formulae"].keys) + .to eq(["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee"]) + end + + it "respects page_limit" do + page = full_page("only" => [{ "repo" => "homebrew", "srcname" => "only" }, + { "repo" => "debian_12", "srcname" => "only" }]) + fetcher, calls = recording_fetcher("/projects/?inrepo=homebrew" => page) + new_index(fetcher:, page_limit: 1).build + expect(calls.size).to eq(1) + end + + it "omits formulae with no mapped distros" do + fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) + expect(new_index(fetcher:).build["formulae"]).not_to include("brew-only-tool") + end + + it "records ambiguous projects and indexes each sibling with shared srcnames" do + fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) + result = new_index(fetcher:).build + expected = { "Debian" => ["antlr3", "antlr4", "antlr4-cpp-runtime"] } + expect(result["formulae"]["antlr"]).to eq(expected) + expect(result["formulae"]["antlr4-cpp-runtime"]).to eq(expected) + expect(result["meta"]["ambiguous_projects"]).to eq("antlr" => ["antlr", "antlr4-cpp-runtime"]) + end + + it "logs ambiguous and colliding" do + logger = StringIO.new + page = fixture("repology_page1.json").merge( + "p1" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "foo1" }], + "p2" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "foo2" }], + ) + fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => page) + described_class.new(fetcher:, sleeper: ->(_) {}, logger:).build + expect(logger.string).to match(/1 project grouping sibling formulae .*repology-rules.*: antlr\b/) + expect(logger.string).to match(/skipped 1 cross-project formula .*: foo\b/) + end + + it "produces deterministic meta" do + fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) + meta = new_index(fetcher:).build["meta"] + expect(meta["source"]).to eq(described_class::API_BASE) + expect(meta["osv_distros"]).to eq(described_class::OSV_DISTROS.map { |_, v| v[:ecosystem] }.uniq.sort) + expect(meta.keys.sort).to eq(%w[ambiguous_projects colliding_formulae osv_distros source]) + end + end + + describe "#write" do + it "creates output directory and emits pretty json" do + Dir.mktmpdir do |dir| + out = File.join(dir, "sub", "repology.json") + fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) + new_index(fetcher:).write(out) + parsed = JSON.parse(File.read(out)) + expect(parsed["formulae"].keys) + .to eq(["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee"]) + expect(File.read(out)).to end_with("\n") + end + end + end + + describe "#http_get" do + let(:scripted_class) do + # Test double replacing #http_request with a scripted sequence so retry + # behaviour is exercised without touching the network. + Class.new(described_class) do + attr_reader :attempts + + def initialize(responses:) + super(sleeper: ->(_) {}, logger: nil) + @responses = responses + @attempts = 0 + end + + def http_request(_uri) + @attempts += 1 + action = @responses.shift + raise "scripted responses exhausted" unless action + + action.respond_to?(:call) ? action.call : action + end + end + end + + def ok(body) + Net::HTTPOK.new("1.1", "200", "OK").tap do |r| + r.instance_variable_set(:@body, body) + r.instance_variable_set(:@read, true) + end + end + + it "retries server error then succeeds" do + idx = scripted_class.new(responses: [ + Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable"), + ok("{}"), + ]) + expect(idx.http_get("/x")).to eq("{}") + expect(idx.attempts).to eq(2) + end + + it "retries transient network error then succeeds" do + idx = scripted_class.new(responses: [-> { raise EOFError }, ok("{}")]) + expect(idx.http_get("/x")).to eq("{}") + expect(idx.attempts).to eq(2) + end + + it "raises after exhausting retries" do + responses = Array.new(described_class::MAX_ATTEMPTS) { -> { raise Errno::ETIMEDOUT } } + idx = scripted_class.new(responses:) + expect { idx.http_get("/x") }.to raise_error(described_class::Error, /Errno::ETIMEDOUT/) + expect(idx.attempts).to eq(described_class::MAX_ATTEMPTS) + end + + it "does not retry client errors" do + idx = scripted_class.new(responses: [Net::HTTPForbidden.new("1.1", "403", "Forbidden")]) + expect { idx.http_get("/x") }.to raise_error(described_class::Error) + expect(idx.attempts).to eq(1) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000000..18d69a6e9b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "json" +require "tmpdir" + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +# Shared helpers for the advisory-database spec suite. +module SpecHelpers + FIXTURE_DIR = File.expand_path("fixtures", __dir__).freeze + + def fixture(name) + JSON.parse(File.read(File.join(FIXTURE_DIR, name))) + end +end + +RSpec.configure do |config| + config.include SpecHelpers + config.disable_monkey_patching! + config.warnings = false + config.order = :random + Kernel.srand config.seed +end diff --git a/test/repology_index_test.rb b/test/repology_index_test.rb deleted file mode 100644 index 85ad01aeb8..0000000000 --- a/test/repology_index_test.rb +++ /dev/null @@ -1,324 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" -require "repology_index" - -# Tests for {RepologyIndex}. -class RepologyIndexTest < Minitest::Test - def recording_fetcher(pages_by_path) - calls = [] - fetcher = lambda do |path| - calls << path - pages_by_path.fetch(path) { flunk "unexpected fetch: #{path}" } - end - [fetcher, calls] - end - - def new_index(fetcher:, **opts) - RepologyIndex.new(fetcher:, sleeper: ->(_) {}, logger: nil, **opts) - end - - def full_page(base) - padding = (1..(RepologyIndex::PAGE_SIZE - base.size)).to_h { |i| ["~pad#{i}", []] } - base.merge(padding) - end - - def test_osv_distro_maps_known_prefixes - { - "debian_12" => "Debian", - "ubuntu_24_04" => "Ubuntu", - "alpine_edge" => "Alpine", - "opensuse_tumbleweed" => "openSUSE", - "rocky_9" => "Rocky Linux", - "almalinux_9" => "AlmaLinux", - "mageia_cauldron" => "Mageia", - "openeuler_24_03" => "openEuler", - "ubi_8" => "Red Hat", - "freebsd" => "FreeBSD", - }.each do |repo, eco| - assert_equal eco, RepologyIndex.osv_distro(repo).fetch(:ecosystem), "repo=#{repo}" - end - end - - def test_osv_distro_nil_for_unmapped_repo - %w[scoop homebrew fedora_44 centos_8].each do |repo| - assert_nil RepologyIndex.osv_distro(repo) - end - end - - def test_osv_distro_rejects_opensuse_addon_repos - assert_nil RepologyIndex.osv_distro("opensuse_games_tumbleweed") - assert_nil RepologyIndex.osv_distro("opensuse_network_tumbleweed") - assert_equal "openSUSE", RepologyIndex.osv_distro("opensuse_leap_15_6").fetch(:ecosystem) - end - - def test_homebrew_entries_returns_name_to_preferred_flag - entries = fixture("repology_page1.json")["apache-ant"] - assert_equal({ "ant" => true, "ant@1.9" => true }, RepologyIndex.homebrew_entries(entries)) - end - - def test_homebrew_entries_marks_rolling_as_not_preferred - entries = [ - { "repo" => "homebrew", "srcname" => "allegro", "status" => "rolling" }, - { "repo" => "homebrew", "srcname" => "allegro", "status" => "legacy" }, - ] - assert_equal({ "allegro" => false }, RepologyIndex.homebrew_entries(entries)) - end - - def test_ambiguous_homebrew_set_false_for_versioned_variants - refute RepologyIndex.ambiguous_homebrew_set?(["ant", "ant@1.9"]) - refute RepologyIndex.ambiguous_homebrew_set?(["postgresql@16", "postgresql@17"]) - refute RepologyIndex.ambiguous_homebrew_set?(["curl"]) - end - - def test_ambiguous_homebrew_set_true_for_distinct_upstreams - assert RepologyIndex.ambiguous_homebrew_set?(["antlr", "antlr4-cpp-runtime"]) - assert RepologyIndex.ambiguous_homebrew_set?(["ansible", "ansible-lint"]) - end - - def test_build_resolves_cross_project_collision_via_preferred_status - page = { - "allegro" => [{ "repo" => "homebrew", "srcname" => "allegro", "status" => "newest" }, - { "repo" => "debian_12", "srcname" => "allegro5" }], - "allegro4" => [{ "repo" => "homebrew", "srcname" => "allegro", "status" => "rolling" }, - { "repo" => "debian_12", "srcname" => "allegro4.4" }], - } - fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => page) - result = new_index(fetcher:).build - assert_equal({ "Debian" => ["allegro5"] }, result["formulae"]["allegro"]) - assert_empty result["meta"]["colliding_formulae"] - end - - def test_build_dedups_boundary_project_by_name_even_when_payload_differs - boundary = "~z-boundary" - page1 = full_page(boundary => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, - { "repo" => "debian_12", "srcname" => "foo" }]) - page2 = { boundary => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, - { "repo" => "debian_12", "srcname" => "foo" }, - { "repo" => "alpine_3_22", "srcname" => "foo" }] } - fetcher, = recording_fetcher( - "/projects/?inrepo=homebrew" => page1, - "/projects/#{URI.encode_uri_component(boundary)}/?inrepo=homebrew" => page2, - ) - result = new_index(fetcher:).build - assert_equal({ "Debian" => ["foo"] }, result["formulae"]["foo"]) - assert_empty result["meta"]["colliding_formulae"] - end - - def test_build_records_unresolvable_cross_project_collision - page = { - "p1" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, - { "repo" => "debian_12", "srcname" => "foo1" }], - "p2" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "outdated" }, - { "repo" => "debian_12", "srcname" => "foo2" }], - } - fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => page) - result = new_index(fetcher:).build - refute_includes result["formulae"], "foo" - assert_equal({ "foo" => ["p1", "p2"] }, result["meta"]["colliding_formulae"]) - end - - def test_homebrew_entries_empty_without_homebrew_entry - entries = [{ "repo" => "debian_12", "srcname" => "foo" }] - assert_empty RepologyIndex.homebrew_entries(entries) - end - - def test_distil_collapses_versioned_repos_to_sorted_arrays_per_ecosystem - entries = fixture("repology_page1.json")["curl"] - result = RepologyIndex.distil(entries) - assert_equal( - { - "Alpine" => ["curl"], - "Debian" => ["curl"], - "FreeBSD" => ["curl"], - "Red Hat" => ["curl"], - "Ubuntu" => ["curl"], - "openEuler" => ["curl"], - "openSUSE" => ["curl"], - }, - result, - ) - assert_equal result.keys.sort, result.keys - end - - def test_distil_freebsd_uses_binname - entries = [{ "repo" => "freebsd", "srcname" => "ftp/curl", "binname" => "curl" }] - assert_equal({ "FreeBSD" => ["curl"] }, RepologyIndex.distil(entries)) - end - - def test_distil_collects_all_distinct_srcnames_per_ecosystem - entries = fixture("repology_page1.json")["ack"] - assert_equal({ "Ubuntu" => ["ack", "ack-grep"] }, RepologyIndex.distil(entries)) - end - - def test_distil_drops_legacy_status - entries = [ - { "repo" => "alpine_3_17", "srcname" => "old-name", "status" => "legacy" }, - { "repo" => "alpine_3_22", "srcname" => "curl", "status" => "newest" }, - ] - assert_equal({ "Alpine" => ["curl"] }, RepologyIndex.distil(entries)) - end - - def test_distil_falls_back_to_binname_when_srcname_absent - entries = fixture("repology_page2.json")["zstd"] - assert_equal ["zstd"], RepologyIndex.distil(entries)["Mageia"] - end - - def test_distil_uses_distro_specific_srcname - entries = fixture("repology_page1.json")["libgee"] - assert_equal( - { "Alpine" => ["libgee"], "Debian" => ["libgee-0.8"] }, - RepologyIndex.distil(entries), - ) - end - - def test_distil_skips_entries_without_a_name - entries = [{ "repo" => "debian_12", "status" => "newest" }] - assert_empty RepologyIndex.distil(entries) - end - - def test_build_paginates_by_max_key_and_aggregates_all_homebrew_names - page1 = full_page(fixture("repology_page1.json")) - page2 = fixture("repology_page2.json") - max_key = page1.keys.max - fetcher, calls = recording_fetcher( - "/projects/?inrepo=homebrew" => page1, - "/projects/#{URI.encode_uri_component(max_key)}/?inrepo=homebrew" => page2, - ) - - result = new_index(fetcher:).build - - assert_equal( - ["/projects/?inrepo=homebrew", - "/projects/#{URI.encode_uri_component(max_key)}/?inrepo=homebrew"], - calls, - ) - assert_equal ["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee", "zstd"], - result["formulae"].keys - assert_equal result["formulae"]["ant"], result["formulae"]["ant@1.9"] - assert_equal( - { "AlmaLinux" => ["zstd"], "Mageia" => ["zstd"], "Rocky Linux" => ["zstd"] }, - result["formulae"]["zstd"], - ) - end - - def test_build_stops_on_first_short_page - fetcher, calls = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) - result = new_index(fetcher:).build - assert_equal 1, calls.size - assert_equal ["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee"], - result["formulae"].keys - end - - def test_build_respects_page_limit - page = full_page("only" => [{ "repo" => "homebrew", "srcname" => "only" }, - { "repo" => "debian_12", "srcname" => "only" }]) - fetcher, calls = recording_fetcher("/projects/?inrepo=homebrew" => page) - new_index(fetcher:, page_limit: 1).build - assert_equal 1, calls.size - end - - def test_build_omits_formulae_with_no_mapped_distros - fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) - refute_includes new_index(fetcher:).build["formulae"], "brew-only-tool" - end - - def test_build_records_ambiguous_projects_and_indexes_each_sibling_with_shared_srcnames - fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) - result = new_index(fetcher:).build - expected = { "Debian" => ["antlr3", "antlr4", "antlr4-cpp-runtime"] } - assert_equal expected, result["formulae"]["antlr"] - assert_equal expected, result["formulae"]["antlr4-cpp-runtime"] - assert_equal({ "antlr" => ["antlr", "antlr4-cpp-runtime"] }, result["meta"]["ambiguous_projects"]) - end - - def test_build_logs_ambiguous_and_colliding - logger = StringIO.new - page = fixture("repology_page1.json").merge( - "p1" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, - { "repo" => "debian_12", "srcname" => "foo1" }], - "p2" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" }, - { "repo" => "debian_12", "srcname" => "foo2" }], - ) - fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => page) - RepologyIndex.new(fetcher:, sleeper: ->(_) {}, logger:).build - assert_match(/1 project grouping sibling formulae .*repology-rules.*: antlr\b/, logger.string) - assert_match(/skipped 1 cross-project formula .*: foo\b/, logger.string) - end - - def test_build_meta_is_deterministic - fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) - meta = new_index(fetcher:).build["meta"] - assert_equal RepologyIndex::API_BASE, meta["source"] - assert_equal RepologyIndex::OSV_DISTROS.map { |_, v| v[:ecosystem] }.uniq.sort, meta["osv_distros"] - assert_equal %w[ambiguous_projects colliding_formulae osv_distros source], meta.keys.sort - end - - def test_write_creates_output_directory_and_emits_pretty_json - Dir.mktmpdir do |dir| - out = File.join(dir, "sub", "repology.json") - fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json")) - new_index(fetcher:).write(out) - parsed = JSON.parse(File.read(out)) - assert_equal ["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee"], - parsed["formulae"].keys - assert File.read(out).end_with?("\n") - end - end - - # Test double replacing {RepologyIndex#http_request} with a scripted - # sequence so retry behaviour is exercised without touching the network. - class ScriptedIndex < RepologyIndex - attr_reader :attempts - - def initialize(responses:) - super(sleeper: ->(_) {}, logger: nil) - @responses = responses - @attempts = 0 - end - - def http_request(_uri) - @attempts += 1 - action = @responses.shift - raise "scripted responses exhausted" unless action - - action.respond_to?(:call) ? action.call : action - end - end - - def ok(body) - Net::HTTPOK.new("1.1", "200", "OK").tap do |r| - r.instance_variable_set(:@body, body) - r.instance_variable_set(:@read, true) - end - end - - def test_retries_server_error_then_succeeds - idx = ScriptedIndex.new(responses: [ - Net::HTTPServiceUnavailable.new("1.1", "503", "Service Unavailable"), - ok("{}"), - ]) - assert_equal "{}", idx.http_get("/x") - assert_equal 2, idx.attempts - end - - def test_retries_transient_network_error_then_succeeds - idx = ScriptedIndex.new(responses: [-> { raise EOFError }, ok("{}")]) - assert_equal "{}", idx.http_get("/x") - assert_equal 2, idx.attempts - end - - def test_raises_after_exhausting_retries - responses = Array.new(RepologyIndex::MAX_ATTEMPTS) { -> { raise Errno::ETIMEDOUT } } - idx = ScriptedIndex.new(responses:) - err = assert_raises(RepologyIndex::Error) { idx.http_get("/x") } - assert_match(/Errno::ETIMEDOUT/, err.message) - assert_equal RepologyIndex::MAX_ATTEMPTS, idx.attempts - end - - def test_does_not_retry_client_errors - idx = ScriptedIndex.new(responses: [Net::HTTPForbidden.new("1.1", "403", "Forbidden")]) - assert_raises(RepologyIndex::Error) { idx.http_get("/x") } - assert_equal 1, idx.attempts - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index e1fd1911e3..0000000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require "minitest/autorun" -require "json" -require "tmpdir" - -# Shared test helpers for the advisory-database test suite. -module TestHelpers - FIXTURE_DIR = File.expand_path("fixtures", __dir__).freeze - - def fixture(name) - JSON.parse(File.read(File.join(FIXTURE_DIR, name))) - end -end - -Minitest::Test.include TestHelpers