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)
This commit is contained in:
@@ -256,7 +256,7 @@ def test_cli_version_prints_version():
|
||||
"""`--version` exits 0 and prints the version string."""
|
||||
r = _run_cli("--version")
|
||||
assert r.returncode == 0
|
||||
assert "1.8.0" in r.stdout
|
||||
assert "1.8.1" in r.stdout
|
||||
assert "searxng-cli" in r.stdout
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""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 == []
|
||||
Reference in New Issue
Block a user