Files
malkuthroandCursor cf32beb8b0 fix(install-guard): skip duplicate scan in linked git worktrees (#281) (#282)
* fix(install-guard): skip duplicate scan in linked git worktrees

Linked worktrees outside custom_nodes/ are not ComfyUI installs; stop treating sibling worktrees as #162 duplicates so pytest can register nodes. Closes #281.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(install-guard): keep #162 active for custom_nodes→worktree symlinks

Use the unresolved load path for the worktree skip so Path.resolve() cannot drop a custom_nodes parent and disable the duplicate scan.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:27:46 +02:00

425 lines
18 KiB
Python

"""Tests for the duplicate-install guard (#162) — see
``koolook_install_guard.py`` for the module under test.
Each test materialises a simulated ``custom_nodes/`` layout under
``tmp_path`` and runs the pure-function helpers directly. No ComfyUI,
no aiohttp, no relative imports — the guard is deliberately decoupled
from the runtime so it can be exercised by stdlib pytest.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from koolook_install_guard import (
build_duplicate_report,
detect_duplicate_koolook_installs,
is_linked_git_worktree,
pick_winning_install,
read_pyproject_version,
should_run_duplicate_guard,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
def test_entrypoint_direct_file_import_exposes_scanner_visible_nodes() -> None:
"""Registry scanners may load ``__init__.py`` directly from a file path.
That import style used to make relative imports fail and left
``NODE_CLASS_MAPPINGS`` empty, which makes the public Registry render
"No nodes found" even though ComfyUI can load the pack normally.
"""
module_name = "koolook_scanner_style_entrypoint_test"
spec = importlib.util.spec_from_file_location(
module_name,
REPO_ROOT / "__init__.py",
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
mappings = module.NODE_CLASS_MAPPINGS
finally:
for name in list(sys.modules):
if name == module_name or name.startswith(f"{module_name}."):
sys.modules.pop(name, None)
assert "EasyAIPipeline" in mappings
assert "Koolook_PublishInput" in mappings
assert mappings
def _make_install(parent: Path, name: str, version: str | None = "0.3.7") -> Path:
"""Materialise a fake Koolook install — a directory with the
``koolook_routes.py`` marker file and an optional ``pyproject.toml``
with the given version. Returns the install directory.
"""
install_dir = parent / name
install_dir.mkdir()
(install_dir / "koolook_routes.py").write_text("# marker", encoding="utf-8")
if version is not None:
(install_dir / "pyproject.toml").write_text(
f'[project]\nname = "koolook"\nversion = "{version}"\n',
encoding="utf-8",
)
return install_dir
# =============================================================================
# is_linked_git_worktree / should_run_duplicate_guard (#281)
# =============================================================================
def test_is_linked_git_worktree_detects_gitdir_file(tmp_path: Path) -> None:
"""A ``.git`` *file* whose contents start with ``gitdir:`` is a linked
worktree — the shape agent / Cursor checkouts use."""
here = _make_install(tmp_path, "koolook")
(here / ".git").write_text(
"gitdir: /tmp/fake/.git/worktrees/koolook\n", encoding="utf-8"
)
assert is_linked_git_worktree(here) is True
def test_is_linked_git_worktree_false_for_git_directory(tmp_path: Path) -> None:
"""A plain ``git clone`` has ``.git`` as a directory — not a linked
worktree; the duplicate-install guard must still run."""
here = _make_install(tmp_path, "koolook")
(here / ".git").mkdir()
assert is_linked_git_worktree(here) is False
def test_is_linked_git_worktree_false_when_git_absent(tmp_path: Path) -> None:
"""Manager / Registry installs have no ``.git`` at all — not a
worktree; the guard must still run."""
here = _make_install(tmp_path, "koolook")
assert is_linked_git_worktree(here) is False
def test_should_run_guard_skips_worktree_outside_custom_nodes(
tmp_path: Path,
) -> None:
"""Linked worktree under a non-``custom_nodes`` parent (e.g.
``.claude/worktrees/``) is not a ComfyUI install — skip the sibling
scan so sibling worktrees do not look like duplicate installs."""
parent = tmp_path / "worktrees"
parent.mkdir()
here = _make_install(parent, "repro-b")
(here / ".git").write_text(
"gitdir: /tmp/fake/.git/worktrees/repro-b\n", encoding="utf-8"
)
assert should_run_duplicate_guard(here) is False
def test_should_run_guard_keeps_worktree_under_custom_nodes(
tmp_path: Path,
) -> None:
"""A linked worktree deliberately placed inside ``custom_nodes/``
is genuine competition — the #162 guard must still fire."""
custom_nodes = tmp_path / "custom_nodes"
custom_nodes.mkdir()
here = _make_install(custom_nodes, "koolook")
(here / ".git").write_text(
"gitdir: /tmp/fake/.git/worktrees/koolook\n", encoding="utf-8"
)
assert should_run_duplicate_guard(here) is True
def _link_dir(link: Path, target: Path) -> None:
"""Create a directory symlink, or a Windows junction if symlinks are
blocked (no Developer Mode / admin). Raises OSError if both fail."""
try:
link.symlink_to(target, target_is_directory=True)
return
except OSError:
pass
if sys.platform == "win32":
import subprocess
# Junctions do not require elevated symlink privilege.
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and link.exists():
return
raise OSError(
result.returncode,
f"mklink /J failed: {result.stderr or result.stdout}",
)
raise OSError("directory symlink not supported")
def test_should_run_guard_keeps_symlink_under_custom_nodes(
tmp_path: Path,
) -> None:
"""``custom_nodes/ComfyUI-Koolook`` → linked worktree elsewhere must
still run the guard. ``Path.resolve()`` would drop the
``custom_nodes`` parent and incorrectly skip (#282 review)."""
worktrees = tmp_path / "worktrees"
worktrees.mkdir()
real = _make_install(worktrees, "real-koolook")
(real / ".git").write_text(
"gitdir: /tmp/fake/.git/worktrees/real-koolook\n", encoding="utf-8"
)
custom_nodes = tmp_path / "custom_nodes"
custom_nodes.mkdir()
link = custom_nodes / "ComfyUI-Koolook"
try:
_link_dir(link, real)
except OSError as exc:
pytest.skip(f"dir link not permitted in this environment: {exc}")
# Load path (unresolved symlink/junction) keeps custom_nodes → guard runs.
assert should_run_duplicate_guard(link) is True
# Resolved real path loses custom_nodes → would skip; callers must
# not pass resolve() into the predicate for the load-path check.
assert should_run_duplicate_guard(link.resolve()) is False
# Sibling scan from the load path sees the Manager install next door.
other = _make_install(custom_nodes, "koolook")
assert detect_duplicate_koolook_installs(link) == [other]
def test_is_linked_git_worktree_handles_non_utf8_git_file(
tmp_path: Path,
) -> None:
"""Unreadable / non-UTF-8 ``.git`` file degrades to "not a worktree"
and never raises — same discipline as ``read_pyproject_version``."""
here = _make_install(tmp_path, "koolook")
(here / ".git").write_bytes(b"\xff\xfe gitdir: broken \x00")
assert is_linked_git_worktree(here) is False
assert should_run_duplicate_guard(here) is True
# =============================================================================
# detect_duplicate_koolook_installs
# =============================================================================
def test_detect_no_siblings_returns_empty(tmp_path: Path) -> None:
"""Single install — no other Koolook folders. Sibling list is empty
and the guard short-circuits to "normal startup"."""
here = _make_install(tmp_path, "koolook")
assert detect_duplicate_koolook_installs(here) == []
def test_detect_finds_sibling_with_marker(tmp_path: Path) -> None:
"""Two installs side by side under ``custom_nodes/`` — the sibling
is found via its ``koolook_routes.py`` marker. This is the canonical
failure mode the guard exists to catch."""
here = _make_install(tmp_path, "koolook")
other = _make_install(tmp_path, "ComfyUI-Koolook")
assert detect_duplicate_koolook_installs(here) == [other]
def test_detect_ignores_dirs_without_marker(tmp_path: Path) -> None:
"""A non-Koolook custom node sitting in the same ``custom_nodes/``
folder must not be flagged as a sibling — only directories carrying
the ``koolook_routes.py`` marker file are duplicates of us."""
here = _make_install(tmp_path, "koolook")
bystander = tmp_path / "ComfyUI-Manager"
bystander.mkdir()
(bystander / "__init__.py").write_text("# unrelated plugin", encoding="utf-8")
assert detect_duplicate_koolook_installs(here) == []
def test_detect_ignores_loose_files(tmp_path: Path) -> None:
"""A loose ``koolook_routes.py`` left in ``custom_nodes/`` itself
(not inside a directory) doesn't count — ComfyUI only loads custom
nodes from subdirectories, so a misplaced file can't compete with
our install for route registration."""
here = _make_install(tmp_path, "koolook")
(tmp_path / "koolook_routes.py").write_text("# loose", encoding="utf-8")
assert detect_duplicate_koolook_installs(here) == []
def test_detect_orders_siblings_alphabetically(tmp_path: Path) -> None:
"""Two+ siblings come back in case-insensitive name order so the
critical log reads consistently across both copies (and matches
the alphabetical winner selection in ``pick_winning_install``)."""
here = _make_install(tmp_path, "mid-koolook")
later = _make_install(tmp_path, "z-koolook")
earlier = _make_install(tmp_path, "a-koolook")
siblings = detect_duplicate_koolook_installs(here)
assert [s.name for s in siblings] == ["a-koolook", "z-koolook"]
# Mid-koolook is ``here`` and excluded from the sibling list — the
# caller computes the full alphabetical order by passing both.
assert earlier in siblings
assert later in siblings
def test_detect_skips_unreadable_sibling(tmp_path: Path, monkeypatch) -> None:
"""A sibling directory we can't stat into (restrictive perms / broken
mount) must be skipped, not crash the scan. Regression: an unguarded
``is_file()`` raised ``PermissionError`` up into ``__init__.py`` and the
whole plugin failed to load when ``custom_nodes/`` held an unreadable
neighbour (e.g. a root-owned service dir)."""
here = _make_install(tmp_path, "koolook")
blocked = tmp_path / "blocked-node"
blocked.mkdir()
real_is_file = Path.is_file
def fake_is_file(self: Path) -> bool:
if self.name == "koolook_routes.py" and self.parent == blocked:
raise PermissionError(13, "Permission denied")
return real_is_file(self)
monkeypatch.setattr(Path, "is_file", fake_is_file)
# Must not raise; the unreadable sibling is simply skipped.
assert detect_duplicate_koolook_installs(here) == []
# =============================================================================
# read_pyproject_version
# =============================================================================
def test_read_version_extracts_quoted_value(tmp_path: Path) -> None:
install = _make_install(tmp_path, "koolook", version="0.3.8")
assert read_pyproject_version(install) == "0.3.8"
def test_read_version_handles_single_quotes(tmp_path: Path) -> None:
"""Some pyprojects use single quotes — our manual scan should
handle both. The duplicate-install log doesn't care which the user
wrote, just that we can extract it."""
install = tmp_path / "koolook"
install.mkdir()
(install / "koolook_routes.py").write_text("# marker", encoding="utf-8")
(install / "pyproject.toml").write_text(
"[project]\nname = 'koolook'\nversion = '0.3.9'\n",
encoding="utf-8",
)
assert read_pyproject_version(install) == "0.3.9"
def test_read_version_returns_qmark_when_missing(tmp_path: Path) -> None:
"""No pyproject.toml — fall back to ``"?"`` so the critical log
still names the path."""
install = _make_install(tmp_path, "koolook", version=None)
assert read_pyproject_version(install) == "?"
def test_read_version_ignores_versioning_near_collision(tmp_path: Path) -> None:
"""A ``[tool.koolook] versioning = "strict"`` line shouldn't trip
the version extractor — only the literal ``version = ...`` key."""
install = tmp_path / "koolook"
install.mkdir()
(install / "koolook_routes.py").write_text("# marker", encoding="utf-8")
(install / "pyproject.toml").write_text(
'[project]\nname = "koolook"\nversioning = "strict"\nversion = "0.3.10"\n',
encoding="utf-8",
)
assert read_pyproject_version(install) == "0.3.10"
def test_read_version_strips_inline_comment(tmp_path: Path) -> None:
"""Tolerate ``version = "0.3.11" # bumped 2026-05-29``."""
install = tmp_path / "koolook"
install.mkdir()
(install / "koolook_routes.py").write_text("# marker", encoding="utf-8")
(install / "pyproject.toml").write_text(
'[project]\nversion = "0.3.11" # bumped\n',
encoding="utf-8",
)
assert read_pyproject_version(install) == "0.3.11"
def test_read_version_handles_non_utf8_pyproject(tmp_path: Path) -> None:
"""A non-UTF-8 / binary ``pyproject.toml`` must not raise
``UnicodeDecodeError`` — the version is simply unknown (``"?"``).
Regression: ``read_text(encoding="utf-8")`` raised a ``ValueError``
that escaped the ``except OSError`` and crashed the plugin import."""
install = tmp_path / "koolook"
install.mkdir()
(install / "koolook_routes.py").write_text("# marker", encoding="utf-8")
(install / "pyproject.toml").write_bytes(b"\xff\xfe version = broken \x00")
assert read_pyproject_version(install) == "?"
# =============================================================================
# pick_winning_install + build_duplicate_report
# =============================================================================
def test_pick_winning_install_is_alphabetical(tmp_path: Path) -> None:
"""Resolution is alphabetical-by-folder-name (case-insensitive) so
both copies arrive at the same winner without runtime coordination."""
here = _make_install(tmp_path, "koolook")
other = _make_install(tmp_path, "ComfyUI-Koolook")
# "ComfyUI-Koolook" < "koolook" case-insensitively → other wins.
assert pick_winning_install(here, [other]) == other
def test_build_report_marks_winner_correctly(tmp_path: Path) -> None:
"""The ``custom_nodes/koolook/`` install (Registry-derived name) is
the alphabetical loser when shadowed by a ``ComfyUI-Koolook/`` clone.
The Manager-installed copy is the one users see in the Manager UI,
so the winner is the clone — but the guard reports both paths
regardless so the user can choose which to remove."""
here = _make_install(tmp_path, "koolook", version="0.3.7")
other = _make_install(tmp_path, "ComfyUI-Koolook", version="0.3.8")
is_winning, message = build_duplicate_report(here, [other])
assert is_winning is False
assert str(here) in message
assert str(other) in message
assert "0.3.7" in message
assert "0.3.8" in message
assert "non-winning duplicate" in message
assert "CRITICAL" in message
def test_build_report_winner_skips_skip_message(tmp_path: Path) -> None:
"""When ``here`` IS the winner, the critical message names both
paths but does NOT add the "skipping registration" trailer — the
winner runs unchanged."""
here = _make_install(tmp_path, "ComfyUI-Koolook", version="0.3.8")
other = _make_install(tmp_path, "koolook", version="0.3.7")
is_winning, message = build_duplicate_report(here, [other])
assert is_winning is True
assert "CRITICAL" in message
assert str(here) in message
assert str(other) in message
assert "non-winning duplicate" not in message
def test_build_report_handles_three_way_collision(tmp_path: Path) -> None:
"""Edge case: three Koolook installs in the same ``custom_nodes/``.
The alphabetical winner is the lone survivor; the other two each
individually compute "I'm a loser" and skip."""
a = _make_install(tmp_path, "a-koolook", version="0.3.6")
b = _make_install(tmp_path, "koolook", version="0.3.7")
c = _make_install(tmp_path, "z-koolook", version="0.3.8")
# From ``b``'s perspective, both ``a`` and ``c`` are siblings.
siblings_from_b = detect_duplicate_koolook_installs(b)
assert siblings_from_b == [a, c]
is_winning_b, _ = build_duplicate_report(b, siblings_from_b)
assert is_winning_b is False
# From ``a``'s perspective, ``a`` wins.
siblings_from_a = detect_duplicate_koolook_installs(a)
is_winning_a, _ = build_duplicate_report(a, siblings_from_a)
assert is_winning_a is True
def test_build_report_survives_non_utf8_sibling_pyproject(tmp_path: Path) -> None:
"""The full report path must not raise on a duplicate whose
``pyproject.toml`` is non-UTF-8 — it degrades that version to ``"?"``
and still names both paths. Regression: this raised ``UnicodeDecodeError``
up into ``__init__.py`` and took the whole plugin offline (both copies)."""
here = _make_install(tmp_path, "koolook", version="0.3.7")
other = tmp_path / "ComfyUI-Koolook"
other.mkdir()
(other / "koolook_routes.py").write_text("# marker", encoding="utf-8")
(other / "pyproject.toml").write_bytes(b"\xffname = broken")
is_winning, message = build_duplicate_report(here, [other])
assert is_winning is False # "ComfyUI-Koolook" < "koolook" case-insensitively
assert str(other) in message
assert "0.3.7" in message # here's own version is still extracted