Files
plezy/scripts/test_set_native_revision.py
T

529 lines
20 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Behavior tests for scripts/set_native_revision.sh.
The script moves ten pin sites at once and promises all-or-nothing: either
every Apple requirement, every SwiftPM lock, and mpv-build.lock.json move to
the new commit together, or nothing is written. These tests drive the real
script end to end against a synthetic Plezy tree and a real (temporary) git
checkout of the unified mpv-build repo, because the failure that matters --
a half-moved pin -- can only happen across process and file boundaries.
The pin-site fixtures reproduce the exact anchors the rewrites key on: the
pbxproj carries the `/* XCRemoteSwiftPackageReference "MPVKit" */ = {` object
plus the same comment inside a packageReferences list and a product
dependency (which must NOT match), and the SwiftPM locks are version-3 files
with an unrelated pin that must survive untouched. The fixtures are staged at
the pre-flip edde746/MPVKit state, so every successful run also exercises the
repo flip to edde746/mpv-build: repositoryURL, SwiftPM identity, and the
pbxproj comment strings move with the revision in one all-or-nothing pass.
"""
import json
import http.server
import os
import shutil
import subprocess
import tempfile
import threading
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
SCRIPT = SCRIPT_DIR / "set_native_revision.sh"
OLD_SHA = "b" * 40
OLD_URL = "https://github.com/edde746/MPVKit"
NEW_REPO = "edde746/mpv-build"
NEW_URL = f"https://github.com/{NEW_REPO}"
NEW_KEY_ANDROID = "aa11bb22cc33"
NEW_KEY_LINUX = "dd44ee55ff66"
PBXPROJ_TEMPLATE = """\
// !$*UTF8*$!
{{
\tobjects = {{
\t\t97C146E61CF9000F007C117D /* Project object */ = {{
\t\t\tisa = PBXProject;
\t\t\tpackageReferences = (
\t\t\t\tCEE842A5A25923620300AB90 /* XCRemoteSwiftPackageReference "MPVKit" */,
\t\t\t);
\t\t}};
\t\tCEE842A5A25923620300AB90 /* XCRemoteSwiftPackageReference "MPVKit" */ = {{
\t\t\tisa = XCRemoteSwiftPackageReference;
\t\t\trepositoryURL = "https://github.com/edde746/MPVKit";
\t\t\trequirement = {{
\t\t\t\tkind = revision;
\t\t\t\trevision = {revision};
\t\t\t}};
\t\t}};
\t\tEA0F4263E7B912702C490108 /* MPVKit */ = {{
\t\t\tisa = XCSwiftPackageProductDependency;
\t\t\tpackage = CEE842A5A25923620300AB90 /* XCRemoteSwiftPackageReference "MPVKit" */;
\t\t\tproductName = MPVKit;
\t\t}};
\t}};
\trootObject = 97C146E61CF9000F007C117D /* Project object */;
}}
"""
RESOLVED_TEMPLATE = {
"originHash": "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins": [
{
"identity": "mpvkit",
"kind": "remoteSourceControl",
"location": "https://github.com/edde746/MPVKit",
"state": {"revision": OLD_SHA},
},
{
"identity": "unrelated",
"kind": "remoteSourceControl",
"location": "https://example.invalid/unrelated",
"state": {"revision": "c" * 40},
},
],
"version": 3,
}
VERSIONS_FIXTURE = {"formatVersion": 1, "components": {"mpv": {"version": "0.40.0"}}}
def android_section() -> dict:
return {
"assetBase": "https://github.com/edde746/MPVKit/releases/download/binaries-android",
"libraries": {
"libmpv-android": {
"key": NEW_KEY_ANDROID,
"prebuilt": {
abi: {
"asset": f"libmpv-android-{NEW_KEY_ANDROID}-{abi}.tar.gz",
"checksum": format(index, "x") * 64,
}
for index, abi in enumerate(
("arm64-v8a", "armeabi-v7a", "x86", "x86_64"), start=1
)
},
}
},
}
def linux_section() -> dict:
return {
"assetBase": "https://github.com/edde746/MPVKit/releases/download/binaries-linux",
"libraries": {
"libmpv-linux": {
"key": NEW_KEY_LINUX,
"prebuilt": {
"x86_64": {
"asset": f"libmpv-linux-{NEW_KEY_LINUX}-x86_64.tar.gz",
"checksum": "5" * 64,
}
},
}
},
}
def apple_section() -> dict:
return {
"assetBase": "https://github.com/edde746/MPVKit/releases/download/binaries-apple",
"libraries": {
"libmpv": {
"frameworks": {
"Libmpv": {
"asset": "Libmpv-e73a34ad67f2.xcframework.zip",
"checksum": "6" * 64,
}
},
"key": "e73a34ad67f2",
"prebuilt": {"ios": "libmpv-all-e73a34ad67f2-ios.zip"},
}
},
}
def artifacts_fixture() -> dict:
# windows deliberately absent: its omission note is part of the contract.
return {
"platforms": {
"apple": apple_section(),
"android": android_section(),
"linux": linux_section(),
},
"schema": 2,
}
class SetNativeRevisionTest(unittest.TestCase):
def setUp(self) -> None:
self.temporary = Path(tempfile.mkdtemp(prefix="set-native-revision-test."))
self.addCleanup(shutil.rmtree, self.temporary, ignore_errors=True)
self.root = self.temporary / "plezy"
self._stage_plezy_tree()
# ---- fixtures ---------------------------------------------------------
def _stage_plezy_tree(self) -> None:
scripts = self.root / "scripts"
scripts.mkdir(parents=True)
shutil.copy2(SCRIPT, scripts / SCRIPT.name)
for platform in ("ios", "macos", "tvos"):
base = self.root / platform
(base / "Runner.xcodeproj").mkdir(parents=True)
self._pbxproj(platform).write_text(
PBXPROJ_TEMPLATE.format(revision=OLD_SHA), encoding="utf-8"
)
for lock in self._resolved(platform):
lock.parent.mkdir(parents=True)
lock.write_text(
json.dumps(RESOLVED_TEMPLATE, indent=2) + "\n", encoding="utf-8"
)
def _pbxproj(self, platform: str) -> Path:
return self.root / platform / "Runner.xcodeproj" / "project.pbxproj"
def _resolved(self, platform: str) -> list[Path]:
return [
self.root
/ platform
/ "Runner.xcodeproj"
/ "project.xcworkspace"
/ "xcshareddata"
/ "swiftpm"
/ "Package.resolved",
self.root
/ platform
/ "Runner.xcworkspace"
/ "xcshareddata"
/ "swiftpm"
/ "Package.resolved",
]
def _pin_sites(self) -> list[Path]:
sites = []
for platform in ("ios", "macos", "tvos"):
sites.append(self._pbxproj(platform))
sites.extend(self._resolved(platform))
return sites
def _make_mpv_build(
self, name: str = "mpv-build", *, artifacts: dict | None = None, manifests: bool = True
) -> tuple[Path, str]:
"""A real git checkout carrying the manifests, and its HEAD commit."""
repo = self.temporary / name
repo.mkdir()
if manifests:
(repo / "artifacts.json").write_text(
json.dumps(artifacts or artifacts_fixture(), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
(repo / "versions.json").write_text(
json.dumps(VERSIONS_FIXTURE, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
else:
(repo / "README.md").write_text("pre-migration MPVKit\n", encoding="utf-8")
git = self._git(repo)
subprocess.run(git[:3] + ["init", "-q"], check=True)
subprocess.run(git + ["add", "-A"], check=True)
subprocess.run(git + ["commit", "-q", "-m", "fixture"], check=True)
head = subprocess.run(
git[:3] + ["rev-parse", "HEAD"], check=True, capture_output=True, text=True
).stdout.strip()
return repo, head
def _git(self, repo: Path) -> list[str]:
return [
"git",
"-C",
str(repo),
"-c",
"user.name=fixture",
"-c",
"user.email=fixture@example.invalid",
"-c",
"commit.gpgsign=false",
]
def _advance(self, repo: Path) -> str:
"""A second commit in the fixture repo, so the pin can move again."""
(repo / "CHANGELOG").write_text("moved\n", encoding="utf-8")
git = self._git(repo)
subprocess.run(git + ["add", "-A"], check=True)
subprocess.run(git + ["commit", "-q", "-m", "fixture 2"], check=True)
return subprocess.run(
git[:3] + ["rev-parse", "HEAD"], check=True, capture_output=True, text=True
).stdout.strip()
def _run(self, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", str(self.root / "scripts" / SCRIPT.name), *args],
check=False,
capture_output=True,
text=True,
)
def _snapshot(self) -> dict[Path, str]:
"""Byte content of every pin site that exists right now."""
return {
site: site.read_text(encoding="utf-8")
for site in self._pin_sites()
if site.exists()
}
def _expected_lock(self, commit: str, repo: str = NEW_REPO) -> str:
artifacts = {}
for group, section in (("android", android_section()), ("linux", linux_section())):
((_, entry),) = section["libraries"].items()
artifacts[group] = {
"assetBase": section["assetBase"],
"assets": entry["prebuilt"],
"key": entry["key"],
}
lock = {
"artifacts": artifacts,
"commit": commit,
"formatVersion": 1,
"repo": repo,
}
return json.dumps(lock, indent=2, sort_keys=True) + "\n"
# ---- the pin moves as one unit ----------------------------------------
def _assert_pinned(self, commit: str, url: str = NEW_URL) -> None:
"""All nine Apple sites name url@commit with coherent names/identities."""
name = url.rsplit("/", 1)[-1]
for platform in ("ios", "macos", "tvos"):
pbxproj = self._pbxproj(platform).read_text(encoding="utf-8")
self.assertIn(f"revision = {commit};", pbxproj)
self.assertIn(f'repositoryURL = "{url}";', pbxproj)
# Xcode derives these comments from the URL's last path component;
# all three mentions (packageReferences list, object definition,
# product dependency) must carry the same name.
self.assertEqual(
pbxproj.count(f'/* XCRemoteSwiftPackageReference "{name}" */'), 3
)
# The product name comes from Package.swift, not the URL.
self.assertIn("productName = MPVKit;", pbxproj)
for lock in self._resolved(platform):
resolved = json.loads(lock.read_text(encoding="utf-8"))
by_identity = {pin["identity"]: pin for pin in resolved["pins"]}
pin = by_identity[name.lower()]
self.assertEqual(pin["location"], url)
self.assertEqual(pin["state"]["revision"], commit)
# Targeted edit: the unrelated pin and originHash never churn.
self.assertEqual(by_identity["unrelated"]["state"]["revision"], "c" * 40)
self.assertEqual(
by_identity["unrelated"]["location"], "https://example.invalid/unrelated"
)
self.assertEqual(resolved["originHash"], RESOLVED_TEMPLATE["originHash"])
def test_the_flip_rewrites_every_pin_site_and_derives_the_lock(self) -> None:
# The tree starts at the pre-flip edde746/MPVKit pin; a run against the
# unified repo must move URL, identity, comments, and revision at all
# nine Apple sites and derive the lock's repo from the source used.
repo, commit = self._make_mpv_build()
result = self._run(commit, "--repo", str(repo))
self.assertEqual(result.returncode, 0, result.stderr)
self._assert_pinned(commit)
for platform in ("ios", "macos", "tvos"):
pbxproj = self._pbxproj(platform).read_text(encoding="utf-8")
self.assertNotIn(OLD_SHA, pbxproj)
self.assertNotIn(OLD_URL, pbxproj)
self.assertNotIn('XCRemoteSwiftPackageReference "MPVKit"', pbxproj)
self.assertEqual(
(self.root / "mpv-build.lock.json").read_text(encoding="utf-8"),
self._expected_lock(commit),
)
self.assertIn(
"note: no windows artifacts published at this commit; "
"omitted from mpv-build.lock.json",
result.stdout,
)
def test_default_source_is_the_sibling_checkout(self) -> None:
# No --repo: the ../mpv-build convention must find the checkout
# sitting next to the Plezy tree.
_, commit = self._make_mpv_build()
result = self._run(commit)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("../mpv-build", result.stdout)
self.assertIn(f"revision = {commit};", self._pbxproj("ios").read_text(encoding="utf-8"))
def test_remote_source_serves_the_manifests_by_commit(self) -> None:
docroot = self.temporary / "raw"
commit = "d" * 40
(docroot / commit).mkdir(parents=True)
(docroot / commit / "artifacts.json").write_text(
json.dumps(artifacts_fixture()), encoding="utf-8"
)
(docroot / commit / "versions.json").write_text(
json.dumps(VERSIONS_FIXTURE), encoding="utf-8"
)
handler = lambda *args, **kwargs: http.server.SimpleHTTPRequestHandler( # noqa: E731
*args, directory=str(docroot), **kwargs
)
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
result = self._run(commit, "--repo", f"http://127.0.0.1:{server.server_address[1]}")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
(self.root / "mpv-build.lock.json").read_text(encoding="utf-8"),
self._expected_lock(commit),
)
def test_rerun_with_the_same_commit_is_a_reported_noop(self) -> None:
repo, commit = self._make_mpv_build()
first = self._run(commit, "--repo", str(repo))
self.assertEqual(first.returncode, 0, first.stderr)
before = self._snapshot()
lock_before = (self.root / "mpv-build.lock.json").read_text(encoding="utf-8")
second = self._run(commit, "--repo", str(repo))
self.assertEqual(second.returncode, 0, second.stderr)
self.assertIn("nothing to do", second.stdout)
self.assertEqual(self._snapshot(), before)
self.assertEqual(
(self.root / "mpv-build.lock.json").read_text(encoding="utf-8"), lock_before
)
def test_a_move_within_the_target_repo_only_touches_revisions(self) -> None:
# Once flipped, a later pin move must not churn URLs, identities, or
# comment names -- only the revisions and the lock's commit change.
repo, first_commit = self._make_mpv_build()
first = self._run(first_commit, "--repo", str(repo))
self.assertEqual(first.returncode, 0, first.stderr)
second_commit = self._advance(repo)
result = self._run(second_commit, "--repo", str(repo))
self.assertEqual(result.returncode, 0, result.stderr)
self._assert_pinned(second_commit)
self.assertEqual(
(self.root / "mpv-build.lock.json").read_text(encoding="utf-8"),
self._expected_lock(second_commit),
)
def test_lock_repo_derives_from_the_local_checkouts_origin_remote(self) -> None:
# The pin names whichever GitHub repo actually served the commit, so a
# checkout cloned from a fork flips every site to that fork.
repo, commit = self._make_mpv_build()
subprocess.run(
self._git(repo)[:3]
+ ["remote", "add", "origin", "git@github.com:someone/mpv-build.git"],
check=True,
)
result = self._run(commit, "--repo", str(repo))
self.assertEqual(result.returncode, 0, result.stderr)
self._assert_pinned(commit, url="https://github.com/someone/mpv-build")
self.assertEqual(
(self.root / "mpv-build.lock.json").read_text(encoding="utf-8"),
self._expected_lock(commit, repo="someone/mpv-build"),
)
# ---- refusals leave every byte alone ----------------------------------
def test_refuses_a_malformed_sha(self) -> None:
before = self._snapshot()
result = self._run("deadbeef")
self.assertEqual(result.returncode, 2)
self.assertIn("not a full 40-character lowercase commit sha", result.stderr)
self.assertEqual(self._snapshot(), before)
def test_refuses_when_a_pin_site_is_missing(self) -> None:
repo, commit = self._make_mpv_build()
removed = self._resolved("macos")[1]
removed.unlink()
before = self._snapshot()
result = self._run(commit, "--repo", str(repo))
self.assertEqual(result.returncode, 1)
self.assertIn("missing native pin site(s)", result.stderr)
self.assertIn(str(removed.relative_to(self.root)), result.stderr)
self.assertEqual(self._snapshot(), before)
self.assertFalse((self.root / "mpv-build.lock.json").exists())
def test_an_ambiguous_edit_aborts_before_any_write(self) -> None:
repo, commit = self._make_mpv_build()
pbxproj = self._pbxproj("tvos")
content = pbxproj.read_text(encoding="utf-8")
anchor = '/* XCRemoteSwiftPackageReference "MPVKit" */ = {'
duplicated = content.replace(
"\trootObject",
"\t\tDUPLICATE " + anchor + "\n"
'\t\t\trepositoryURL = "https://github.com/edde746/MPVKit";\n'
"\t\t\trequirement = {\n"
"\t\t\t\tkind = revision;\n"
f"\t\t\t\trevision = {OLD_SHA};\n"
"\t\t\t};\n"
"\t\t};\n"
"\trootObject",
)
pbxproj.write_text(duplicated, encoding="utf-8")
before = self._snapshot()
result = self._run(commit, "--repo", str(repo))
self.assertEqual(result.returncode, 1)
self.assertIn("expected exactly 1 native pin, found 2", result.stderr)
# All-or-nothing: the eight unambiguous sites must not have moved, and
# no lock may appear alongside a refused pin.
self.assertEqual(self._snapshot(), before)
self.assertFalse((self.root / "mpv-build.lock.json").exists())
def test_refuses_a_commit_that_predates_the_unified_repo(self) -> None:
repo, commit = self._make_mpv_build(name="MPVKit", manifests=False)
before = self._snapshot()
result = self._run(commit, "--repo", str(repo))
self.assertEqual(result.returncode, 1)
self.assertIn("not a unified mpv-build commit", result.stderr)
self.assertEqual(self._snapshot(), before)
def test_refuses_a_checksumless_prebuilt_asset(self) -> None:
artifacts = artifacts_fixture()
android = artifacts["platforms"]["android"]["libraries"]["libmpv-android"]
android["prebuilt"]["arm64-v8a"] = f"libmpv-android-{NEW_KEY_ANDROID}-arm64-v8a.tar.gz"
repo, commit = self._make_mpv_build(artifacts=artifacts)
before = self._snapshot()
result = self._run(commit, "--repo", str(repo))
self.assertEqual(result.returncode, 1)
self.assertIn("carries no checksum", result.stderr)
self.assertEqual(self._snapshot(), before)
self.assertFalse((self.root / "mpv-build.lock.json").exists())
def test_a_group_with_no_published_libraries_is_omitted_with_a_note(self) -> None:
artifacts = artifacts_fixture()
artifacts["platforms"]["linux"]["libraries"] = {}
repo, commit = self._make_mpv_build(artifacts=artifacts)
result = self._run(commit, "--repo", str(repo))
self.assertEqual(result.returncode, 0, result.stderr)
lock = json.loads((self.root / "mpv-build.lock.json").read_text(encoding="utf-8"))
self.assertEqual(sorted(lock["artifacts"]), ["android"])
self.assertIn("linux has published no libraries", result.stdout)
if __name__ == "__main__":
unittest.main()