fuzzyworld

fuzzyworld-tools / files / tests / test_deploy_tools.py

test_deploy_tools.py

Blame: the commit that last changed each line · file · history

92625227 2026-11-04 Pavel Safronov1import importlib.machinery
2import importlib.util
3import json
4import os
5import shutil
6import subprocess
7import tempfile
8import unittest
9from unittest import mock
10
11PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "deploy-tools")
12loader = importlib.machinery.SourceFileLoader("deploy_tools", PATH)
13dt = importlib.util.module_from_spec(importlib.util.spec_from_loader("deploy_tools", loader))
14loader.exec_module(dt)
15
16
17class 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 Safronov81 rec = self.ledger[f"{dt.REPO}/server"]
92625227 2026-11-04 Pavel Safronov82 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 Safronov117 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 Safronov119 mock.patch.object(dt.socket, "gethostname", return_value="mac-a"):
120 dt.main(["x", "laptop-runner"])
258a9da8 2026-11-06 Pavel Safronov121 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 Safronov123 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 Safronov125 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 Safronov127 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 Safronov139 self.ledger[f"{dt.REPO}/server"] = {"hash": "stale", "commit": "abc1234", "at": "2026-11-04 10:00"}
92625227 2026-11-04 Pavel Safronov140 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 Safronov146 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 Safronov162 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 Safronov185 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 Safronov192
193if __name__ == "__main__":
194 unittest.main()