♻️ gremlins: platform seam (WindowsPlatform, PosixPlatform) and path matrix

This commit is contained in:
2026-10-16 20:36:18 +02:00
parent 065afcba4c
commit 25d03587b4
2 changed files with 629 additions and 328 deletions
@@ -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()}