Files
searxng-use-cli/tests/test_utf8_and_winpath.py
thzxx f7cdc81c7f feat(v1.8.1): Windows 兼容性修复 + SKILL.md 铁律区块
Windows 兼容性修复(基于真实使用痛点):

- force_utf8_stdout(): 强制 stdout/stderr 为 UTF-8,修复 Windows GBK 崩溃(print('\\xa0') 不再炸)

- resolve_instances/load_config 新增 %APPDATA%/searxng-cli/ 路径,覆盖 Windows 配置约定

- fetch.py 失败诊断增强:输出 status_code=/cause=/url= 字段,AI Agent 可程序化区分 404/403/DNS 失败

SKILL.md 铁律区块(5 条,置顶):

- stdout=数据/stderr=日志 永不混淆

- 禁用 2>/dev/null(丢弃 stderr = 失败时零诊断)

- 排错去 --quiet 加 --verbose

- 配置查找覆盖 WSL + Windows 双路径

- 实例 URL 必填,公共实例发现已移除

测试: 352 -> 362(新增 10 个:force_utf8_stdout 幂等性/GBK 替换/非 ASCII 打印/APPDATA 路径发现/txt 回退/空 APPDATA)
2026-08-01 20:50:42 +08:00

182 lines
6.1 KiB
Python

"""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 == []