diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 70074661..563eca89 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -29,6 +29,26 @@ jobs: run: | pytest -q + windows-test: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package and test deps + run: | + python -m pip install --upgrade pip + pip install -c constraints.txt -e .[dev] + + - name: Run tests + run: | + pytest -q + # Editable installs (-e) never exercise wheel packaging, so a broken wheel # can pass tests and still fail every real `pip install` from source. # This job builds the actual wheel and installs it into a clean venv. diff --git a/tests/conftest.py b/tests/conftest.py index 1d0f1704..6f384ff5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,20 @@ from agent_reach.config import Config +@pytest.fixture +def symlink_or_skip(): + """Create a symlink or skip when the host has no symlink privilege.""" + + def create(link, target, *, target_is_directory=False): + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation is unavailable: {exc}") + return link + + return create + + @pytest.fixture(autouse=True) def isolated_home(tmp_path, monkeypatch): """Redirect every common home/config root before each test runs.""" diff --git a/tests/test_channels.py b/tests/test_channels.py index 0897559c..358d013b 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -936,7 +936,7 @@ def test_saved_cli_cookie_is_unverified_not_active( assert ch.active_backend is None def test_saved_cli_cookie_refuses_ancestor_symlink( - self, monkeypatch, isolated_home + self, monkeypatch, isolated_home, symlink_or_skip ): self._isolate(monkeypatch) monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") @@ -946,7 +946,8 @@ def test_saved_cli_cookie_refuses_ancestor_symlink( json.dumps({"a1": "do-not-read"}), encoding="utf-8", ) - (isolated_home / ".xiaohongshu-cli").symlink_to( + symlink_or_skip( + isolated_home / ".xiaohongshu-cli", victim_dir, target_is_directory=True, ) @@ -1414,7 +1415,7 @@ def test_hosts_metadata_is_read_without_exposing_token( assert "super-secret-token" not in message def test_hosts_metadata_refuses_ancestor_symlink( - self, monkeypatch, isolated_home + self, monkeypatch, isolated_home, symlink_or_skip ): monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh") monkeypatch.delenv("GH_TOKEN", raising=False) @@ -1426,7 +1427,8 @@ def test_hosts_metadata_refuses_ancestor_symlink( "github.com:\n oauth_token: do-not-read\n", encoding="utf-8", ) - (isolated_home / ".config").symlink_to( + symlink_or_skip( + isolated_home / ".config", real_config, target_is_directory=True, ) diff --git a/tests/test_p0_cli.py b/tests/test_p0_cli.py index 1003b0e9..7d2f4f7a 100644 --- a/tests/test_p0_cli.py +++ b/tests/test_p0_cli.py @@ -505,7 +505,6 @@ def test_system_install_uses_ytdlp_first_user_config( import agent_reach.utils.paths as paths - monkeypatch.setattr(paths.sys, "platform", "darwin") monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: tmp_path)) monkeypatch.delenv("XDG_CONFIG_HOME") monkeypatch.setattr( @@ -852,8 +851,9 @@ def test_uninstall_warns_about_opt_in_legacy_credential_copies( cli._cmd_uninstall(Namespace(dry_run=True, keep_config=False)) output = capsys.readouterr().out - assert str(xfetch) in output - assert str(bird) in output + normalized_output = output.replace("\\", "/") + assert xfetch.as_posix() in normalized_output + assert bird.as_posix() in normalized_output assert "不会自动删除" in output assert xfetch.exists() assert bird.exists() diff --git a/tests/test_paths.py b/tests/test_paths.py index 1f3425f1..ee802b19 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """Behavior tests for cross-platform path and remediation helpers.""" +import shutil import subprocess from pathlib import Path +import pytest + from agent_reach.utils import paths @@ -17,8 +20,11 @@ def test_posix_ytdlp_fix_is_single_line_executable_and_idempotent( command = paths.render_ytdlp_fix_command() assert "\n" not in command - subprocess.run(["/bin/sh", "-c", command], check=True) - subprocess.run(["/bin/sh", "-c", command], check=True) + shell = shutil.which("sh") + if shell is None: + pytest.skip("requires a POSIX-compatible sh executable") + subprocess.run([shell, "-c", command], check=True) + subprocess.run([shell, "-c", command], check=True) config = tmp_path / ".config" / "yt-dlp" / "config" assert config.read_text(encoding="utf-8") == "--js-runtimes node\n" diff --git a/tests/test_private_file_writes.py b/tests/test_private_file_writes.py index eb6b72ed..a6b8f971 100644 --- a/tests/test_private_file_writes.py +++ b/tests/test_private_file_writes.py @@ -96,6 +96,7 @@ def test_legacy_xfetch_sync_refuses_oversized_existing_session( tmp_path, monkeypatch ): monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) session_path = tmp_path / ".config" / "xfetch" / "session.json" session_path.parent.mkdir(parents=True) previous = json.dumps({"untrusted": "x" * (1024 * 1024)}) @@ -125,6 +126,7 @@ def test_xhs_cookie_editor_json_ignores_non_xhs_domains( tmp_path, monkeypatch, capsys ): monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.setattr("shutil.which", lambda name: None) exported = [ { diff --git a/tests/test_reddit_channel.py b/tests/test_reddit_channel.py index f4232758..9d0c9138 100644 --- a/tests/test_reddit_channel.py +++ b/tests/test_reddit_channel.py @@ -98,12 +98,12 @@ def test_check_rdt_unparseable_credential_is_warn(isolated_home): assert "无法安全解析" in message -def test_check_rdt_refuses_symlink_credential(isolated_home): +def test_check_rdt_refuses_symlink_credential(isolated_home, symlink_or_skip): victim = isolated_home / "victim.json" victim.write_text('{"secret": "do-not-read"}', encoding="utf-8") path = isolated_home / ".config" / "rdt-cli" / "credential.json" path.parent.mkdir(parents=True) - path.symlink_to(victim) + symlink_or_skip(path, victim) with patch("shutil.which", return_value="/usr/local/bin/rdt"): status, message = RedditChannel()._check_rdt() @@ -111,7 +111,7 @@ def test_check_rdt_refuses_symlink_credential(isolated_home): assert "符号链接" in message -def test_check_rdt_refuses_ancestor_symlink(isolated_home): +def test_check_rdt_refuses_ancestor_symlink(isolated_home, symlink_or_skip): victim_dir = isolated_home / "victim-config" credential_path = victim_dir / "rdt-cli" / "credential.json" credential_path.parent.mkdir(parents=True) @@ -119,7 +119,8 @@ def test_check_rdt_refuses_ancestor_symlink(isolated_home): '{"cookies": {"reddit_session": "do-not-read"}}', encoding="utf-8", ) - (isolated_home / ".config").symlink_to( + symlink_or_skip( + isolated_home / ".config", victim_dir, target_is_directory=True, )