arcade-mcp/libs/tests/cli/test_console_encoding.py
jottakka fe8ddfd500
[TOO-326] Windows papercuts (#768)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Medium Risk**
> Touches authentication/login flow, credentials-file permissions, and
subprocess lifecycle behavior across platforms; while mostly defensive,
regressions could impact login or process management on Windows/macOS
runners.
> 
> **Overview**
> Improves Windows/cross-platform reliability across the CLI and MCP
server: OAuth login now binds the callback server to `127.0.0.1`, avoids
slow loopback reverse-DNS, adds a configurable callback timeout
(`--timeout` + env default), and opens URLs via a Windows-friendly
`_open_browser` to avoid flashing console windows.
> 
> Centralizes CLI output via a shared `console` that forces UTF-8 on
Windows, standardizes UTF-8 file reads/writes throughout, tightens
credentials-file permissions on Windows using `icacls`, and adds shared
Windows subprocess helpers for **no-window** process creation and
graceful termination (used by `deploy`, MCP reload, and usage-tracking
worker).
> 
> Updates client configuration UX/robustness (Windows AppData resolution
via `platformdirs`, Cursor config path fallbacks + compatibility writes,
overwrite warnings, absolute `uv` path for GUI clients, safer path
display) and improves `deploy` child-process handling to avoid
pipe-buffer deadlocks while giving better debug-aware error messages.
> 
> Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI
integration workflow, disables usage tracking in toolkits CI, and adds
extensive regression tests for Windows signals, subprocess cleanup,
UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and
`arcade-mcp-server` to `1.17.2` (with updated dependency pin).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-25 13:18:16 -03:00

161 lines
5.9 KiB
Python

"""Tests for the console.py encoding safety layer.
These tests verify that _needs_utf8() and _configure_windows_utf8()
behave correctly and do not crash, even when the console encoding
would be cp1252 (the default on many Western-European Windows installs).
"""
from __future__ import annotations
import io
import os
import sys
from unittest.mock import patch
import pytest
from arcade_cli.console import _configure_windows_utf8, _needs_utf8
# ---------------------------------------------------------------------------
# _needs_utf8()
# ---------------------------------------------------------------------------
class TestNeedsUtf8:
"""Unit tests for _needs_utf8()."""
@pytest.mark.parametrize(
"encoding, expected",
[
("utf-8", False),
("UTF-8", False),
("utf8", False),
("UTF8", False),
("cp1252", True),
("ascii", True),
("latin-1", True),
("", True),
(None, True),
],
)
def test_known_encodings(self, encoding: str | None, expected: bool) -> None:
assert _needs_utf8(encoding) is expected
# ---------------------------------------------------------------------------
# _configure_windows_utf8()
# ---------------------------------------------------------------------------
class TestConfigureWindowsUtf8:
"""Tests for _configure_windows_utf8()."""
def test_noop_on_non_windows(self) -> None:
"""On non-Windows platforms the function should be a no-op."""
with patch.object(sys, "platform", "linux"):
# Should not raise and not change anything.
_configure_windows_utf8()
def test_reconfigures_when_cp1252(self) -> None:
"""Simulate a cp1252 stdout on 'win32' and verify reconfigure is called."""
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdout", fake_stdout),
patch.object(sys, "stderr", fake_stderr),
):
_configure_windows_utf8()
# After reconfiguration the streams should be utf-8.
assert fake_stdout.encoding.lower().replace("-", "") == "utf8"
assert fake_stderr.encoding.lower().replace("-", "") == "utf8"
def test_sets_pythonioencoding_env(self) -> None:
"""When reconfiguring, PYTHONIOENCODING should be set as a fallback."""
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
env_copy = os.environ.copy()
env_copy.pop("PYTHONIOENCODING", None)
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdout", fake_stdout),
patch.object(sys, "stderr", fake_stderr),
patch.dict(os.environ, env_copy, clear=True),
):
_configure_windows_utf8()
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
def test_does_not_override_existing_pythonioencoding(self) -> None:
"""If PYTHONIOENCODING is already set, don't overwrite it."""
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdout", fake_stdout),
patch.object(sys, "stderr", fake_stderr),
patch.dict(os.environ, {"PYTHONIOENCODING": "ascii"}, clear=False),
):
_configure_windows_utf8()
# Should keep the existing value.
assert os.environ["PYTHONIOENCODING"] == "ascii"
def test_no_crash_when_reconfigure_missing(self) -> None:
"""Streams without a reconfigure method should not crash."""
class FakeStream:
encoding = "cp1252"
# No reconfigure attribute.
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdout", FakeStream()),
patch.object(sys, "stderr", FakeStream()),
):
# Should not raise.
_configure_windows_utf8()
def test_noop_when_already_utf8(self) -> None:
"""If both streams are already utf-8, nothing should be reconfigured."""
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="utf-8")
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="utf-8")
reconfigure_called = False
original_reconfigure = fake_stdout.reconfigure
def tracking_reconfigure(**kwargs):
nonlocal reconfigure_called
reconfigure_called = True
return original_reconfigure(**kwargs)
fake_stdout.reconfigure = tracking_reconfigure # type: ignore[assignment]
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdout", fake_stdout),
patch.object(sys, "stderr", fake_stderr),
):
_configure_windows_utf8()
assert not reconfigure_called, "reconfigure should not be called when already utf-8"
def test_emoji_output_after_reconfigure(self) -> None:
"""After reconfiguring a cp1252 stream, writing emoji should not crash."""
buf = io.BytesIO()
fake_stdout = io.TextIOWrapper(buf, encoding="cp1252")
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdout", fake_stdout),
patch.object(sys, "stderr", fake_stdout),
):
_configure_windows_utf8()
# Now write emoji — should not raise UnicodeEncodeError.
fake_stdout.write("Hello! \u2705 \U0001f680 Done.\n")
fake_stdout.flush()
output = buf.getvalue().decode("utf-8")
assert "\u2705" in output
assert "\U0001f680" in output