Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scripts/run_tests_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,14 @@ def _slice_files(


def main() -> int:
# Reconfigure stdout/stderr to UTF-8 to prevent UnicodeEncodeError on Windows
# when printing checkmarks/crosses and box-drawing symbols.
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/test_worktree_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def test_rejects_parent_directory_directory_traversal(self, git_repo):
finally:
_force_remove_worktree(info)

@pytest.mark.require_symlinks
def test_rejects_symlink_that_resolves_outside_repo(self, git_repo):
import cli as cli_mod

Expand Down Expand Up @@ -110,6 +111,7 @@ def test_allows_valid_file_include(self, git_repo):
finally:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test exercises _setup_worktree's Windows copytree fallback (cli.py:1542-1556) when symlink creation is unavailable. Skipping it removes coverage of that intentional path; keep it runnable and assert the included directory is usable whether it is a symlink or a copied directory.

_force_remove_worktree(info)

@pytest.mark.require_symlinks
def test_allows_valid_directory_include(self, git_repo):
import cli as cli_mod

Expand Down
32 changes: 32 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import asyncio
import os

import sys
from pathlib import Path

Expand Down Expand Up @@ -533,6 +534,37 @@ def pytest_configure(config): # noqa: D401 — pytest hook
"(only for tests that genuinely need real os.kill / subprocess "
"behaviour — e.g. PTY tests that signal their own child).",
)
config.addinivalue_line(
"markers",
"require_symlinks: skip the test if symbolic links cannot be created in the current environment.",
)


_symlink_supported_cache = None

def _check_symlink_support() -> bool:
global _symlink_supported_cache
if _symlink_supported_cache is not None:
return _symlink_supported_cache

import tempfile
try:
with tempfile.TemporaryDirectory() as d:
src = Path(d) / "src"
src.touch()
lnk = Path(d) / "lnk"
lnk.symlink_to(src)
_symlink_supported_cache = True
return True
except OSError:
_symlink_supported_cache = False
return False


def pytest_runtest_setup(item):
if item.get_closest_marker("require_symlinks"):
if not _check_symlink_support():
pytest.skip("Environment does not support symbolic links (requires admin/developer mode on Windows)")


@pytest.fixture(autouse=True)
Expand Down
5 changes: 5 additions & 0 deletions tests/test_atomic_replace_symlinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def _write_tmp(dir_: Path, content: str) -> Path:
return tmp


@pytest.mark.require_symlinks
def test_atomic_replace_preserves_symlink(tmp_path: Path) -> None:
real = tmp_path / "real.yaml"
link = tmp_path / "link.yaml"
Expand Down Expand Up @@ -94,6 +95,7 @@ def test_atomic_replace_accepts_pathlike_and_str(tmp_path: Path) -> None:
# ─── atomic_json_write / atomic_yaml_write wiring ──────────────────────────


@pytest.mark.require_symlinks
def test_atomic_json_write_preserves_symlink(tmp_path: Path) -> None:
real = tmp_path / "real.json"
link = tmp_path / "link.json"
Expand All @@ -107,6 +109,7 @@ def test_atomic_json_write_preserves_symlink(tmp_path: Path) -> None:
assert loaded == {"hello": "world"}


@pytest.mark.require_symlinks
def test_atomic_yaml_write_preserves_symlink(tmp_path: Path) -> None:
real = tmp_path / "real.yaml"
link = tmp_path / "link.yaml"
Expand All @@ -120,6 +123,7 @@ def test_atomic_yaml_write_preserves_symlink(tmp_path: Path) -> None:
assert data == {"model": {"provider": "openrouter"}}


@pytest.mark.require_symlinks
def test_atomic_json_write_preserves_symlink_permissions(tmp_path: Path) -> None:
"""Symlinked targets keep the real file's permission bits."""
if os.name != "posix":
Expand All @@ -141,6 +145,7 @@ def test_atomic_json_write_preserves_symlink_permissions(tmp_path: Path) -> None
# ─── Broken-symlink edge case ─────────────────────────────────────────────


@pytest.mark.require_symlinks
def test_atomic_replace_broken_symlink_creates_target(tmp_path: Path) -> None:
"""A symlink pointing at a missing file: the write should create the
real target (resolving via realpath) rather than leaving the dangling
Expand Down
1 change: 1 addition & 0 deletions tests/test_hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ def raise_oserror(p, m):
# Should not raise
secure_parent_dir(target)

@pytest.mark.require_symlinks
def test_symlink_resolved(self, tmp_path, monkeypatch):
"""Symlinks should be resolved before checking depth."""
real_dir = tmp_path / "a" / "b"
Expand Down
Loading