test_browser_contract.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. """Browser cleanup decisions with fake processes and a completed-output token."""
  2. import contextlib
  3. import os
  4. import subprocess
  5. import tempfile
  6. import unittest
  7. from pathlib import Path
  8. from types import SimpleNamespace
  9. from unittest.mock import patch
  10. import fixtures
  11. class CompletedOutput:
  12. """Stand in for the completed-output observation without creating an image."""
  13. def startswith(self, prefix):
  14. return True
  15. def endswith(self, suffix):
  16. return True
  17. @contextlib.contextmanager
  18. def rendering(*, exited=False, taskkill_status=0, wait_timeout=False, locked_profile=False):
  19. module = fixtures.load_script("browser_tools")
  20. with tempfile.TemporaryDirectory() as temp, contextlib.ExitStack() as stack:
  21. html, png = Path(temp).resolve() / "card.html", Path(temp).resolve() / "card.png"
  22. html.write_text("<p>card</p>")
  23. process = SimpleNamespace(pid=1100, returncode=0 if exited else None)
  24. process.poll = lambda: process.returncode
  25. calls, profiles = [], []
  26. original_is_file, original_unlink = Path.is_file, os.unlink
  27. def popen(argv, **kwargs):
  28. profiles.append(Path(kwargs["cwd"]))
  29. return process
  30. def taskkill(argv, **kwargs):
  31. calls.append(argv)
  32. if taskkill_status == 0 and not wait_timeout:
  33. process.returncode = -9
  34. return subprocess.CompletedProcess(argv, taskkill_status, b"", b"termination failed")
  35. def wait(timeout):
  36. if process.returncode is None:
  37. raise subprocess.TimeoutExpired("fake browser", timeout)
  38. return process.returncode
  39. def unlink(path, *args, **kwargs):
  40. if locked_profile and Path(path).name == "browser.log":
  41. raise PermissionError("locked browser profile")
  42. return original_unlink(path, *args, **kwargs)
  43. process.wait = wait
  44. for obj, name, value in (
  45. (module.sys, "platform", "win32"),
  46. (module.subprocess, "Popen", popen),
  47. (module.subprocess, "run", taskkill),
  48. (Path, "is_file", lambda path: True if path == png else original_is_file(path)),
  49. (Path, "read_bytes", lambda path: CompletedOutput()),
  50. (os, "unlink", unlink),
  51. ):
  52. stack.enter_context(patch.object(obj, name, value))
  53. try:
  54. yield SimpleNamespace(render=lambda: module.render_card(html, png, browser="fake-browser", width=640, height=360),
  55. process=process, calls=calls, profiles=profiles)
  56. finally:
  57. stack.close()
  58. for profile in profiles:
  59. if profile.exists():
  60. module.shutil.rmtree(profile)
  61. class BrowserCleanupContract(unittest.TestCase):
  62. def test_windows_tree_termination_failure_reaches_caller_as_oserror(self):
  63. module = fixtures.load_script("browser_tools")
  64. with patch.object(module.sys, "platform", "win32"), \
  65. patch.object(module.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, b"", b"access denied")):
  66. with self.assertRaises(OSError):
  67. module.kill_process_tree(1100)
  68. def test_completed_output_does_not_hide_tree_termination_failure(self):
  69. with rendering(taskkill_status=1) as rig:
  70. with self.assertRaises(OSError):
  71. rig.render()
  72. def test_completed_output_does_not_hide_owned_child_wait_timeout(self):
  73. with rendering(wait_timeout=True) as rig:
  74. with self.assertRaises(subprocess.TimeoutExpired):
  75. rig.render()
  76. def test_completed_output_does_not_hide_locked_profile(self):
  77. with rendering(locked_profile=True) as rig:
  78. with self.assertRaises(PermissionError):
  79. rig.render()
  80. def test_normally_exited_completed_card_succeeds_without_numeric_pid_cleanup(self):
  81. with rendering(exited=True, taskkill_status=1) as rig:
  82. self.assertIsNone(rig.render())
  83. self.assertEqual(rig.calls, [])
  84. self.assertFalse(rig.profiles[0].exists())
  85. def test_completed_card_releases_live_browser_and_profile(self):
  86. with rendering() as rig:
  87. self.assertIsNone(rig.render())
  88. self.assertEqual(rig.process.poll(), -9)
  89. self.assertEqual(len(rig.calls), 1)
  90. self.assertFalse(rig.profiles[0].exists())
  91. if __name__ == "__main__":
  92. unittest.main()