hermes-agent/tests/tools/test_termux_api_detection.py
xxxigm 0a5c5519fc test(voice-mode): pin Termux:API detection probe ladder + #31015 fallback
Two test classes lock in the new detection contract:

1. TestTermuxApiAppInstalledProbeLadder — drives _termux_api_app_installed
   with a fake subprocess.run dispatcher and walks each rung of the
   ladder:
     - non-Termux env returns False (no probes run),
     - pm-confirms returns True (back-compat),
     - pm-clean-miss → cmd-confirms returns True,
     - pm-FileNotFoundError → cmd-confirms returns True,
     - pm-TimeoutExpired → cmd-confirms returns True,
     - pm-nonzero-exit → cmd-confirms returns True,
     - both probes inconclusive + binary on PATH returns True (the
       core #31015 case),
     - both probes inconclusive + no binary returns False,
     - both probes clean-miss returns False (the genuine "CLI without
       app" case keeps the existing warning),
     - case-insensitive package match for ROMs that capitalise differently.

2. TestDetectAudioEnvironmentTermuxFallback — end-to-end through
   detect_audio_environment, asserting the misleading "Termux:API
   Android app is not installed" warning no longer fires when probes
   are inconclusive but the binary is on PATH (the user-reported #31015
   symptom), AND that the warning still fires when probes can
   conclusively report the app is missing.

Refs: NousResearch/hermes-agent#31015
2026-07-28 11:57:37 -07:00

307 lines
13 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_clean_miss_then_cmd_confirms(self, monkeypatch):
"""`pm` ran cleanly with no match → fall through to `cmd package`,
which finds the app. Some devices return empty `pm` output for
the calling user even when the package is installed."""
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": SimpleNamespace(returncode=0, stdout="", stderr=""),
"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_missing_then_cmd_confirms(self, monkeypatch):
"""`pm` not on PATH → FileNotFoundError → fall through to `cmd`."""
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": FileNotFoundError("pm: command not found"),
"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_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_both_probes_inconclusive_and_no_binary_returns_false(self, monkeypatch):
"""Without the binary on PATH there's nothing to trust — fall
through to False so the user gets the install hint."""
_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: None)
from tools.voice_mode import _termux_api_app_installed
assert _termux_api_app_installed() is False
def test_both_probes_clean_no_match_returns_false(self, monkeypatch):
"""Clean (returncode=0) probes that don't list the package are
authoritative — the app is genuinely missing. Don't promote
this to True via the binary fallback."""
_force_termux(monkeypatch)
run = _make_run_dispatcher({
"pm": SimpleNamespace(returncode=0, stdout="", stderr=""),
"cmd": SimpleNamespace(returncode=0, stdout="", stderr=""),
})
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 False
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"]
)