From 5945929d4b6c4a31dc7b7640ab4e490b243e26d7 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:40:33 +0300 Subject: [PATCH] fix(tests): read and write test files as UTF-8 so the suite runs on Windows `tests/hermes_cli/test_plugins_cmd.py::TestNoAutoActivation::test_compressor_default_ignores_plugin` fails on every Windows machine: UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f in position 47744: character maps to The test reads `run_agent.py` back as text to assert a removed comment is gone, but called `open()` with no `encoding=`. Python then falls back to the locale preferred encoding, which is cp1252 on a default Windows install rather than UTF-8. `run_agent.py` contains nine bytes cp1252 leaves undefined, so the read raises before the assertion is reached. On Linux and macOS the preferred encoding is UTF-8 and the same line is fine, which is why CI never caught it. That one line is the only active failure. The rest of this change closes the same gap in the files it touches, which `scripts/check-windows-footguns.py` flags and which the #71014 read_text campaign has been working through elsewhere in the tree: - `tests/hermes_cli/test_plugins_cmd.py`: nine bare `write_text`/`read_text` calls writing YAML manifests, config and plugin sources - `tests/tools/test_web_tools_truncate.py`: reads stored extracted web text, which is arbitrary content from the internet - `tests/stress/test_atypical_scenarios.py`: writes and reads worker task ids and a barrier file All three files are now clean under `check-windows-footguns.py`. Reads go through `Path.read_text(encoding="utf-8")` rather than `open(...).read()`, which also closes the handle instead of leaving it to the garbage collector. On Windows a live handle blocks tmpdir cleanup, so that part is not cosmetic either. No new test. The repaired test is the regression coverage: it fails before this change and passes after, on Windows. --- tests/hermes_cli/test_plugins_cmd.py | 25 ++++++++++++++----------- tests/stress/test_atypical_scenarios.py | 10 +++++++--- tests/tools/test_web_tools_truncate.py | 3 ++- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 1f38fca04749f..2c49b12441889 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -189,14 +189,14 @@ class TestReadManifest: assert result == {} def test_invalid_yaml_returns_empty_and_logs(self, tmp_path, caplog): - (tmp_path / "plugin.yaml").write_text(": : : bad yaml [[[") + (tmp_path / "plugin.yaml").write_text(": : : bad yaml [[[", encoding="utf-8") with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins_cmd"): result = _read_manifest(tmp_path) assert result == {} assert any("Failed to read plugin.yaml" in r.message for r in caplog.records) def test_empty_file_returns_empty(self, tmp_path): - (tmp_path / "plugin.yaml").write_text("") + (tmp_path / "plugin.yaml").write_text("", encoding="utf-8") result = _read_manifest(tmp_path) assert result == {} @@ -388,7 +388,7 @@ class TestCopyExampleFiles: # Create example file example_file = tmp_path / "config.yaml.example" - example_file.write_text("key: value") + example_file.write_text("key: value", encoding="utf-8") _copy_example_files(tmp_path, console) @@ -404,7 +404,7 @@ class TestCopyExampleFiles: # Create example file example_file = tmp_path / "config.yaml.example" - example_file.write_text("key: value") + example_file.write_text("key: value", encoding="utf-8") # Mock shutil.copy2 to raise an error with patch( @@ -497,10 +497,10 @@ class TestProviderDiscovery: """Saving a context engine persists to config.yaml.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) config_file = tmp_path / "config.yaml" - config_file.write_text("context:\n engine: compressor\n") + config_file.write_text("context:\n engine: compressor\n", encoding="utf-8") from hermes_cli.plugins_cmd import _save_context_engine _save_context_engine("lcm") - content = yaml.safe_load(config_file.read_text()) + content = yaml.safe_load(config_file.read_text(encoding="utf-8")) assert content["context"]["engine"] == "lcm" @@ -525,7 +525,7 @@ class TestNoAutoActivation: # This tests the run_agent.py logic indirectly by checking that the # code path for default config doesn't call get_plugin_context_engine. import run_agent as ra_module - source = open(ra_module.__file__).read() + source = Path(ra_module.__file__).read_text(encoding="utf-8") # The old code had: "Even with default config, check if a plugin registered one" # The fix removes this. Verify it's gone. assert "Even with default config, check if a plugin registered one" not in source @@ -545,16 +545,19 @@ class TestSubdirInstallE2E: repo_root.mkdir(parents=True, exist_ok=True) # Root-level noise: docs + tests that should NOT be installed. - (repo_root / "README.md").write_text("# Monorepo docs\n") + (repo_root / "README.md").write_text("# Monorepo docs\n", encoding="utf-8") (repo_root / "tests").mkdir() - (repo_root / "tests" / "test_x.py").write_text("def test_x():\n pass\n") + (repo_root / "tests" / "test_x.py").write_text( + "def test_x():\n pass\n", encoding="utf-8" + ) # The actual plugin in a subdirectory. plugin_dir = repo_root / "my-plugin" plugin_dir.mkdir() (plugin_dir / "plugin.yaml").write_text( - "name: my-plugin\nmanifest_version: 1\ndescription: A subdir plugin\n" + "name: my-plugin\nmanifest_version: 1\ndescription: A subdir plugin\n", + encoding="utf-8", ) - (plugin_dir / "__init__.py").write_text("# plugin entry\n") + (plugin_dir / "__init__.py").write_text("# plugin entry\n", encoding="utf-8") env = { **os.environ, diff --git a/tests/stress/test_atypical_scenarios.py b/tests/stress/test_atypical_scenarios.py index 936dbaf5baf61..ed537956ce855 100644 --- a/tests/stress/test_atypical_scenarios.py +++ b/tests/stress/test_atypical_scenarios.py @@ -705,7 +705,7 @@ def _idempotency_race_worker(hermes_home: str, key: str, result_file: str, ) finally: conn.close() - with open(result_file, "w") as f: + with open(result_file, "w", encoding="utf-8") as f: f.write(tid) @@ -731,12 +731,16 @@ def _(home, kb): p.start() time.sleep(0.1) # let them hit the spin # Fire the gun - with open(barrier, "w") as f: + with open(barrier, "w", encoding="utf-8") as f: f.write("go") for p in procs: p.join(timeout=10) - tids = [open(r).read().strip() for r in results if os.path.exists(r)] + tids = [ + Path(r).read_text(encoding="utf-8").strip() + for r in results + if os.path.exists(r) + ] assert len(tids) == 2, f"only {len(tids)} workers finished" assert tids[0] == tids[1], ( f"idempotency key race produced two different tasks: {tids}" diff --git a/tests/tools/test_web_tools_truncate.py b/tests/tools/test_web_tools_truncate.py index b89466fe6540e..d1dab454fe7fb 100644 --- a/tests/tools/test_web_tools_truncate.py +++ b/tests/tools/test_web_tools_truncate.py @@ -6,6 +6,7 @@ _get_extract_char_limit, and the end-to-end web_extract_tool truncation behavior import asyncio import json import os +from pathlib import Path from unittest.mock import patch import pytest @@ -49,7 +50,7 @@ class TestTruncation: path_line = next(ln for ln in out.splitlines() if "Full text saved to:" in ln) stored_path = path_line.split("Full text saved to:", 1)[1].strip() assert os.path.exists(stored_path) - full = open(stored_path).read() + full = Path(stored_path).read_text(encoding="utf-8") assert "UNIQUE_MIDDLE_MARKER" in full assert "row 2500" in full # the omitted-middle row is in the stored file