fuzzyworld-tools / files / tests / test_deploy_tools.py
test_deploy_tools.py
92625227 2026-11-04 Pavel Safronov | 1 | import importlib.machinery |
| 2 | import importlib.util | |
| 3 | import json | |
| 4 | import os | |
| 5 | import shutil | |
| 6 | import subprocess | |
| 7 | import tempfile | |
| 8 | import unittest | |
| 9 | from unittest import mock | |
| 10 | ||
| 11 | PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "deploy-tools") | |
| 12 | loader = importlib.machinery.SourceFileLoader("deploy_tools", PATH) | |
| 13 | dt = importlib.util.module_from_spec(importlib.util.spec_from_loader("deploy_tools", loader)) | |
| 14 | loader.exec_module(dt) | |
| 15 | ||
| 16 | ||
| 17 | class DeployTools(unittest.TestCase): | |
| 18 | def setUp(self): | |
| 19 | self.root = tempfile.mkdtemp() | |
| 20 | self.addCleanup(shutil.rmtree, self.root, True) | |
| 21 | self.git = lambda *a: subprocess.run(["git", "-C", self.root, *a], check=True, capture_output=True, text=True, | |
| 22 | env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", | |
| 23 | "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}).stdout.strip() | |
| 24 | self.git("init", "-q") | |
| 25 | os.makedirs(os.path.join(self.root, "server")) | |
| 26 | self.write("server/a.txt", "one") | |
| 27 | self.write("other.txt", "x") | |
| 28 | self.write(".gitignore", "*.log\n") | |
| 29 | self.git("add", "-A") | |
| 30 | self.git("commit", "-qm", "c") | |
| 31 | patcher = mock.patch.object(dt, "ROOT", self.root) | |
| 32 | patcher.start() | |
| 33 | self.addCleanup(patcher.stop) | |
| 34 | self.ledger = {} # stands in for the server's ledger | |
| 35 | for name, fn in [("load", lambda: json.loads(json.dumps(self.ledger))), | |
| 36 | ("save", lambda ledger: self.ledger.update(json.loads(json.dumps(ledger))))]: | |
| 37 | patcher = mock.patch.object(dt, name, fn) | |
| 38 | patcher.start() | |
| 39 | self.addCleanup(patcher.stop) | |
| 40 | ||
| 41 | def write(self, rel, text): | |
| 42 | with open(os.path.join(self.root, rel), "w") as f: | |
| 43 | f.write(text) | |
| 44 | ||
| 45 | def test_hash_follows_content_not_commits_or_unrelated_files(self): | |
| 46 | h = dt.content_hash(["server"]) | |
| 47 | self.assertEqual(h, self.git("rev-parse", "HEAD:server")) | |
| 48 | self.write("other.txt", "changed") # outside the target | |
| 49 | self.write("server/ignored.log", "noise") # gitignored | |
| 50 | self.assertEqual(dt.content_hash(["server"]), h) | |
| 51 | self.write("server/new.txt", "untracked") # untracked counts | |
| 52 | dirty = dt.content_hash(["server"]) | |
| 53 | self.assertNotEqual(dirty, h) | |
| 54 | self.git("add", "-A") | |
| 55 | self.git("commit", "-qm", "d") # committing the same content gives the same hash | |
| 56 | self.assertEqual(dt.content_hash(["server"]), dirty) | |
| 57 | self.assertEqual(dirty, self.git("rev-parse", "HEAD:server")) | |
| 58 | ||
| 59 | def test_deploys_only_when_changed_and_records_ledger(self): | |
| 60 | calls = [] | |
| 61 | ok = mock.Mock(returncode=0) | |
| 62 | targets = {"server": (["server"], ["server/install.sh"])} | |
| 63 | real_run = subprocess.run | |
| 64 | ||
| 65 | def fake(cmd, **kw): | |
| 66 | if cmd[0] == "git": | |
| 67 | return real_run(cmd, **kw) | |
| 68 | calls.append(cmd) | |
| 69 | return ok | |
| 70 | with mock.patch.object(dt, "TARGETS", targets): | |
| 71 | with mock.patch.object(dt.subprocess, "run", side_effect=fake): | |
| 72 | self.assertEqual(dt.main(["x", "server"]), 0) | |
| 73 | self.assertEqual(len(calls), 1) | |
| 74 | self.assertEqual(dt.main(["x", "server"]), 0) # unchanged: skipped | |
| 75 | self.assertEqual(len(calls), 1) | |
| 76 | self.write("server/a.txt", "two") | |
| 77 | dt.main(["x", "server"]) | |
| 78 | self.assertEqual(len(calls), 2) | |
| 79 | dt.main(["x", "server", "--force"]) | |
| 80 | self.assertEqual(len(calls), 3) | |
258a9da8 2026-11-06 Pavel Safronov | 81 | rec = self.ledger[f"{dt.REPO}/server"] |
92625227 2026-11-04 Pavel Safronov | 82 | self.assertTrue(rec["commit"].endswith("+uncommitted")) |
| 83 | ||
| 84 | def test_failed_deploy_leaves_ledger_alone(self): | |
| 85 | real_run = subprocess.run | |
| 86 | ||
| 87 | def fake(cmd, **kw): | |
| 88 | return real_run(cmd, **kw) if cmd[0] == "git" else mock.Mock(returncode=1) | |
| 89 | with mock.patch.object(dt, "TARGETS", {"server": (["server"], ["server/install.sh"])}), \ | |
| 90 | mock.patch.object(dt.subprocess, "run", side_effect=fake): | |
| 91 | self.assertEqual(dt.main(["x", "server"]), 1) | |
| 92 | self.assertEqual(self.ledger, {}) | |
| 93 | ||
| 94 | def test_content_hash_leaves_the_real_index_alone(self): | |
| 95 | index = os.path.join(self.root, ".git", "index") | |
| 96 | self.write("server/staged.txt", "s") | |
| 97 | self.git("add", "server/staged.txt") | |
| 98 | self.write("server/a.txt", "edited, not staged") | |
| 99 | with open(index, "rb") as f: | |
| 100 | before = f.read() | |
| 101 | cached = self.git("diff", "--cached") | |
| 102 | dt.content_hash(["server"]) | |
| 103 | with open(index, "rb") as f: | |
| 104 | self.assertEqual(f.read(), before) | |
| 105 | self.assertEqual(self.git("diff", "--cached"), cached) | |
| 106 | ||
| 107 | def test_content_hash_without_an_index_file_and_with_a_missing_folder(self): | |
| 108 | os.remove(os.path.join(self.root, ".git", "index")) | |
| 109 | self.assertEqual(dt.content_hash(["server"]), self.git("rev-parse", "HEAD:server")) | |
| 110 | with self.assertRaises(SystemExit): | |
| 111 | dt.content_hash(["vscode-web"]) | |
| 112 | ||
| 113 | def test_clean_deploy_has_no_uncommitted_suffix_and_laptop_runner_is_per_machine(self): | |
| 114 | real_run = subprocess.run | |
| 115 | fake = lambda cmd, **kw: real_run(cmd, **kw) if cmd[0] == "git" else mock.Mock(returncode=0) | |
| 116 | targets = {"laptop-runner": (["server"], ["server/x.sh"])} | |
258a9da8 2026-11-06 Pavel Safronov | 117 | with mock.patch.object(dt, "TARGETS", targets), mock.patch.object(dt, "PER_MACHINE", {"laptop-runner"}), \ |
| 118 | mock.patch.object(dt, "REPO", "demo"), mock.patch.object(dt.subprocess, "run", side_effect=fake), \ | |
92625227 2026-11-04 Pavel Safronov | 119 | mock.patch.object(dt.socket, "gethostname", return_value="mac-a"): |
| 120 | dt.main(["x", "laptop-runner"]) | |
258a9da8 2026-11-06 Pavel Safronov | 121 | self.assertEqual(list(self.ledger), ["demo/laptop-runner@mac-a"]) |
| 122 | self.assertFalse(self.ledger["demo/laptop-runner@mac-a"]["commit"].endswith("+uncommitted")) | |
92625227 2026-11-04 Pavel Safronov | 123 | calls = [] |
| 124 | fake2 = lambda cmd, **kw: real_run(cmd, **kw) if cmd[0] == "git" else calls.append((cmd, kw)) or mock.Mock(returncode=0) | |
258a9da8 2026-11-06 Pavel Safronov | 125 | with mock.patch.object(dt, "TARGETS", targets), mock.patch.object(dt, "PER_MACHINE", {"laptop-runner"}), \ |
| 126 | mock.patch.object(dt, "REPO", "demo"), mock.patch.object(dt.subprocess, "run", side_effect=fake2), \ | |
92625227 2026-11-04 Pavel Safronov | 127 | mock.patch.object(dt.socket, "gethostname", return_value="mac-b"): |
| 128 | dt.main(["x", "laptop-runner"]) # another Mac: not deployed there yet | |
| 129 | self.assertEqual(len(calls), 1) | |
| 130 | self.assertEqual(calls[0][1]["cwd"], self.root) | |
| 131 | self.assertTrue(os.path.isabs(calls[0][0][0])) | |
| 132 | ||
| 133 | def test_unknown_target_fails_before_any_server_access(self): | |
| 134 | with mock.patch.object(dt, "load", side_effect=AssertionError("must not reach the server")): | |
| 135 | with self.assertRaises(SystemExit): | |
| 136 | dt.main(["x", "typo"]) | |
| 137 | ||
| 138 | def test_status_lists_targets(self): | |
258a9da8 2026-11-06 Pavel Safronov | 139 | self.ledger[f"{dt.REPO}/server"] = {"hash": "stale", "commit": "abc1234", "at": "2026-11-04 10:00"} |
92625227 2026-11-04 Pavel Safronov | 140 | with mock.patch("builtins.print") as pr: |
| 141 | self.assertEqual(dt.main(["x", "status"]), 0) | |
| 142 | text = "\n".join(c.args[0] for c in pr.call_args_list) | |
| 143 | self.assertIn("PENDING", text) | |
| 144 | self.assertIn("never deployed", text) | |
| 145 | ||
258a9da8 2026-11-06 Pavel Safronov | 146 | def test_targets_are_read_from_package_json_and_checked(self): |
| 147 | def declare(deploy): | |
| 148 | self.write("package.json", json.dumps({"name": "demo", "fuzzyworld": {"deploy": deploy}})) | |
| 149 | declare({"a": {"paths": ["server"], "command": ["server/x.sh"]}, | |
| 150 | "b": {"paths": ["server"], "command": ["x"], "perMachine": True}}) | |
| 151 | with mock.patch.object(dt, "PER_MACHINE", set()) as per: | |
| 152 | repo, targets = dt.declared_targets() | |
| 153 | self.assertEqual((repo, targets["a"], per), ("demo", (["server"], ["server/x.sh"]), {"b"})) | |
| 154 | for bad in ({"a": {"paths": ["./server"], "command": ["x"]}}, {"a": {"paths": ["server/"], "command": ["x"]}}, | |
| 155 | {"a@x": {"paths": ["server"], "command": ["x"]}}, {"a/b": {"paths": ["server"], "command": ["x"]}}, | |
| 156 | {"a": {"paths": ["../etc"], "command": ["x"]}}, {"a": {"paths": ["/abs"], "command": ["x"]}}, | |
| 157 | {"a": {"paths": [], "command": ["x"]}}, {"a": {"paths": ["server"], "command": "x"}}): | |
| 158 | declare(bad) | |
| 159 | with self.assertRaises(SystemExit): | |
| 160 | dt.declared_targets() | |
| 161 | ||
ab35fc22 2026-11-07 Pavel Safronov | 162 | def test_legacy_ledger_keys_are_renamed_in_memory(self): |
| 163 | targets = {"server": (["server"], ["x"]), "laptop-runner": (["server"], ["x"])} | |
| 164 | with mock.patch.object(dt, "TARGETS", targets), mock.patch.object(dt, "REPO", "demo"), \ | |
| 165 | mock.patch.object(dt, "PER_MACHINE", {"laptop-runner"}), \ | |
| 166 | mock.patch.object(dt.socket, "gethostname", return_value="mac-a"): | |
| 167 | ledger = {"server": {"hash": "h1"}, "laptop-runner@mac-a": {"hash": "h2"}, "laptop-runner@mac-b": {"hash": "h3"}, | |
| 168 | "demo/server": {"hash": "new"}} | |
| 169 | out = dt.migrate(ledger) | |
| 170 | self.assertEqual(out["demo/server"], {"hash": "new"}) # an existing new key wins | |
| 171 | self.assertEqual(out["demo/laptop-runner@mac-a"], {"hash": "h2"}) | |
| 172 | self.assertNotIn("laptop-runner@mac-a", out) | |
| 173 | self.assertIn("laptop-runner@mac-b", out) # another machine's record is left alone | |
| 174 | ||
| 175 | def test_malformed_ledger_and_targets_give_clear_errors(self): | |
| 176 | self.ledger = {"demo/server": {"commit": "abc"}} # a record without a hash is just "changed" | |
| 177 | with mock.patch.object(dt, "TARGETS", {"server": (["server"], ["x"])}), mock.patch.object(dt, "REPO", "demo"): | |
| 178 | self.assertEqual(dt.main(["x", "status"]), 0) | |
| 179 | for bad in ({"a": "x"}, {"a": {"paths": ["server"], "command": ["../x"]}}, | |
| 180 | {"a": {"paths": [":(top)"], "command": ["x"]}}, {"a": {"paths": ["s*"], "command": ["x"]}}): | |
| 181 | self.write("package.json", json.dumps({"name": "demo", "fuzzyworld": {"deploy": bad}})) | |
| 182 | with self.assertRaises(SystemExit): | |
| 183 | dt.declared_targets() | |
| 184 | ||
258a9da8 2026-11-06 Pavel Safronov | 185 | def test_repo_name_can_be_overridden_and_must_be_plain(self): |
| 186 | self.write("package.json", json.dumps({"name": "@scope/pkg", "fuzzyworld": {"repo": "server-name", "deploy": {}}})) | |
| 187 | self.assertEqual(dt.declared_targets()[0], "server-name") | |
| 188 | self.write("package.json", json.dumps({"name": "@scope/pkg", "fuzzyworld": {"deploy": {}}})) | |
| 189 | with self.assertRaises(SystemExit): | |
| 190 | dt.declared_targets() | |
| 191 | ||
92625227 2026-11-04 Pavel Safronov | 192 | |
| 193 | if __name__ == "__main__": | |
| 194 | unittest.main() |