Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 上下文)
Expand Down
28 changes: 28 additions & 0 deletions emrg/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
145 changes: 145 additions & 0 deletions tests/test_cli_output_encoding.py
Original file line number Diff line number Diff line change
@@ -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
Loading