♻️ gremlins: platform seam (WindowsPlatform, PosixPlatform) and path matrix
This commit is contained in:
@@ -37,7 +37,8 @@ def _load_gremlins():
|
||||
|
||||
|
||||
gremlins = _load_gremlins()
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
IS_WINDOWS = os.name == "nt" # test-only: generated wrappers (.cmd vs sh) and the module-end sweep
|
||||
WIN_PLATFORM = gremlins.PLATFORM.name == "windows" # platform behaviour under test (digest §3.5)
|
||||
STUB_VERSION = "2.1.999"
|
||||
|
||||
|
||||
@@ -134,7 +135,7 @@ class PathTests(Base):
|
||||
"""M13: a Git Bash rooted path that is not a drive form is refused (exit 2) when cygpath is
|
||||
not on PATH; it is never mapped onto the cwd's drive. Drive forms need no cygpath."""
|
||||
cwd = "G:\\dev\\proj"
|
||||
with mock.patch.object(gremlins, "find_cygpath", lambda: None):
|
||||
with mock.patch.object(gremlins.WindowsPlatform, "find_cygpath", lambda: None):
|
||||
for src in ("/tmp/x", "/usr/share/doc", "/tmp/x-run.md"):
|
||||
with self.subTest(src=src):
|
||||
with self.assertRaises(gremlins.GremlinsError) as cm:
|
||||
@@ -145,7 +146,7 @@ class PathTests(Base):
|
||||
self.assertEqual(gremlins.norm_path("/g/x/y.md", cwd=cwd, windows=True), "G:\\x\\y.md")
|
||||
self.assertEqual(gremlins.norm_path("/c", cwd=cwd, windows=True), "C:\\")
|
||||
self.assertEqual(gremlins.norm_path("\\rooted\\a", cwd=cwd, windows=True), "G:\\rooted\\a")
|
||||
if IS_WINDOWS: # end to end: --run /tmp/... is refused, nothing written on the cwd's drive
|
||||
if WIN_PLATFORM: # end to end: --run /tmp/... is refused, nothing written on the cwd's drive
|
||||
drive = os.path.splitdrive(os.getcwd())[0]
|
||||
code, out, raw = call(["init", "--run", "/tmp/gremlins-m13-x-run.md", "--model",
|
||||
"claude-opus-5-5", "--cwd", self.tmp])
|
||||
@@ -158,19 +159,19 @@ class PathTests(Base):
|
||||
"""M13: with cygpath on PATH the path is converted by `cygpath -w`; a failing cygpath
|
||||
refuses."""
|
||||
exe, log = self.fake_cygpath()
|
||||
with mock.patch.object(gremlins, "find_cygpath", lambda: exe):
|
||||
with mock.patch.object(gremlins.WindowsPlatform, "find_cygpath", lambda: exe):
|
||||
self.assertEqual(gremlins.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True),
|
||||
"C:\\Fake\\tmp\\x")
|
||||
self.assertEqual(gremlins.norm_path("/g/x", cwd="G:\\dev\\proj", windows=True), "G:\\x")
|
||||
with open(log) as f:
|
||||
self.assertEqual(f.read().splitlines(), ["-w /tmp/x"]) # drive forms skip cygpath
|
||||
bad, _ = self.fake_cygpath(code=1)
|
||||
with mock.patch.object(gremlins, "find_cygpath", lambda: bad):
|
||||
with mock.patch.object(gremlins.WindowsPlatform, "find_cygpath", lambda: bad):
|
||||
with self.assertRaises(gremlins.GremlinsError) as cm:
|
||||
gremlins.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True)
|
||||
self.assertEqual(cm.exception.payload["error"], "cygpath failed")
|
||||
real = shutil.which("cygpath")
|
||||
if IS_WINDOWS and real: # the real one (Git Bash): never the cwd's drive + \tmp
|
||||
if WIN_PLATFORM and real: # the real one (Git Bash): never the cwd's drive + \tmp
|
||||
got = gremlins.norm_path("/tmp/x", cwd="G:\\dev\\proj", windows=True)
|
||||
self.assertNotEqual(got, "G:\\tmp\\x")
|
||||
self.assertRegex(got, r"^[A-Z]:\\.+\\x$")
|
||||
@@ -192,7 +193,7 @@ class PathTests(Base):
|
||||
with self.assertRaises(gremlins.GremlinsError):
|
||||
gremlins.norm_path(" ")
|
||||
|
||||
@unittest.skipUnless(IS_WINDOWS, "Windows path forms")
|
||||
@unittest.skipUnless(WIN_PLATFORM, "Windows path forms")
|
||||
def test_state_dir_windows_forms(self):
|
||||
want = "G:\\w\\reports\\2026-10-16-08-x-run-gremlins"
|
||||
for src in ("G:\\w\\reports\\2026-10-16-08-x-run.md", "G:/w/reports/2026-10-16-08-x-run.md",
|
||||
@@ -211,6 +212,108 @@ class PathTests(Base):
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
class PathMatrixTests(Base):
|
||||
"""Digest §3.4 (amendment A5): every path form x both platform classes, on any host. Pure: the
|
||||
cygpath lookup and its one call are faked, nothing touches the filesystem."""
|
||||
|
||||
WIN_CWD = "G:\\dev\\proj"
|
||||
POSIX_CWD = "/home/u/proj"
|
||||
# (row, input, Windows result, POSIX result). A result is the normalised path, or
|
||||
# ("refused", <word the error names>).
|
||||
ROWS = (
|
||||
(1, "C:\\x\\y.md", "C:\\x\\y.md", ("refused", "drive")),
|
||||
(2, "c:/x/y.md", "C:\\x\\y.md", ("refused", "drive")),
|
||||
(3, "C:", ("refused", "cannot normalise"), ("refused", "drive")),
|
||||
(3, "C:x", ("refused", "cannot normalise"), ("refused", "drive")),
|
||||
(4, "/c/x/y.md", "C:\\x\\y.md", "/c/x/y.md"),
|
||||
(5, "/c", "C:\\", "/c"),
|
||||
(6, "/tmp/x", "C:\\Fake\\tmp\\x", "/tmp/x"),
|
||||
(7, "\\\\srv\\share\\x\\y.md", "\\\\srv\\share\\x\\y.md", ("refused", "backslash")),
|
||||
(8, "//srv/share/x/y.md", "\\\\srv\\share\\x\\y.md", "//srv/share/x/y.md"),
|
||||
(9, "/mnt/c/x/y.md", ("refused", "WSL"), "/mnt/c/x/y.md"),
|
||||
(10, "docs/a.md", "G:\\dev\\proj\\docs\\a.md", "/home/u/proj/docs/a.md"),
|
||||
(11, "docs\\a.md", "G:\\dev\\proj\\docs\\a.md", ("refused", "backslash")),
|
||||
(12, "\\rooted\\a", "G:\\rooted\\a", ("refused", "backslash")),
|
||||
(13, "../z", "G:\\dev\\z", "/home/u/z"),
|
||||
(14, " G:/x/y.md ", "G:\\x\\y.md", ("refused", "drive")),
|
||||
(15, "", ("refused", "empty"), ("refused", "empty")),
|
||||
(15, " ", ("refused", "empty"), ("refused", "empty")),
|
||||
)
|
||||
FAKE_CYGPATH = "fake-cygpath"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cyg_calls = []
|
||||
|
||||
def fake_run(args, cwd=None, timeout=60, env=None):
|
||||
self.assertEqual(list(args[:2]), [self.FAKE_CYGPATH, "-w"]) # the only process call
|
||||
self.cyg_calls.append(args[2])
|
||||
out = "C:\\Fake" + args[2].replace("/", "\\") + "\r\n"
|
||||
return subprocess.CompletedProcess(args, 0, stdout=out.encode(), stderr=b"")
|
||||
for target, name, fn in ((gremlins.WindowsPlatform, "find_cygpath", lambda: self.FAKE_CYGPATH),
|
||||
(gremlins, "_run", fake_run)):
|
||||
p = mock.patch.object(target, name, fn)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def check(self, fn, cwd, src, want):
|
||||
if isinstance(want, tuple):
|
||||
with self.assertRaises(gremlins.GremlinsError) as cm:
|
||||
fn(src, cwd)
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
self.assertIn(want[1], cm.exception.payload["error"])
|
||||
self.assertEqual(cm.exception.payload["path"], src)
|
||||
else:
|
||||
self.assertEqual(fn(src, cwd), want)
|
||||
|
||||
def test_windows_rows(self):
|
||||
for row, src, want, _ in self.ROWS:
|
||||
with self.subTest(row=row, src=src):
|
||||
self.check(gremlins.WindowsPlatform.norm_path, self.WIN_CWD, src, want)
|
||||
self.assertEqual(self.cyg_calls, ["/tmp/x"]) # row 6 only; drive forms skip cygpath
|
||||
|
||||
def test_posix_rows(self):
|
||||
for row, src, _, want in self.ROWS:
|
||||
with self.subTest(row=row, src=src):
|
||||
self.check(gremlins.PosixPlatform.norm_path, self.POSIX_CWD, src, want)
|
||||
self.assertEqual(self.cyg_calls, [])
|
||||
|
||||
def test_row6_without_cygpath(self):
|
||||
"""M13: without cygpath a Git Bash rooted non-drive path is refused on Windows."""
|
||||
with mock.patch.object(gremlins.WindowsPlatform, "find_cygpath", lambda: None):
|
||||
self.check(gremlins.WindowsPlatform.norm_path, self.WIN_CWD, "/tmp/x", ("refused", "cygpath"))
|
||||
self.assertEqual(self.cyg_calls, [])
|
||||
|
||||
def test_delegate_selects_the_class(self):
|
||||
"""The module-level `norm_path(windows=…)` is a selector over the two classes."""
|
||||
self.assertEqual(gremlins.norm_path("../z", cwd=self.WIN_CWD, windows=True), "G:\\dev\\z")
|
||||
self.assertEqual(gremlins.norm_path("../z", cwd=self.POSIX_CWD, windows=False), "/home/u/z")
|
||||
with self.assertRaises(gremlins.GremlinsError):
|
||||
gremlins.norm_path("C:\\x", cwd=self.POSIX_CWD, windows=False)
|
||||
|
||||
def test_both_classes_build_anywhere(self):
|
||||
"""No ctypes at construction: both classes and their pure parts work on any host."""
|
||||
w, p = gremlins.WindowsPlatform(), gremlins.PosixPlatform()
|
||||
self.assertIsNone(w._api)
|
||||
self.assertEqual((w.name, w.detach_method), ("windows", "cim-hidden"))
|
||||
self.assertEqual((p.name, p.detach_method), ("posix", "setsid"))
|
||||
self.assertEqual(w.popen_kwargs(), {"creationflags": 0x08000000})
|
||||
self.assertEqual(w.popen_kwargs(new_group=True), {"creationflags": 0x08000000})
|
||||
self.assertEqual(p.popen_kwargs(), {"start_new_session": False})
|
||||
self.assertEqual(p.popen_kwargs(new_group=True), {"start_new_session": True})
|
||||
self.assertEqual(w.epoch_to_ctime(0), 116444736000000000)
|
||||
self.assertIsNone(w.epoch_to_ctime(None))
|
||||
self.assertIsNone(p.visible_windows())
|
||||
for d, name in ((w.describe(), "windows"), (p.describe(), "posix")):
|
||||
self.assertEqual(set(d), {"name", "sys_platform", "wsl", "supported", "detach"})
|
||||
self.assertEqual((d["name"], d["sys_platform"]), (name, sys.platform))
|
||||
self.assertIsNone(w._api)
|
||||
# select_platform: the test's own os.name check is the reference here (IS_WINDOWS)
|
||||
self.assertIs(type(gremlins.PLATFORM), gremlins.WindowsPlatform if IS_WINDOWS
|
||||
else gremlins.PosixPlatform)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
class ScrubTests(Base):
|
||||
def test_scrubbed_env(self):
|
||||
@@ -571,7 +674,7 @@ class ProcessTreeTests(Base):
|
||||
seen = []
|
||||
with mock.patch.object(gremlins, "kill_tree", lambda rec, extra=(), alive_until=None:
|
||||
seen.append((rec, alive_until)) or {"snapshot": [], "survivors": [],
|
||||
"taskkill_rc": None}):
|
||||
"tree_kill_rc": None}):
|
||||
self.assertEqual(gremlins.reap_lost(st), ["j0001"])
|
||||
self.assertEqual(seen, [(self.rec(), gremlins._ts(gremlins._iso(old)))])
|
||||
|
||||
@@ -1052,7 +1155,8 @@ class SubprocessTests(Base):
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
out = self.assert_one_json_line(r.stdout.decode("utf-8"))
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["spawn"], "cim-hidden" if IS_WINDOWS else "setsid")
|
||||
self.assertEqual(out["platform"]["detach"], "cim-hidden" if WIN_PLATFORM else "setsid")
|
||||
self.assertNotIn("spawn", out)
|
||||
self.assertEqual(out["binary"]["version"], STUB_VERSION)
|
||||
self.assertEqual(out["binary"]["how"], "--binary")
|
||||
self.assertEqual(os.path.normcase(out["script"]), os.path.normcase(os.path.abspath(GREMLINS_PY)))
|
||||
@@ -1294,25 +1398,9 @@ def price(tokens: dict) -> float:
|
||||
|
||||
|
||||
def visible_windows() -> list[tuple]:
|
||||
"""Visible top-level windows: [(hwnd, pid, class, title)] (Windows only)."""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
u32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
out = []
|
||||
proto = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
|
||||
|
||||
def cb(hwnd, _):
|
||||
if u32.IsWindowVisible(hwnd):
|
||||
pid = wt.DWORD()
|
||||
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
cls = ctypes.create_unicode_buffer(256)
|
||||
u32.GetClassNameW(hwnd, cls, 256)
|
||||
ttl = ctypes.create_unicode_buffer(256)
|
||||
u32.GetWindowTextW(hwnd, ttl, 256)
|
||||
out.append((int(hwnd or 0), pid.value, cls.value, ttl.value))
|
||||
return True
|
||||
u32.EnumWindows(proto(cb), 0)
|
||||
return out
|
||||
"""Visible top-level windows: [(hwnd, pid, class, title)] (`PLATFORM.visible_windows()`; []
|
||||
where the platform has no such check)."""
|
||||
return gremlins.PLATFORM.visible_windows() or []
|
||||
|
||||
|
||||
# P0.1's detector: a visible console window (conhost) or Windows Terminal window. The latter belongs
|
||||
@@ -2689,7 +2777,7 @@ class LaunchTests(LaunchBase):
|
||||
self.assertIn(rt["claude"]["pid"], pids)
|
||||
self.assertIn(stub["pid"], pids)
|
||||
self.assertIn(stub["child_pid"], pids)
|
||||
if IS_WINDOWS:
|
||||
if WIN_PLATFORM:
|
||||
# spawned through CIM: the supervisor's parent is the WMI provider host, not us
|
||||
table = {pid: (ppid, name) for pid, ppid, name in gremlins.process_table()}
|
||||
parent = table.get(table[sup["pid"]][0], (None, None))[1]
|
||||
@@ -2818,19 +2906,20 @@ class LaunchTests(LaunchBase):
|
||||
["wait", "--run", self.run_log, "--all", "j0001"]):
|
||||
self.assertEqual(call(argv)[0], 2, argv)
|
||||
|
||||
def test_spawn_failed(self):
|
||||
def test_detach_failed(self):
|
||||
self.setup_run()
|
||||
failed = {"ok": False, "method": gremlins.DETACH_METHOD, "detail": "Win32_Process.Create returned 9"}
|
||||
failed = {"ok": False, "method": gremlins.PLATFORM.detach_method,
|
||||
"detail": "Win32_Process.Create returned 9"}
|
||||
with mock.patch.object(gremlins, "detach_supervisor", lambda *a: failed):
|
||||
t = self.train("B1")
|
||||
code, out = self.wait(t["job"], timeout=5)
|
||||
self.assertEqual(code, 4, out)
|
||||
r = out["done"][0]
|
||||
self.assertEqual((r["status"], r["reason"]), ("failed", "spawn_failed"))
|
||||
self.assertEqual((r["status"], r["reason"]), ("failed", "detach_failed"))
|
||||
self.assertEqual(r["tokens"], {k: None for k in gremlins.TOKEN_KEYS})
|
||||
self.assertEqual(self.reg()["bases"]["B1"]["status"], "failed")
|
||||
self.assertEqual(self.reg()["jobs"][t["job"]]["reason_detail"], failed["detail"])
|
||||
self.assertEqual([x["reason"] for x in self.ledger()], ["spawn_failed"])
|
||||
self.assertEqual([x["reason"] for x in self.ledger()], ["detach_failed"])
|
||||
self.assertEqual(stub_calls(self.stub), [])
|
||||
|
||||
def test_refusals(self):
|
||||
@@ -2896,14 +2985,14 @@ class LaunchTests(LaunchBase):
|
||||
|
||||
# --- block 2.1 -----------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(IS_WINDOWS, "visible windows are a Windows check")
|
||||
@unittest.skipUnless(WIN_PLATFORM, "visible windows are a Windows check")
|
||||
def test_no_visible_window(self):
|
||||
"""M8: while a job runs, no visible window belongs to its process tree, and no visible
|
||||
console or Windows Terminal window appears anywhere on the desktop (a Terminal window
|
||||
belongs to WindowsTerminal.exe, not to the tree). A positive control, a deliberately visible
|
||||
console (minimised, not activated) closed at once, shows the check sees one. Skipped, not
|
||||
failed, when there is no desktop."""
|
||||
if not visible_windows():
|
||||
failed, when there is no desktop (or the platform has no window check: None)."""
|
||||
if not gremlins.PLATFORM.visible_windows():
|
||||
self.skipTest("no desktop: window enumeration sees no visible window")
|
||||
# positive control
|
||||
before = {w[0] for w in console_windows()}
|
||||
|
||||
Reference in New Issue
Block a user