# frozen_string_literal: true require "test_helper" class TestResolver < Minitest::Test include PicopackageTest V1 = <<~RUBY module Poller INTERVAL = 30 def self.run fetch end end RUBY # Upstream carrying its own block is optional, not required — but when it does, # only some of the block is upstream's to assert. This one is stale, as any # author who edits without re-running the tool will ship. PACKAGED = V1 + <<~RUBY # @PICOPACKAGE_START # --- # url: https://example.com/canonical/poller.rb # filename: upstream_name.rb # payload_version: 1.4.0 # payload_checksum: sha256:#{"de" * 32} # licence: MIT # @PICOPACKAGE_END RUBY def resolve(upstream_path, project, cache, force: false, filename: nil) Picopackage::Fetch.fetch(upstream_path, project, force: force, filename: filename, cache: cache) end def test_installs_when_nothing_is_there with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) result = resolve(path, project, cache) assert_equal :installed, result.state installed = Picopackage::Package.from_file(File.join(project, "poller.rb")) refute_predicate installed, :bare? assert_equal path, installed.url assert installed.verify_payload end end # A bare upstream file is a valid picopackage source: nobody has to adopt the # format for their code to be installable. Everything the block would have # said gets derived from the fetch instead. def test_installs_from_an_upstream_with_no_metadata_block with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) assert_predicate upstream_package(path), :bare?, "precondition: upstream carries no block" assert_equal :installed, resolve(path, project, cache).state installed = Picopackage::Package.from_file(File.join(project, "poller.rb")) assert installed.verify_payload assert_equal path, installed.url end end def test_publisher_claims_are_adopted_and_local_records_are_recomputed with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", PACKAGED) assert_equal :installed, resolve(path, project, cache).state # Claims: upstream's, and worth more than anything we could infer. The # declared filename beats the url's basename, so the file lands as the # author named it rather than as whatever the mirror called it. local_path = File.join(project, "upstream_name.rb") assert_path_exists local_path installed = Picopackage::Package.from_file(local_path) assert_equal "https://example.com/canonical/poller.rb", installed.url, "the canonical url should outlive the mirror we fetched from" assert_equal "1.4.0", installed.payload_version assert_equal "MIT", installed.metadata.extra["licence"] # Records: ours, describing this copy on this disk. assert installed.verify_payload, "a freshly installed file must verify, whatever upstream claimed" refute_predicate installed, :modified? refute_predicate installed, :diverged? assert_equal "upstream_name.rb", installed.filename, "filename records where it actually landed" end end def test_a_stale_upstream_checksum_does_not_break_a_later_merge with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", PACKAGED) resolve(path, project, cache) local_path = File.join(project, "upstream_name.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5")) File.write(path, PACKAGED.sub(" fetch\n", " fetch\n prune\n")) result = resolve(path, project, cache) assert_equal :merged, result.state, result.message merged = File.read(local_path) assert_includes merged, "INTERVAL = 5" assert_includes merged, "prune" assert_equal "MIT", Picopackage::Package.from_file(local_path).metadata.extra["licence"] end end def test_reinstalling_the_same_thing_is_a_no_op with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) before = File.read(File.join(project, "poller.rb")) result = resolve(path, project, cache) assert_equal :current, result.state assert_equal before, File.read(File.join(project, "poller.rb")), "an up-to-date file should not be rewritten" end end # The pleasant case: a file you pasted in by hand months ago gets its # provenance attached without its content being touched. def test_adopts_a_bare_local_file_with_identical_content with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) File.write(File.join(project, "poller.rb"), V1) result = resolve(path, project, cache) assert_equal :adopted, result.state adopted = Picopackage::Package.from_file(File.join(project, "poller.rb")) refute_predicate adopted, :bare? assert_equal Picopackage::Payload.normalize(V1), adopted.payload end end def test_fast_forwards_an_unmodified_package with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 60")) result = resolve(path, project, cache) assert_equal :updated, result.state assert_includes File.read(File.join(project, "poller.rb")), "INTERVAL = 60" end end # The headline capability: local edits and upstream edits both survive. def test_merges_local_edits_with_upstream_changes with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5 # tuned for us")) File.write(path, V1.sub(" fetch\n", " fetch\n prune\n")) result = resolve(path, project, cache) assert_equal :merged, result.state, result.message merged = File.read(local_path) assert_includes merged, "INTERVAL = 5 # tuned for us", "local edit should survive" assert_includes merged, "prune", "upstream change should be applied" refute_includes merged, "<<<<<<<" package = Picopackage::Package.from_file(local_path) assert package.verify_payload, "a merged file should verify against its own recorded checksum" refute_equal package.payload_checksum, package.base_checksum, "base_checksum should still point at upstream, not at the merged result" end end # A merged file must remain updatable: the base pointer has to advance to the # upstream payload, not to the merge result, or the next merge replays old # upstream changes as conflicts. def test_a_merged_file_can_be_updated_again with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5")) File.write(path, V1.sub(" fetch\n", " fetch\n prune\n")) assert_equal :merged, resolve(path, project, cache).state File.write(path, V1.sub(" fetch\n", " fetch\n prune\n report\n")) result = resolve(path, project, cache) assert_equal :merged, result.state, result.message merged = File.read(local_path) assert_includes merged, "INTERVAL = 5" assert_includes merged, "report" refute_includes merged, "<<<<<<<" end end # Editing locally while upstream stands still is the common case, and there is # nothing to merge: upstream is still the ancestor we branched from. def test_local_edits_with_no_upstream_change_are_left_alone with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5")) before = File.read(local_path) result = resolve(path, project, cache) assert_equal :current, result.state, result.message assert_equal before, File.read(local_path), "an edited file with no upstream change must not be rewritten" end end # Repopulating the merge base from an unchanged upstream is the whole reason # that branch still touches the cache. def test_an_unchanged_upstream_restores_a_lost_merge_base with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5")) # A fresh machine, or a cleared cache: the ancestor is gone. empty = Picopackage::Cache.new(root: File.join(Dir.mktmpdir, "empty")) assert_equal :current, resolve(path, project, empty).state # Upstream now moves. The base recovered above makes this mergeable. File.write(path, V1.sub(" fetch\n", " fetch\n prune\n")) result = resolve(path, project, empty) assert_equal :merged, result.state, result.message assert_includes File.read(local_path), "INTERVAL = 5" assert_includes File.read(local_path), "prune" end end def test_conflicting_edits_leave_the_original_alone with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5")) before = File.read(local_path) File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 90")) result = resolve(path, project, cache) assert_equal :conflict, result.state assert_equal before, File.read(local_path), "the user's file must not be touched" markers = local_path + Picopackage::Resolver::MERGE_SUFFIX assert_path_exists markers assert_includes File.read(markers), "<<<<<<<" end end def test_force_discards_local_changes with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5")) File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 90")) result = resolve(path, project, cache, force: true) assert_equal :updated, result.state assert_includes File.read(local_path), "INTERVAL = 90" refute_includes File.read(local_path), "INTERVAL = 5" end end def test_an_unrelated_bare_file_with_the_same_name_is_refused with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) File.write(File.join(project, "poller.rb"), "# something else entirely\n") result = resolve(path, project, cache) assert_equal :conflict, result.state assert_equal "# something else entirely\n", File.read(File.join(project, "poller.rb")) end end def test_a_modified_file_with_no_cached_ancestor_is_refused with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5")) File.write(path, V1.sub(" fetch\n", " fetch\n prune\n")) empty_cache = Picopackage::Cache.new(root: File.join(Dir.mktmpdir, "empty")) result = resolve(path, project, empty_cache) assert_equal :conflict, result.state assert_match(/cache/, result.message) end end # Renaming a package locally is allowed; updating it must not install a second # copy under upstream's preferred name. def test_update_writes_back_to_the_local_filename with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) FileUtils.mv(File.join(project, "poller.rb"), File.join(project, "my_poller.rb")) File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 60")) result = resolve(path, project, cache, filename: "my_poller.rb") assert_equal :updated, result.state refute_path_exists File.join(project, "poller.rb"), "should not have created a second copy" assert_includes File.read(File.join(project, "my_poller.rb")), "INTERVAL = 60" assert_equal "my_poller.rb", Picopackage::Package.from_file(File.join(project, "my_poller.rb")).filename end end def test_timestamps_never_override_the_checksum with_dirs do |upstream, project, cache| path = publish(upstream, "poller.rb", V1) resolve(path, project, cache) local_path = File.join(project, "poller.rb") before = File.read(local_path) # Same bytes, much newer mtime — the old timestamp-driven logic called # this an update and rewrote the file. FileUtils.touch(path, mtime: Time.now + 86_400) result = resolve(path, project, cache) assert_equal :current, result.state assert_equal before, File.read(local_path) end end end