diff --git a/Agent.md b/Agent.md index ea0c676b..a9e26da7 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1284) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1290) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) diff --git a/emrg/__main__.py b/emrg/__main__.py index bc686861..0d0dc9d4 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -112,7 +112,35 @@ def _build_parser() -> argparse.ArgumentParser: return parser +def _harden_redirected_output() -> None: + """Keep redirected CLI output from aborting on a legacy codec. + + When stdout/stderr is a pipe or a file, Python encodes with the *locale* + codec — ASCII under ``LANG=C``/POSIX, ``cp1252`` on older Windows, GBK on + zh-CN hosts. Typography the CLI prints freely (em dash, arrows in the + ``emrg update`` hints) has no mapping in some of those, so the ``print`` + raises ``UnicodeEncodeError`` mid-write and the command dies with a + traceback: ``emrg --help > log.txt`` under an ASCII locale exited 1 and + printed nothing at all. + + ``errors="replace"`` degrades an unencodable character to ``?`` instead of + aborting. That is the right trade for human-facing CLI text — the typography + is decorative, and a readable line beats a traceback. Interactive terminals + are deliberately left alone: they can encode the text, and the TUI must not + have its output rewritten. + """ + for stream in (sys.stdout, sys.stderr): + try: + if not stream.isatty(): + stream.reconfigure(errors="replace") + except (AttributeError, ValueError, OSError): + # Not a TextIOWrapper (already wrapped/closed), or the platform + # forbids it — the status quo is no worse than before the call. + pass + + def main() -> None: + _harden_redirected_output() parser = _build_parser() parsed = parser.parse_args() diff --git a/tests/test_cli_output_encoding.py b/tests/test_cli_output_encoding.py new file mode 100644 index 00000000..31352c73 --- /dev/null +++ b/tests/test_cli_output_encoding.py @@ -0,0 +1,145 @@ +"""The `emrg` CLI must not die when its output cannot be encoded. + +Background (cycle 2026-09-10, same class as the `scripts/` defect on #1121) +---------------------------------------------------------------------------- +With stdout redirected, Python encodes using the *locale* codec rather than the +console's: ASCII under ``LANG=C``/POSIX, ``cp1252`` on older Windows, GBK on +zh-CN hosts. `emrg --help` prints an em dash, which the ASCII codec cannot +represent, so the print raised ``UnicodeEncodeError`` mid-write: + + $ PYTHONIOENCODING=ascii python -m emrg --help > log.txt + Traceback (most recent call last): + ... + UnicodeEncodeError: 'ascii' codec can't encode character '\\u2014' + $ echo $? + 1 + +The command exited 1 and printed *nothing* -- `--help` failing at a point where +a caller reads "the CLI is broken". ``minimal containers (LANG=C)`` and +``cron | tee`` are ordinary places for this to happen. + +The fix degrades unencodable characters instead of aborting, and applies only +to non-interactive streams (see ``emrg.__main__._harden_redirected_output``), +so an interactive console keeps its full typography. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from emrg.__main__ import _harden_redirected_output + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _run_cli(args: list[str], codec: str) -> subprocess.CompletedProcess[bytes]: + """Run the CLI with a forced stdout codec, capturing raw bytes. + + Byte capture (no ``text=True``) is deliberate: a text-mode capture decodes + in the *parent*, which hides the child's own failure. + """ + import os + + env = dict(os.environ, PYTHONIOENCODING=codec) + return subprocess.run( + [sys.executable, "-m", "emrg", *args], + cwd=REPO_ROOT, + env=env, + capture_output=True, + check=False, + ) + + +@pytest.mark.parametrize("codec", ["ascii", "cp1252"]) +def test_cli_help_survives_a_legacy_stdout(codec: str) -> None: + """`--help` must print and exit 0 under a codec that cannot encode its text. + + ASCII is the reported failure (LANG=C / POSIX); cp1252 is the older Windows + default. Both must yield usable output rather than a traceback. + """ + proc = _run_cli(["--help"], codec) + + assert proc.returncode == 0, proc.stdout + proc.stderr + assert b"Traceback" not in proc.stderr, proc.stderr + assert b"UnicodeEncodeError" not in proc.stderr, proc.stderr + assert proc.stdout.strip(), "help text must still be printed" + assert b"usage:" in proc.stdout + + # Everything emitted must be round-trippable by the codec that produced it. + # The two codecs reach that differently, and the difference is the point: + # cp1252 *can* encode the em dash (0x97), so it passes through untouched, + # while ASCII cannot, so that character degrades to "?" rather than raising. + assert proc.stdout.decode(codec) + if codec == "ascii": + assert proc.stdout.isascii() + assert b"EMRG ?" in proc.stdout, "the unencodable char should degrade to '?'" + + +def test_cli_help_keeps_its_typography_on_a_utf8_stdout() -> None: + """The degradation must not apply where the text *is* encodable. + + Positive control for the fix: hardening redirected streams must not be a + blanket ASCII-ification of the CLI's output. + """ + proc = _run_cli(["--help"], "utf-8") + + assert proc.returncode == 0, proc.stdout + proc.stderr + assert b"usage:" in proc.stdout + assert "\u2014".encode("utf-8") in proc.stdout, "em dash should survive UTF-8" + + +class _FakeStream: + """Minimal stdout stand-in recording reconfigure() calls.""" + + def __init__(self, tty: bool) -> None: + self._tty = tty + self.calls: list[dict] = [] + + def isatty(self) -> bool: + return self._tty + + def reconfigure(self, **kwargs) -> None: + self.calls.append(kwargs) + + +class _BareStream: + """A stream object with no reconfigure() (e.g. a wrapper).""" + + def isatty(self) -> bool: + return False + + +def test_interactive_streams_are_left_alone(monkeypatch) -> None: + """A terminal can encode the text; the TUI's streams must not be rewritten.""" + out, err = _FakeStream(tty=True), _FakeStream(tty=True) + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", err) + + _harden_redirected_output() + + assert out.calls == [] + assert err.calls == [] + + +def test_redirected_streams_degrade_instead_of_aborting(monkeypatch) -> None: + """A pipe/file stream gets errors="replace" so an unencodable char cannot raise.""" + out, err = _FakeStream(tty=False), _FakeStream(tty=False) + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", err) + + _harden_redirected_output() + + assert out.calls == [{"errors": "replace"}] + assert err.calls == [{"errors": "replace"}] + + +def test_streams_without_reconfigure_are_tolerated(monkeypatch) -> None: + """Streams that cannot be reconfigured must not turn the hardening into a crash.""" + monkeypatch.setattr(sys, "stdout", _BareStream()) + monkeypatch.setattr(sys, "stderr", _BareStream()) + + _harden_redirected_output() # must not raise