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)
182 lines
6.1 KiB
Python
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 == []
|