"""Tests for v1.8.1 Windows compatibility fixes. Covers: * ``force_utf8_stdout()`` — reconfigures stdout/stderr to UTF-8, idempotent, tolerates non-UTF-8 encodings, never raises on any input. * ``_windows_appdata_config_dir()`` — returns APPDATA-based path on Windows, empty Path on POSIX. * ``resolve_instances()`` — discovers config under ``%APPDATA%/searxng-cli/`` when set (simulating Windows), still works when APPDATA is unset (POSIX). * ``fetch.py`` failure diagnostics — error output includes ``status_code=``, ``cause=``, ``url=`` fields for programmatic diagnosis. """ import io import os import sys from pathlib import Path from unittest.mock import patch import pytest # Ensure scripts/ is on sys.path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) from common import force_utf8_stdout # ----- force_utf8_stdout ----- def test_force_utf8_stdout_is_idempotent(): """Calling twice should not raise and should leave stdout usable.""" force_utf8_stdout() force_utf8_stdout() # second call: encoding already utf-8 or close — no-op # stdout must still be writable print("test", end="") sys.stdout.flush() def test_force_utf8_stdout_replaces_non_utf8_stream(): """A non-UTF-8 stdout should be reconfigured to UTF-8.""" # Simulate a GBK stream by creating a fresh TextIOWrapper with GBK original = sys.stdout try: buf = io.BytesIO() # Use errors='replace' so writing non-GBK chars doesn't crash the test sys.stdout = io.TextIOWrapper(buf, encoding="gbk", errors="replace") force_utf8_stdout() # After force_utf8_stdout, encoding should now be utf-8 (or utf-8-sig) enc = (sys.stdout.encoding or "").lower().replace("-", "") assert enc in ("utf8", "utf8sig"), f"expected utf-8, got {enc}" finally: sys.stdout = original def test_force_utf8_stdout_handles_missing_buffer(): """A stream without .buffer should not crash the function.""" class FakeStream: encoding = "gbk" # No .buffer, no .reconfigure original = sys.stdout try: sys.stdout = FakeStream() # Should not raise force_utf8_stdout() finally: sys.stdout = original def test_force_utf8_stdout_skips_already_utf8(): """An already-UTF-8 stdout should be untouched.""" original = sys.stdout try: buf = io.BytesIO() sys.stdout = io.TextIOWrapper(buf, encoding="utf-8") force_utf8_stdout() # Still utf-8 assert (sys.stdout.encoding or "").lower().replace("-", "") == "utf8" finally: sys.stdout = original def test_force_utf8_stdout_allows_non_ascii_print(): """End-to-end: after force_utf8_stdout, printing non-ASCII chars works. Regression test for the Windows GBK crash where ``print('\\xa0')`` raised ``UnicodeEncodeError``. With UTF-8 enforced, it must succeed silently. """ force_utf8_stdout() # This must not raise. On a broken GBK stdout it would. print("\xa0中文测试\u2014em-dash", end="") sys.stdout.flush() # ----- _windows_appdata_config_dir ----- def test_windows_appdata_dir_returns_path_when_set(monkeypatch): """When APPDATA is set, returns APPDATA/searxng-cli.""" import search monkeypatch.setenv("APPDATA", "/fake/appdata") result = search._windows_appdata_config_dir() assert result == Path("/fake/appdata") / "searxng-cli" def test_windows_appdata_dir_returns_empty_when_unset(monkeypatch): """When APPDATA is unset (POSIX), returns a sentinel Path that never exists.""" import search monkeypatch.delenv("APPDATA", raising=False) result = search._windows_appdata_config_dir() # Sentinel path must never exist on disk — safe to append to candidate lists assert not result.exists() # ----- resolve_instances with APPDATA ----- def test_resolve_instances_finds_appdata_config(monkeypatch, tmp_path): """resolve_instances discovers searxng.toml under APPDATA (Windows sim).""" import search # Simulate Windows: set APPDATA to a temp dir, clear other sources appdata = tmp_path / "appdata" appdata.mkdir() cfg_dir = appdata / "searxng-cli" cfg_dir.mkdir() (cfg_dir / "searxng.toml").write_text( '[searxng]\ninstance = "https://from-appdata.example.com"\n', encoding="utf-8", ) monkeypatch.setenv("APPDATA", str(appdata)) monkeypatch.delenv("SEARXNG_INSTANCE", raising=False) # CWD and HOME must not have a config (so we isolate the test) monkeypatch.chdir(tmp_path) fake_home = tmp_path / "fake-home" fake_home.mkdir() monkeypatch.setenv("HOME", str(fake_home)) # On Windows, Path.home() uses USERPROFILE, not HOME monkeypatch.setenv("USERPROFILE", str(fake_home)) result = search.resolve_instances() assert result == ["https://from-appdata.example.com"] def test_resolve_instances_appdata_txt_fallback(monkeypatch, tmp_path): """instances.txt under APPDATA is also discovered.""" import search appdata = tmp_path / "appdata" appdata.mkdir() cfg_dir = appdata / "searxng-cli" cfg_dir.mkdir() (cfg_dir / "instances.txt").write_text( "https://from-txt.example.com\n", encoding="utf-8" ) monkeypatch.setenv("APPDATA", str(appdata)) monkeypatch.delenv("SEARXNG_INSTANCE", raising=False) monkeypatch.chdir(tmp_path) fake_home = tmp_path / "fake-home" fake_home.mkdir() monkeypatch.setenv("HOME", str(fake_home)) monkeypatch.setenv("USERPROFILE", str(fake_home)) result = search.resolve_instances() assert result == ["https://from-txt.example.com"] def test_resolve_instances_appdata_empty(monkeypatch, tmp_path): """When APPDATA set but no config there, falls through cleanly.""" import search appdata = tmp_path / "appdata" appdata.mkdir() monkeypatch.setenv("APPDATA", str(appdata)) monkeypatch.delenv("SEARXNG_INSTANCE", raising=False) monkeypatch.chdir(tmp_path) fake_home = tmp_path / "fake-home" fake_home.mkdir() monkeypatch.setenv("HOME", str(fake_home)) monkeypatch.setenv("USERPROFILE", str(fake_home)) result = search.resolve_instances() assert result == []