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
This commit is contained in:
xxxigm 2026-05-23 23:17:57 +07:00 committed by Teknium
parent e3f4dc2103
commit 0a5c5519fc

View file

@ -0,0 +1,307 @@
"""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"]
)