test_terminal.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. """The terminal recorder: prompt parsing and the frame grid anywhere; a real
  2. ttyd session wherever ttyd and a Chrome-family browser exist."""
  3. import importlib.util
  4. import json
  5. import os
  6. import shlex
  7. import shutil
  8. import subprocess
  9. import sys
  10. import tempfile
  11. import time
  12. import unittest
  13. from pathlib import Path
  14. from unittest.mock import patch
  15. import fixtures
  16. SCRIPT = Path(__file__).resolve().parents[2] / "skills/proving-it-works-with-a-movie/examples/film-terminal.py"
  17. FIXTURE = Path(__file__).resolve().with_name("fixtures") / "terminal_app.py"
  18. TTYD = os.environ.get("MOVIE_TEST_TTYD") or shutil.which("ttyd")
  19. BROWSER = fixtures.load_script("browser_tools").find_browser(os.environ.get("MOVIE_TEST_BROWSER"))
  20. SHELL = os.environ.get("MOVIE_TEST_SHELL") or ("powershell51" if os.name == "nt" else "bash")
  21. BASH = SHELL in ("bash", "gitbash")
  22. def recorder():
  23. spec = importlib.util.spec_from_file_location("film_terminal", SCRIPT)
  24. module = importlib.util.module_from_spec(spec)
  25. spec.loader.exec_module(module)
  26. return module
  27. def gone(pid, timeout=5):
  28. deadline = time.monotonic() + timeout
  29. while time.monotonic() < deadline:
  30. if os.name == "nt":
  31. listed = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"],
  32. capture_output=True, text=True).stdout
  33. if str(pid) not in listed:
  34. return True
  35. else:
  36. try:
  37. os.kill(pid, 0)
  38. except ProcessLookupError:
  39. return True
  40. time.sleep(0.1)
  41. return False
  42. class PromptTests(unittest.TestCase):
  43. def test_prompts_parse_both_terminators_and_paths_with_semicolons(self):
  44. module = recorder()
  45. log = (b"noise\x1b]0;MOVIE;1;1;;C:\\a;b\x07\x1b[0m"
  46. b"\x1b]2;MOVIE;2;0;7;/c/x\x1b\\tail"
  47. b"\x1b]0;MOVIE;3;1;0;/home/me\x07")
  48. self.assertEqual(module.prompts(log), [
  49. dict(n=1, ok=True, exit_code=None, cwd="C:\\a;b"),
  50. dict(n=2, ok=False, exit_code=7, cwd="/c/x"),
  51. dict(n=3, ok=True, exit_code=0, cwd="/home/me"),
  52. ])
  53. self.assertEqual(module.prompts(b"\x1b]0;something else\x07"), [])
  54. def test_prompt_install_is_one_typed_line_per_shell(self):
  55. module = recorder()
  56. cwd = Path("C:/Users/x/movie O'Brien λ")
  57. for kind in module.SHELLS:
  58. line = module.prompt_command(kind, cwd)
  59. self.assertEqual(len(line.splitlines()), 1, kind)
  60. self.assertNotIn("MOVIE;", line, "the marker text must not be echoed by the install line")
  61. self.assertIn("Brien λ", module.prompt_script(kind, cwd), "the script enters the cwd")
  62. def test_keys_are_named_or_single_characters(self):
  63. module = recorder()
  64. self.assertEqual(module.key_params("Ctrl-C")["modifiers"], 2)
  65. self.assertEqual(module.key_params("Enter")["text"], "\r")
  66. self.assertEqual(module.key_params("q"), dict(key="q", text="q"))
  67. with self.assertRaises(SystemExit):
  68. module.key_params("Bogus")
  69. class FilmGridTests(unittest.TestCase):
  70. def test_filming_refuses_a_nonempty_take_without_changing_its_contents(self):
  71. module = recorder()
  72. with tempfile.TemporaryDirectory() as directory:
  73. out = Path(directory) / "take"
  74. out.mkdir()
  75. (out / "f00000.png").write_bytes(b"old frame")
  76. (out / "notes.txt").write_bytes(b"sentinel evidence")
  77. before = {path.name: path.read_bytes() for path in out.iterdir()}
  78. captures = []
  79. with self.assertRaisesRegex(SystemExit, "not empty.*new take directory"):
  80. module.film(out, seconds=1, hold=0,
  81. capture=lambda: captures.append(True) or b"new frame",
  82. finished=lambda: True)
  83. self.assertEqual(captures, [])
  84. self.assertEqual(
  85. {path.name: path.read_bytes() for path in out.iterdir()}, before
  86. )
  87. def test_a_slow_capture_repeats_the_previous_frame_and_filming_holds_after_the_prompt(self):
  88. module = recorder()
  89. clock = {"now": 0.0}
  90. shots = []
  91. def capture():
  92. shots.append(len(shots) + 1)
  93. clock["now"] += 0.5 if len(shots) == 2 else 0.01 # the second screenshot stalls
  94. return bytes([len(shots)])
  95. with tempfile.TemporaryDirectory() as directory:
  96. out = Path(directory) / "take"
  97. frames = module.film(out, seconds=10, hold=0.4, capture=capture,
  98. finished=lambda: clock["now"] >= 1.0,
  99. clock=lambda: clock["now"],
  100. sleep=lambda s: clock.__setitem__("now", clock["now"] + s))
  101. files = sorted(out.glob("f*.png"))
  102. self.assertEqual([f.name for f in files], [f"f{i:05d}.png" for i in range(frames)])
  103. self.assertEqual(files[2].read_bytes(), files[1].read_bytes(), "missed slot repeats the last frame")
  104. self.assertNotEqual(files[3].read_bytes(), files[2].read_bytes())
  105. self.assertEqual(frames, 7, "1.0 s to the prompt plus 0.4 s hold at 5 fps")
  106. def test_filming_stops_at_the_deadline_while_the_command_runs(self):
  107. module = recorder()
  108. clock = {"now": 0.0}
  109. with tempfile.TemporaryDirectory() as directory:
  110. frames = module.film(Path(directory), seconds=1.0, hold=5, capture=lambda: b"png",
  111. finished=lambda: False, clock=lambda: clock["now"],
  112. sleep=lambda s: clock.__setitem__("now", clock["now"] + s))
  113. self.assertEqual(frames, 5)
  114. class ServeArgumentTests(unittest.TestCase):
  115. def test_serve_refuses_a_missing_cwd_before_launching_anything(self):
  116. with tempfile.TemporaryDirectory() as directory:
  117. result = subprocess.run([sys.executable, str(SCRIPT), "serve", str(Path(directory) / "session"),
  118. "--shell", "bash", "--cwd", str(Path(directory) / "missing")],
  119. capture_output=True, text=True, timeout=60)
  120. self.assertEqual(result.returncode, 1)
  121. self.assertIn("--cwd is not a directory", result.stderr)
  122. self.assertFalse((Path(directory) / "session" / "session.json").exists())
  123. def test_recording_verbs_refuse_a_nonempty_take_before_session_side_effects(self):
  124. module = recorder()
  125. with tempfile.TemporaryDirectory() as directory:
  126. root = Path(directory)
  127. session = root / "session"
  128. session.mkdir()
  129. (session / "ready.json").write_text("{}", encoding="utf-8")
  130. (session / "session.json").write_text(
  131. json.dumps({"debug_port": 1}), encoding="utf-8"
  132. )
  133. (session / "terminal.log").write_bytes(
  134. b"\x1b]0;MOVIE;1;1;0;/tmp\x07"
  135. )
  136. for verb, positional in (("run", ["echo hello"]),
  137. ("key", ["Enter"]),
  138. ("watch", [])):
  139. with self.subTest(verb=verb):
  140. take = root / verb
  141. take.mkdir()
  142. (take / "f00000.png").write_bytes(b"old frame")
  143. (take / "sentinel.txt").write_bytes(b"keep me")
  144. before = {path.name: path.read_bytes() for path in take.iterdir()}
  145. argv = ["film-terminal", verb, str(session), *positional,
  146. "--record", str(take)]
  147. with patch.object(sys, "argv", argv), \
  148. patch.object(module, "connect",
  149. side_effect=AssertionError("connected")), \
  150. patch.object(module, "type_text",
  151. side_effect=AssertionError("typed")), \
  152. patch.object(module, "press",
  153. side_effect=AssertionError("pressed")), \
  154. self.assertRaisesRegex(SystemExit,
  155. "not empty.*new take directory"):
  156. module.main()
  157. self.assertEqual(
  158. {path.name: path.read_bytes() for path in take.iterdir()}, before
  159. )
  160. @unittest.skipUnless(TTYD and BROWSER, "ttyd and a Chrome-family browser are required")
  161. class SessionTests(unittest.TestCase):
  162. def setUp(self):
  163. self.tmp = tempfile.TemporaryDirectory(prefix="movie-terminal-", ignore_cleanup_errors=True)
  164. self.addCleanup(self.tmp.cleanup)
  165. self.work = Path(self.tmp.name) / "movie O'Brien λ"
  166. self.work.mkdir()
  167. self.session = Path(self.tmp.name) / "session"
  168. self.log = (Path(self.tmp.name) / "serve.log").open("wb")
  169. self.addCleanup(self.log.close)
  170. argv = [sys.executable, str(SCRIPT), "serve", str(self.session), "--shell", SHELL,
  171. "--cwd", str(self.work), "--ttyd", TTYD, "--browser", BROWSER]
  172. if os.environ.get("MOVIE_TEST_SHELL_EXE"):
  173. argv += ["--shell-exe", os.environ["MOVIE_TEST_SHELL_EXE"]]
  174. self.owned_pids = []
  175. self.serve = subprocess.Popen(argv, stdout=self.log, stderr=subprocess.STDOUT)
  176. self.addCleanup(self.close_session)
  177. deadline = time.monotonic() + 45
  178. while not (self.session / "ready.json").exists() and self.serve.poll() is None \
  179. and time.monotonic() < deadline:
  180. time.sleep(0.1)
  181. if (self.session / "session.json").exists():
  182. self.owned_pids = json.loads((self.session / "session.json").read_text(encoding="utf-8"))["pids"]
  183. if not (self.session / "ready.json").exists():
  184. report = "".join(f"--- {name}\n" + path.read_text(errors="replace") if path.exists() else ""
  185. for name, path in (("serve.log", Path(self.tmp.name) / "serve.log"),
  186. ("ttyd.log", self.session / "ttyd.log"),
  187. ("browser.log", self.session / "browser.log")))
  188. self.fail(report)
  189. def close_session(self):
  190. if self.serve.poll() is None:
  191. # A failed setup may not have session metadata yet, but the
  192. # owner still needs its stop request before we wait for cleanup.
  193. self.session.mkdir(parents=True, exist_ok=True)
  194. (self.session / "stop").write_text("", encoding="utf-8")
  195. try:
  196. self.serve.wait(35)
  197. except subprocess.TimeoutExpired:
  198. self.serve.kill()
  199. self.serve.wait()
  200. for pid in self.owned_pids:
  201. self.assertTrue(gone(pid), f"pid {pid} survived close")
  202. def cli(self, *args, timeout=120):
  203. result = subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, timeout=timeout)
  204. return result.returncode, result.stdout.decode("utf-8", "replace"), result.stderr.decode("utf-8", "replace")
  205. def run_command(self, command, *extra):
  206. code, out, err = self.cli("run", str(self.session), command, *extra)
  207. self.assertTrue(out.strip(), err)
  208. return code, json.loads(out.strip().splitlines()[-1])
  209. def quoted(self, *words):
  210. if BASH:
  211. return " ".join(shlex.quote(w.replace("\\", "/")) for w in words)
  212. return "& " + " ".join("'" + w.replace("'", "''") + "'" for w in words)
  213. def native(self, code):
  214. return self.quoted(sys.executable) + f' -c "{code}"'
  215. def test_commands_report_status_and_the_shell_persists_between_calls(self):
  216. code, result = self.run_command("echo hello")
  217. self.assertEqual((code, result["outcome"], result["ok"]), (0, "completed", True), result)
  218. self.assertTrue(result["cwd"].endswith("movie O'Brien λ"), result["cwd"])
  219. if BASH:
  220. set_value, check_value, failing = "MOVIE_VALUE=kept", 'test "$MOVIE_VALUE" = kept', "false"
  221. else:
  222. set_value = "$global:MovieValue = 'kept'"
  223. check_value = "if ($global:MovieValue -ne 'kept') { throw 'lost' }"
  224. failing = "Get-Item 'Z:\\nowhere'"
  225. self.assertEqual(self.run_command(set_value)[0], 0)
  226. self.assertEqual(self.run_command(check_value)[0], 0, "state must survive separate calls")
  227. code, result = self.run_command(failing)
  228. self.assertEqual((code, result["ok"]), (1, False), result)
  229. code, result = self.run_command(self.native("import sys; sys.exit(7)"))
  230. self.assertEqual((code, result["ok"], result["exit_code"]), (1, False, 7), result)
  231. def test_a_tui_is_filmed_across_two_takes_and_a_long_command_across_calls(self):
  232. from PIL import Image
  233. take_one, take_two, take_three = (self.work / name for name in ("take-one", "take-two", "take-three"))
  234. code, result = self.run_command(self.quoted(sys.executable, str(FIXTURE)),
  235. "--record", str(take_one), "--seconds", "7")
  236. self.assertEqual((code, result["outcome"]), (2, "running"), result)
  237. frames = sorted(take_one.glob("f*.png"))
  238. self.assertGreaterEqual(len(frames), 30, "7 s at 5 fps")
  239. seen = []
  240. for frame in frames:
  241. with Image.open(frame) as image:
  242. r, g, b = image.convert("RGB").getpixel((300, 120))
  243. color = ("red" if r > 150 and g < 100 and b < 100 else
  244. "green" if g > 120 and r < 100 and b < 140 else
  245. "blue" if b > 150 and r < 100 and g < 140 else None)
  246. if color and (not seen or seen[-1] != color):
  247. seen.append(color)
  248. self.assertEqual(seen, ["red", "green", "blue"], "the three TUI states, in order, in the automatic frames")
  249. code, out, err = self.cli("key", str(self.session), "q", "--record", str(take_two), "--seconds", "10")
  250. result = json.loads(out.strip().splitlines()[-1])
  251. self.assertEqual((code, result["outcome"], result["ok"]), (0, "completed", True), (result, err))
  252. self.assertGreaterEqual(result["frames"], 7, "the exit plus the 1.5 s hold")
  253. self.assertEqual(len(json.loads((self.work / "states.json").read_text())), 3)
  254. code, result = self.run_command(self.native("import time; time.sleep(3)"), "--seconds", "1")
  255. self.assertEqual(result["outcome"], "running")
  256. code, out, err = self.cli("watch", str(self.session), "--record", str(take_three), "--seconds", "15")
  257. result = json.loads(out.strip().splitlines()[-1])
  258. self.assertEqual((code, result["outcome"], result["ok"]), (0, "completed", True), (result, err))
  259. self.assertGreaterEqual(result["frames"], 10, "about 2 s of waiting plus the hold")
  260. self.assertEqual(result["scene"], {"kind": "frames", "src": str(take_three.resolve()), "rate": 5})
  261. def test_close_kills_the_shell_tree_and_spares_unrelated_processes(self):
  262. sentinel = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"])
  263. self.addCleanup(sentinel.kill)
  264. tree = self.work / "tree"
  265. code, result = self.run_command(self.quoted(sys.executable, str(FIXTURE), "tree", str(tree)), "--seconds", "2")
  266. self.assertEqual(result["outcome"], "running")
  267. deadline = time.monotonic() + 20
  268. while len(list(tree.glob("*.json"))) < 3 and time.monotonic() < deadline:
  269. time.sleep(0.1)
  270. pids = [json.loads(path.read_text())["pid"] for path in tree.glob("*.json")]
  271. self.assertEqual(len(pids), 3)
  272. code, out, err = self.cli("close", str(self.session))
  273. self.assertEqual(code, 0, err)
  274. self.assertEqual(self.serve.wait(15), 0)
  275. for pid in pids:
  276. self.assertTrue(gone(pid), f"descendant {pid} survived close")
  277. self.assertIsNone(sentinel.poll(), "an unrelated process must survive")
  278. if __name__ == "__main__":
  279. unittest.main()