hermes-agent/tests/tools/test_termux_api_detection.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

242 lines
9.8 KiB
Python

"""Regression tests for issue #31015 — Termux:API app detection.
`/voice on` was reporting "Termux:API Android app is not installed"
even on devices where the app *is* installed and the
`termux-microphone-record` binary works fine. The cause was a single
brittle probe (`pm list packages com.termux.api`) that returns a
false negative on some Android versions / Termux configurations.
These tests pin the new probe ladder:
1. `pm list packages` confirms the app → True (back-compat).
2. `pm` missing or non-zero → fall back to
`cmd package list packages` (modern Android API 28+).
3. Both probes inconclusive but `termux-microphone-record` on PATH
→ trust the binary, return True.
4. At least one probe ran cleanly and definitively did not list
the package → return False (the genuine "CLI without app"
case the existing warning was written for).
"""
from __future__ import annotations
import subprocess
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
# ── Helpers ────────────────────────────────────────────────────────────────
def _make_run_dispatcher(behaviors):
"""Build a fake `subprocess.run` that returns / raises per-command.
`behaviors` maps the first arg of each invocation to either a
SimpleNamespace (returncode, stdout, stderr) or an Exception class
instance to raise.
"""
def _fake_run(cmd, **kwargs):
key = cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd
action = behaviors.get(key)
if action is None:
raise AssertionError(f"Unexpected probe command: {cmd!r}")
if isinstance(action, BaseException):
raise action
return action
return _fake_run
def _force_termux(monkeypatch):
monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: True)
# ── _termux_api_app_installed: probe ladder ───────────────────────────────
class TestTermuxApiAppInstalledProbeLadder:
"""The probe ladder is the heart of the #31015 fix — keep its truth
table pinned so future "simplifications" can't silently regress it."""
def test_returns_false_outside_termux(self, monkeypatch):
monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: False)
from tools.voice_mode import _termux_api_app_installed
assert _termux_api_app_installed() is False
def test_pm_confirms_package_returns_true(self, monkeypatch):
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": SimpleNamespace(
returncode=0,
stdout="package:com.termux.api\n",
stderr="",
),
})
monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
from tools.voice_mode import _termux_api_app_installed
assert _termux_api_app_installed() is True
def test_pm_timeout_then_cmd_confirms(self, monkeypatch):
"""A hung `pm` (5s timeout) must not block detection."""
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": subprocess.TimeoutExpired(cmd="pm", timeout=5),
"cmd": SimpleNamespace(
returncode=0,
stdout="package:com.termux.api\n",
stderr="",
),
})
monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
from tools.voice_mode import _termux_api_app_installed
assert _termux_api_app_installed() is True
def test_pm_nonzero_exit_then_cmd_confirms(self, monkeypatch):
"""Non-zero exit (e.g. permission denied for the calling user)
is treated as inconclusive, not as a definitive "no"."""
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": SimpleNamespace(returncode=1, stdout="", stderr="permission denied"),
"cmd": SimpleNamespace(
returncode=0,
stdout="package:com.termux.api\n",
stderr="",
),
})
monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
from tools.voice_mode import _termux_api_app_installed
assert _termux_api_app_installed() is True
def test_both_probes_inconclusive_but_binary_present_returns_true(self, monkeypatch):
"""Core #31015 case: both probes fail or are unavailable, but the
`termux-microphone-record` binary is on PATH. Trust the binary —
a false positive only surfaces a precise runtime error, while a
false negative blocks /voice on entirely (the user-reported
symptom)."""
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": FileNotFoundError("pm: command not found"),
"cmd": FileNotFoundError("cmd: command not found"),
})
monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
monkeypatch.setattr(
"tools.voice_mode.shutil.which",
lambda name: "/data/data/com.termux/files/usr/bin/termux-microphone-record"
if name == "termux-microphone-record" else None,
)
from tools.voice_mode import _termux_api_app_installed
assert _termux_api_app_installed() is True
def test_match_is_case_insensitive(self, monkeypatch):
"""Defensive against ROMs that capitalise the prefix differently."""
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": SimpleNamespace(
returncode=0,
stdout="Package:com.termux.api\n",
stderr="",
),
})
monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
from tools.voice_mode import _termux_api_app_installed
assert _termux_api_app_installed() is True
# ── End-to-end through detect_audio_environment ───────────────────────────
class TestDetectAudioEnvironmentTermuxFallback:
"""The point of the fix is that #31015 users with the binary on PATH
no longer see the misleading 'Termux:API Android app is not installed'
warning when the package-manager probe is inconclusive."""
def test_inconclusive_probes_with_binary_does_not_emit_app_warning(
self, monkeypatch
):
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", raising=False)
# No sounddevice — we go down the Termux:API branch.
monkeypatch.setattr(
"tools.voice_mode._import_audio",
lambda: (_ for _ in ()).throw(ImportError("no audio libs")),
)
monkeypatch.setattr(
"tools.voice_mode._termux_microphone_command",
lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record",
)
# Both probes fail (the #31015 reproduction).
run = _make_run_dispatcher({
"pm": FileNotFoundError("pm: command not found"),
"cmd": FileNotFoundError("cmd: command not found"),
})
monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
monkeypatch.setattr(
"tools.voice_mode.shutil.which",
lambda name: "/data/data/com.termux/files/usr/bin/termux-microphone-record"
if name == "termux-microphone-record" else None,
)
from tools.voice_mode import detect_audio_environment
result = detect_audio_environment()
assert result["available"] is True, (
f"Voice mode should be available when the binary is on PATH "
f"and probes are inconclusive (issue #31015). Got: {result}"
)
assert not any(
"Termux:API Android app is not installed" in w
for w in result["warnings"]
), (
"The misleading 'app is not installed' warning must not fire "
"when probes are inconclusive but the binary works (issue "
f"#31015). warnings={result['warnings']!r}"
)
assert any(
"Termux:API microphone recording available" in n
for n in result.get("notices", [])
)
def test_clean_probes_no_match_still_blocks(self, monkeypatch):
"""The genuine "CLI installed without the app" case still blocks
with the existing warning — important so users don't lose the
install hint when the package manager *can* tell us the truth."""
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", raising=False)
monkeypatch.setattr(
"tools.voice_mode._import_audio",
lambda: (_ for _ in ()).throw(ImportError("no audio libs")),
)
monkeypatch.setattr(
"tools.voice_mode._termux_microphone_command",
lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record",
)
run = _make_run_dispatcher({
"pm": SimpleNamespace(returncode=0, stdout="", stderr=""),
"cmd": SimpleNamespace(returncode=0, stdout="", stderr=""),
})
monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
from tools.voice_mode import detect_audio_environment
result = detect_audio_environment()
assert result["available"] is False
assert any(
"Termux:API Android app is not installed" in w
for w in result["warnings"]
)