Files
plezy/scripts/release/test_deploy.py
T

440 lines
16 KiB
Python

import sys
import tempfile
import unittest
import xml.dom.minidom
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import deploy
from deploy import (
DeployError,
State,
build_appcast,
bump_pubspec_text,
collect_release_files,
parse_env_text,
resolve_phases,
sanitize_msstore_pricing,
update_cask_text,
)
class ParseEnvTextTest(unittest.TestCase):
def test_parses_values_and_skips_comments_and_blanks(self) -> None:
text = """\
# App Store
DELIVER_USERNAME=user@example.com
export TOKEN=abc123
NOT A VALID LINE
"""
values = parse_env_text(text)
self.assertEqual(
values,
{"DELIVER_USERNAME": "user@example.com", "TOKEN": "abc123"},
)
def test_strips_matching_quotes(self) -> None:
values = parse_env_text("A=\"quoted value\"\nB='single'\nC=un\"quoted\n")
self.assertEqual(values["A"], "quoted value")
self.assertEqual(values["B"], "single")
self.assertEqual(values["C"], 'un"quoted')
def test_expands_references_to_earlier_keys(self) -> None:
# The real .env relies on this: SUPPLY_PACKAGE_NAME=${DELIVER_APP_IDENTIFIER}
text = "DELIVER_APP_IDENTIFIER=com.edde746.plezy\nSUPPLY_PACKAGE_NAME=${DELIVER_APP_IDENTIFIER}\n"
values = parse_env_text(text)
self.assertEqual(values["SUPPLY_PACKAGE_NAME"], "com.edde746.plezy")
def test_expands_from_base_environment(self) -> None:
values = parse_env_text("A=${HOME_LIKE}/sub\n", {"HOME_LIKE": "/base"})
self.assertEqual(values["A"], "/base/sub")
def test_unknown_reference_expands_empty(self) -> None:
self.assertEqual(parse_env_text("A=${MISSING}x\n")["A"], "x")
def test_single_quotes_suppress_expansion(self) -> None:
values = parse_env_text("A='${NOPE}'\n", {"NOPE": "value"})
self.assertEqual(values["A"], "${NOPE}")
class BumpPubspecTextTest(unittest.TestCase):
PUBSPEC = """\
name: plezy
description: A media client.
version: 2.12.1+230
environment:
sdk: ">=3.12.0 <4.0.0"
dependencies:
some_package:
version: 9.9.9+999
"""
def test_replaces_only_the_top_level_version(self) -> None:
result = bump_pubspec_text(self.PUBSPEC, "2.13.0", 231)
self.assertIn("version: 2.13.0+231\n", result)
self.assertIn(" version: 9.9.9+999\n", result)
self.assertNotIn("2.12.1+230", result)
self.assertEqual(result.count("\n"), self.PUBSPEC.count("\n"))
def test_rejects_pubspec_without_version(self) -> None:
with self.assertRaises(ValueError):
bump_pubspec_text("name: plezy\n", "2.13.0", 231)
class BuildAppcastTest(unittest.TestCase):
def test_full_appcast_has_both_enclosures(self) -> None:
xml_text = build_appcast("2.13.0", "231", "macsig", "1000", "winsig", "2000")
doc = xml.dom.minidom.parseString(xml_text)
enclosures = doc.getElementsByTagName("enclosure")
self.assertEqual(len(enclosures), 2)
macos, windows = enclosures
self.assertEqual(
macos.getAttribute("url"),
"https://github.com/edde746/plezy/releases/download/2.13.0/plezy-macos.dmg",
)
self.assertEqual(macos.getAttribute("sparkle:edSignature"), "macsig")
self.assertEqual(macos.getAttribute("length"), "1000")
self.assertEqual(
windows.getAttribute("url"),
"https://github.com/edde746/plezy/releases/download/2.13.0/plezy-windows-installer.exe",
)
self.assertEqual(windows.getAttribute("sparkle:installerArguments"), "/SILENT /SP-")
version_nodes = doc.getElementsByTagName("sparkle:version")
self.assertEqual(version_nodes[0].firstChild.nodeValue, "231")
def test_unsigned_appcast_has_no_enclosures(self) -> None:
xml_text = build_appcast("2.13.0", "231")
doc = xml.dom.minidom.parseString(xml_text)
self.assertEqual(len(doc.getElementsByTagName("enclosure")), 0)
notes = doc.getElementsByTagName("sparkle:releaseNotesLink")
self.assertIn("2.13.0", notes[0].firstChild.nodeValue)
class UpdateCaskTextTest(unittest.TestCase):
CASK = """\
cask "plezy" do
version "2.12.1"
sha256 "7a5e30d125d5a379108bf691ae8ce5e2af0a1acf98ee40897909a9576ebe3ef9"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
end
"""
def test_updates_version_and_sha(self) -> None:
result = update_cask_text(self.CASK, "2.13.0", "f" * 64)
self.assertIn('version "2.13.0"', result)
self.assertIn(f'sha256 "{"f" * 64}"', result)
self.assertNotIn("2.12.1", result)
# Interpolated URL and other stanzas are untouched.
self.assertIn('#{version}/plezy-macos.dmg', result)
def test_missing_stanza_raises(self) -> None:
with self.assertRaises(DeployError):
update_cask_text('cask "plezy" do\nend\n', "2.13.0", "f" * 64)
class ResolvePhasesTest(unittest.TestCase):
def test_default_selects_all_phases_in_order(self) -> None:
self.assertEqual(resolve_phases(None, None), deploy.PHASES)
def test_only_keeps_preflight_and_canonical_order(self) -> None:
self.assertEqual(
resolve_phases(["msstore", "play"], None),
["preflight", "play", "msstore"],
)
def test_skip_removes_phases(self) -> None:
selected = resolve_phases(None, ["ios", "tvos", "asc"])
self.assertNotIn("ios", selected)
self.assertNotIn("tvos", selected)
self.assertNotIn("asc", selected)
self.assertIn("play", selected)
def test_preflight_can_be_skipped_explicitly(self) -> None:
self.assertEqual(resolve_phases(["release"], ["preflight"]), ["release"])
def test_unknown_phase_raises(self) -> None:
with self.assertRaises(DeployError):
resolve_phases(["appstore"], None)
with self.assertRaises(DeployError):
resolve_phases(None, ["nope"])
class CollectReleaseFilesTest(unittest.TestCase):
EXPECTED = [
"android-apk/plezy-android-arm64-v8a.tar.gz",
"android-apk/plezy-android-armeabi-v7a.tar.gz",
"android-apk/plezy-android-x86_64.tar.gz",
"ios-ipa/plezy-ios.ipa",
"macos-dmg/plezy-macos.dmg",
"windows-x64-portable/plezy-windows-x64-portable.7z",
"windows-arm64-portable/plezy-windows-arm64-portable.7z",
"windows-installer/plezy-windows-installer.exe",
"linux-x64/plezy-linux-x64.deb",
"linux-x64/plezy-linux-x64.tar.gz",
"linux-arm64/plezy-linux-arm64.deb",
"linux-arm64/plezy-linux-arm64.tar.gz",
]
def make_tree(self, root: Path, skip: str = "") -> None:
for rel in self.EXPECTED:
if rel == skip:
continue
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"artifact")
def test_collects_all_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.make_tree(root)
files = collect_release_files(root)
relative = {str(p.relative_to(root)) for p in files}
self.assertEqual(relative, set(self.EXPECTED))
def test_missing_explicit_artifact_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.make_tree(root, skip="macos-dmg/plezy-macos.dmg")
with self.assertRaisesRegex(DeployError, "plezy-macos.dmg"):
collect_release_files(root)
def test_empty_linux_directory_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.make_tree(root)
for entry in (root / "linux-arm64").iterdir():
entry.unlink()
with self.assertRaisesRegex(DeployError, "linux-arm64"):
collect_release_files(root)
class StateTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self._original = deploy.STATE_PATH
deploy.STATE_PATH = Path(self._tmp.name) / "state.json"
def tearDown(self) -> None:
deploy.STATE_PATH = self._original
self._tmp.cleanup()
def test_round_trip(self) -> None:
state = State(version="2.13.0", build_number=231)
state.data["farm_run_id"] = 42
state.mark_done("changelog")
loaded = State.load()
self.assertIsNotNone(loaded)
self.assertEqual(loaded.version, "2.13.0")
self.assertEqual(loaded.build_number, 231)
self.assertEqual(loaded.done, ["changelog"])
self.assertEqual(loaded.data, {"farm_run_id": 42})
def test_mark_done_is_idempotent(self) -> None:
state = State(version="2.13.0", build_number=231)
state.mark_done("play")
state.mark_done("play")
self.assertEqual(State.load().done, ["play"])
def test_load_returns_none_without_file(self) -> None:
self.assertIsNone(State.load())
class InitializeStateTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self._original_root = deploy.ROOT
self._original_deploy_dir = deploy.DEPLOY_DIR
self._original_state_path = deploy.STATE_PATH
deploy.ROOT = Path(self._tmp.name)
deploy.DEPLOY_DIR = deploy.ROOT / "build" / "deploy"
deploy.STATE_PATH = deploy.DEPLOY_DIR / "state.json"
(deploy.ROOT / "pubspec.yaml").write_text("version: 2.14.0+130\n", encoding="utf-8")
def tearDown(self) -> None:
deploy.ROOT = self._original_root
deploy.DEPLOY_DIR = self._original_deploy_dir
deploy.STATE_PATH = self._original_state_path
self._tmp.cleanup()
@staticmethod
def _args(*, dry_run: bool) -> SimpleNamespace:
return SimpleNamespace(
version="2.15.0",
build_number=None,
fresh=True,
dry_run=dry_run,
)
def _write_previous_release(self) -> Path:
State(version="2.14.0", build_number=130, done=["publish"]).save()
note = deploy.DEPLOY_DIR / "notes" / "play.txt"
note.parent.mkdir(parents=True)
note.write_text("old store notes\n", encoding="utf-8")
return note
def test_fresh_release_removes_state_and_generated_outputs(self) -> None:
old_note = self._write_previous_release()
state = deploy.initialize_state(self._args(dry_run=False))
self.assertEqual((state.version, state.build_number), ("2.15.0", 131))
self.assertFalse(old_note.exists())
self.assertEqual(State.load(), state)
def test_dry_run_fresh_release_preserves_existing_outputs(self) -> None:
old_note = self._write_previous_release()
state = deploy.initialize_state(self._args(dry_run=True))
self.assertEqual((state.version, state.build_number), ("2.15.0", 131))
self.assertEqual(old_note.read_text(encoding="utf-8"), "old store notes\n")
self.assertEqual(State.load().version, "2.14.0")
class DeferredPhaseTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self._original_state_path = deploy.STATE_PATH
deploy.STATE_PATH = Path(self._tmp.name) / "state.json"
def tearDown(self) -> None:
deploy.STATE_PATH = self._original_state_path
self._tmp.cleanup()
@staticmethod
def _context(state: State, **args) -> deploy.Context:
defaults = {
"dry_run": False,
"yes": False,
"amazon_skip_commit": False,
}
defaults.update(args)
return deploy.Context(
args=SimpleNamespace(**defaults),
env={"AMAZON_APPSTORE_PACKAGE_NAME": "com.example.app"},
state=state,
phases=["amazon", "publish"],
)
def test_deferred_phase_remains_pending_in_release_state(self) -> None:
state = State(version="2.15.0", build_number=131)
args = SimpleNamespace(
only=["publish"],
skip=None,
notes="cl.txt",
dry_run=False,
)
def defer(_ctx) -> None:
raise deploy.PhaseDeferred
with (
mock.patch.object(deploy, "load_env_file", return_value={}),
mock.patch.object(deploy, "initialize_state", return_value=state),
mock.patch.object(deploy, "resolve_phases", return_value=["publish"]),
mock.patch.dict(deploy.PHASE_FUNCTIONS, {"publish": defer}, clear=True),
mock.patch.object(deploy, "print_summary"),
):
self.assertEqual(deploy.cmd_release(args), 0)
self.assertNotIn("publish", State.load().done)
def test_publish_decline_defers_phase(self) -> None:
ctx = self._context(State(version="2.15.0", build_number=131))
with mock.patch.object(deploy.Context, "confirm", return_value=False):
with self.assertRaises(deploy.PhaseDeferred):
deploy.phase_publish(ctx)
def test_amazon_resume_commits_existing_edit_without_replacing_apk(self) -> None:
state = State(version="2.15.0", build_number=131)
state.data["amazon_pending_edit_id"] = "edit-42"
ctx = self._context(state)
client = mock.Mock()
client.etag.return_value = "etag-42"
with (
mock.patch.object(deploy, "_amazon_token", return_value="token"),
mock.patch.object(deploy, "AmazonClient", return_value=client),
mock.patch.object(deploy.Context, "confirm", return_value=True),
mock.patch.object(deploy, "run") as run_command,
):
deploy.phase_amazon(ctx)
run_command.assert_not_called()
client.request.assert_called_once_with(
"POST",
"/edits/edit-42/commit",
headers={"If-Match": "etag-42"},
)
self.assertNotIn("amazon_pending_edit_id", state.data)
def test_amazon_resume_decline_keeps_edit_pending(self) -> None:
state = State(version="2.15.0", build_number=131)
state.data["amazon_pending_edit_id"] = "edit-42"
ctx = self._context(state)
client = mock.Mock()
with (
mock.patch.object(deploy, "_amazon_token", return_value="token"),
mock.patch.object(deploy, "AmazonClient", return_value=client),
mock.patch.object(deploy.Context, "confirm", return_value=False),
mock.patch.object(deploy, "run") as run_command,
):
with self.assertRaises(deploy.PhaseDeferred):
deploy.phase_amazon(ctx)
run_command.assert_not_called()
client.request.assert_not_called()
self.assertEqual(state.data["amazon_pending_edit_id"], "edit-42")
class SplitPhaseListsTest(unittest.TestCase):
def test_splits_commas_and_repeats(self) -> None:
self.assertEqual(
deploy._split_phase_lists(["play,amazon", "ios"]),
["play", "amazon", "ios"],
)
def test_none_passthrough(self) -> None:
self.assertIsNone(deploy._split_phase_lists(None))
self.assertIsNone(deploy._split_phase_lists([]))
class SanitizeMsstorePricingTest(unittest.TestCase):
def test_drops_price_id_and_advanced_flag_but_keeps_the_rest(self) -> None:
submission = {
"pricing": {
"trialPeriod": "SevenDays",
"marketSpecificPricings": {"LB": "NotAvailable"},
"sales": [],
"priceId": "Base",
"isAdvancedPricingModel": True,
}
}
sanitize_msstore_pricing(submission)
self.assertEqual(
submission["pricing"],
{
"trialPeriod": "SevenDays",
"marketSpecificPricings": {"LB": "NotAvailable"},
"sales": [],
},
)
def test_tolerates_missing_pricing(self) -> None:
submission: dict = {}
sanitize_msstore_pricing(submission)
self.assertEqual(submission, {})
if __name__ == "__main__":
unittest.main()