Files
voculinux/tests/test_gateway_embed.py
T
Jatin K MalikandGitHub fb9166ed9e feat(settings): run local VocaGateway from Advanced (podman-first) (#774)
* feat(settings): run local VocaGateway from Speech Engine (podman-first)

Add an optional Local VocaGateway panel next to Remote Server so Linux users
can start the pinned v0.1.0 compose project with podman (docker fallback),
watch Live/Pairable/Ready status, pair a phone without loopback QR URLs, and
preset remote_api to the gateway transcriptions endpoint. Stop preserves
volumes; Flatpak fails closed; tray offers Stop only when this session manages
the gateway. Default on-device engine stays whisper.cpp.

* fix(gateway-embed): drop nonexistent compose cpu profile

VocaGateway v0.1.0 exposes default service gateway plus native/cuda/vulkan
profiles, not cpu. Start and stop the default service without --profile, keep
the latest image pin rejection, and prefer save_config for the remote_api preset.

* fix(gateway-embed): keep compose --profile cpu per embed contract

v0.1.0 has no cpu profile entry, but Compose still starts the unprofiled
gateway service when --profile cpu is set, and keeps cuda/vulkan/native off.
That matches the frozen desktop embed contract and stays forward-compatible.

* fix(gateway-embed): harden embed path for settings and compose

Keep podman/docker probing off the GTK thread, reject link-local and
loopback public URLs for phone QR, sanitize compose .env image overrides,
and cap bootstrap token reads so a hostile file cannot blow memory.

* fix(gateway-embed): keep LAN toggle and phone QR in sync

When Allow LAN flips while compose is already managed, rewrite publish
host and PUBLIC_URL and recreate the gateway before Pairable comes back.
Also reject default docker/podman bridge addresses for phone QR.

* test(gateway-embed): pin bridge and LAN-gate pairing cases

Cover 172.17.0.2 / link-local not becoming Pairable, and the mid-run
LAN toggle path that must wait for compose republish before advertising
a phone URL.

* style(gateway-embed): black format embed modules

* fix(gateway-embed): reject short-form loopback and localhost.* for QR

Align Pairable/QR host checks with vocagateway desktop-embed policy so
127.1 / localhost.* never become phone-reachable display URLs.

* fix(gateway-embed): detach Settings status listener on destroy

Gateway poll callbacks were left registered after Settings closed, so
idle_add could touch destroyed GTK widgets while compose stayed up.

* fix(gateway-embed): pass host_env to bridge ip probe

_bridge_iface_ips shells out to host ip; without host_env the AST
gate fails and AppImage bundles can hand the wrong GLib to ip.

* fix(packaging): ship gateway_embed in the AppImage

The explicit setuptools packages list omitted the new package, so the
bundle booted from source trees but died with ModuleNotFoundError on
every distro boot test.

* style(packaging): black the gateway_embed package guard

* fix(settings): pack local VocaGateway into the Advanced island

The rebase onto main auto-merged textually onto the simple Speech Model
page. Keep the gateway card on advanced_box, next to Remote Server.

* fix(gateway-embed): treat missing Compose as unavailable

When podman or docker is present but Compose is not, stop advertising Run.
Return empty compose_args, show an install-Compose hint, and cover it with a unit test.

Also add module loggers on the new gateway_embed modules and annotate the
gateway Settings/tray callbacks Greptile flagged. just lock found no lockfile drift.

* fix(gateway-embed): keep Allow LAN bind matched to the switch

Snapshot the LAN flag for start/republish, record what compose actually
got, then republish again if the switch moved mid-flight so 0.0.0.0 never
lingers while Allow LAN reads off.

* fix(gateway-embed): detect leftover compose after Quit

Quit does not stop compose, so a LAN-published gateway can keep running.
Settings and the tray treated that as Stopped because managed_by_us is
only process memory. Probe compose after runtime detection, show Stop
when it is actually up, and let Stop work even if this session did not
start it.

* fix(gateway-embed): restore LAN switch from leftover compose

Quit leaves compose running, so the next launch now reads
VOCAGATEWAY_PUBLISH_HOST from the embed .env, adopts the project as
managed, and keeps Allow LAN honest so republish can close 0.0.0.0.

* fix(gateway-embed): restore .env if LAN republish fails

compose recreate can fail after write_env_file already pointed
VOCAGATEWAY_PUBLISH_HOST at loopback. Restore the previous .env so
orphan adopt still sees a live 0.0.0.0 bind, and snap Allow LAN back
to the running publish mode.

* style(gateway-embed): type helper args and black MagicMock

* refactor(types): move Readable protocol to common_types

AGENTS.md keeps Protocol interfaces in common_types. Pairing now imports
the shared Readable surface instead of a local _Readable helper.

* fix(gateway-embed): satisfy mypy on rebased embed code

* fix(audio): keep duck restore point when a set reports failure
2026-09-29 15:33:27 -07:00

979 lines
38 KiB
Python

"""Unit tests for local VocaGateway embed helpers (no real containers)."""
from __future__ import annotations
import io
import json
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from vocalinux.gateway_embed.pairing import (
MAX_QR_SVG_BYTES,
decode_pairing_payload,
)
from vocalinux.gateway_embed.preset import (
GATEWAY_TRANSCRIPTIONS_ENDPOINT,
remote_api_preset_from_pairing,
)
from vocalinux.gateway_embed.runtime import ContainerRuntime, detect_container_runtime
from vocalinux.gateway_embed.sandbox import detect_sandbox
from vocalinux.gateway_embed.status import GatewayStatus
from vocalinux.gateway_embed.urls import is_loopback_url, reject_loopback_url
from vocalinux.ui.config_manager import DEFAULT_CONFIG
class TestLoopbackRejection(unittest.TestCase):
def test_rejects_localhost_variants(self):
for url in (
"http://127.0.0.1:8765",
"http://localhost:8765",
"http://[::1]:8765",
"https://127.0.0.1/",
"",
None,
):
self.assertTrue(is_loopback_url(url), url)
self.assertIsNone(reject_loopback_url(url))
def test_rejects_short_form_loopback_and_localhost_star(self):
# Contract / gateway policy: 127.1 and localhost.* must never be Pairable.
for url in (
"http://127.1:8765",
"http://127.0.1:8765",
"http://localhost.localdomain:8765",
"http://localhost.foo:8765",
):
self.assertTrue(is_loopback_url(url), url)
self.assertIsNone(reject_loopback_url(url), url)
def test_rejects_link_local_for_qr(self):
for url in (
"http://169.254.10.20:8765",
"http://[fe80::1]:8765",
):
self.assertIsNone(reject_loopback_url(url), url)
def test_rejects_default_container_bridge(self):
for url in (
"http://172.17.0.1:8765",
"http://172.17.0.2:8765",
"http://10.88.0.1:8765",
):
self.assertIsNone(reject_loopback_url(url), url)
def test_accepts_lan(self):
url = "http://192.168.1.20:8765"
self.assertFalse(is_loopback_url(url))
self.assertEqual(reject_loopback_url(url), url)
class TestRuntimeDetector(unittest.TestCase):
def test_prefers_podman(self):
def lookup(name):
return {
"podman": "/usr/bin/podman",
"docker": "/usr/bin/docker",
}.get(name)
def probe(argv, **_kwargs):
return argv[0] in {"/usr/bin/podman", "/usr/bin/docker"} and argv[1] in {
"info",
"version",
"compose",
}
info = detect_container_runtime(path_lookup=lookup, probe=probe)
self.assertEqual(info.kind, ContainerRuntime.PODMAN)
self.assertEqual(info.binary, "/usr/bin/podman")
def test_falls_back_to_docker(self):
def lookup(name):
return {"docker": "/usr/bin/docker"}.get(name)
def probe(argv, **_kwargs):
return argv[0] == "/usr/bin/docker"
info = detect_container_runtime(path_lookup=lookup, probe=probe)
self.assertEqual(info.kind, ContainerRuntime.DOCKER)
def test_none_with_hint(self):
info = detect_container_runtime(path_lookup=lambda _n: None, probe=lambda *_a, **_k: False)
self.assertEqual(info.kind, ContainerRuntime.NONE)
self.assertIn("podman", info.hint.lower())
def test_engine_without_compose_is_unavailable(self):
def lookup(name):
return {"podman": "/usr/bin/podman"}.get(name)
def probe(argv, **_kwargs):
# Engine probes succeed; the compose plugin probe (argv[1]=="compose") fails.
return (
bool(argv)
and argv[0] == "/usr/bin/podman"
and len(argv) > 1
and argv[1] in {"info", "version"}
)
info = detect_container_runtime(path_lookup=lookup, probe=probe)
self.assertEqual(info.kind, ContainerRuntime.PODMAN)
self.assertEqual(info.compose_args, ())
self.assertIn("compose", info.hint.lower())
from vocalinux.gateway_embed.runner import GatewayRunner
runner = GatewayRunner(
runtime=info,
sandbox=detect_sandbox({}),
run=MagicMock(),
)
self.assertFalse(runner.available)
self.assertEqual(runner.unavailable_hint, info.hint)
self.assertNotIn("No container runtime", runner.unavailable_hint)
class TestStatusMachine(unittest.TestCase):
def test_ready_beats_pairable(self):
self.assertEqual(
GatewayStatus.from_health(live=True, ready=True, pairable=True, running=True),
GatewayStatus.READY,
)
def test_pairable_before_ready(self):
self.assertEqual(
GatewayStatus.from_health(live=True, ready=False, pairable=True, running=True),
GatewayStatus.PAIRABLE,
)
def test_live_only(self):
self.assertEqual(
GatewayStatus.from_health(live=True, ready=False, pairable=False, running=True),
GatewayStatus.LIVE,
)
def test_starting(self):
self.assertEqual(
GatewayStatus.from_health(
live=False, ready=False, pairable=False, running=True, starting=True
),
GatewayStatus.STARTING,
)
def test_stopped(self):
self.assertEqual(
GatewayStatus.from_health(live=False, ready=False, pairable=False, running=False),
GatewayStatus.STOPPED,
)
def test_error(self):
self.assertEqual(
GatewayStatus.from_health(
live=False, ready=False, pairable=False, error=True, running=False
),
GatewayStatus.ERROR,
)
class TestRemoteApiPreset(unittest.TestCase):
def test_maps_openai_path(self):
preset = remote_api_preset_from_pairing(
url="http://192.168.1.20:8765",
token="a" * 32,
)
self.assertEqual(preset["engine"], "remote_api")
self.assertEqual(preset["remote_api_url"], "http://192.168.1.20:8765")
self.assertEqual(preset["remote_api_key"], "a" * 32)
self.assertEqual(preset["remote_api_endpoint"], GATEWAY_TRANSCRIPTIONS_ENDPOINT)
def test_rejects_empty(self):
with self.assertRaises(ValueError):
remote_api_preset_from_pairing(url="", token="x" * 32)
class TestPairingDecode(unittest.TestCase):
def test_payload_object(self):
info = decode_pairing_payload(
{
"payload": {
"v": 1,
"url": "http://10.0.0.5:8765",
"token": "tokentokentokentokentokentoken12",
}
}
)
self.assertEqual(info.display_url, "http://10.0.0.5:8765")
self.assertTrue(info.pairable)
def test_payload_string_and_loopback_hidden(self):
raw = json.dumps({"v": 1, "url": "http://127.0.0.1:8765", "token": "t" * 32})
info = decode_pairing_payload({"payload": raw})
self.assertIsNone(info.display_url)
self.assertFalse(info.pairable)
def test_docker_bridge_not_pairable(self):
info = decode_pairing_payload(
{
"payload": {
"v": 1,
"url": "http://172.17.0.2:8765",
"token": "tokentokentokentokentokentoken12",
}
}
)
self.assertIsNone(info.display_url)
self.assertFalse(info.pairable)
def test_link_local_not_pairable(self):
info = decode_pairing_payload(
{
"payload": {
"v": 1,
"url": "http://169.254.1.2:8765",
"token": "tokentokentokentokentokentoken12",
}
}
)
self.assertIsNone(info.display_url)
self.assertFalse(info.pairable)
def test_short_form_loopback_not_pairable(self):
info = decode_pairing_payload(
{
"payload": {
"v": 1,
"url": "http://127.1:8765",
"token": "tokentokentokentokentokentoken12",
}
}
)
self.assertIsNone(info.display_url)
self.assertFalse(info.pairable)
def test_qr_download_capped(self):
from vocalinux.gateway_embed.pairing import _read_capped
class FakeResp:
def __init__(self, payload: bytes):
self._buf = io.BytesIO(payload)
def read(self, n=-1):
return self._buf.read(n if n is not None else -1)
huge = b"x" * (MAX_QR_SVG_BYTES + 10)
with self.assertRaises(ValueError):
_read_capped(FakeResp(huge), MAX_QR_SVG_BYTES)
class TestSandbox(unittest.TestCase):
def test_flatpak_fails_closed(self):
state = detect_sandbox({"FLATPAK_ID": "com.vocahq.Vocalinux"})
self.assertTrue(state.blocked)
self.assertEqual(state.kind, "flatpak")
self.assertIn("Flatpak", state.hint)
def test_host_ok(self):
state = detect_sandbox({})
self.assertFalse(state.blocked)
class TestDefaultEngineUnchanged(unittest.TestCase):
def test_default_still_whisper_cpp(self):
self.assertEqual(DEFAULT_CONFIG["speech_recognition"]["engine"], "whisper_cpp")
self.assertIn("gateway_embed", DEFAULT_CONFIG)
self.assertFalse(DEFAULT_CONFIG["gateway_embed"]["lan_publish"])
class TestRunnerNoVolumeWipe(unittest.TestCase):
def test_stop_refuses_wipe_flag(self):
from vocalinux.gateway_embed.runner import GatewayRunner
from vocalinux.gateway_embed.runtime import RuntimeInfo
runner = GatewayRunner(
runtime=RuntimeInfo(
kind=ContainerRuntime.PODMAN,
binary="/usr/bin/podman",
compose_args=("/usr/bin/podman", "compose"),
),
sandbox=detect_sandbox({}),
run=MagicMock(),
)
with self.assertRaises(RuntimeError):
runner.stop(wipe_volumes=True)
class TestComposeCpuProfile(unittest.TestCase):
def test_start_uses_profile_cpu(self):
from vocalinux.gateway_embed.runner import GatewayRunner
from vocalinux.gateway_embed.runtime import RuntimeInfo
calls = []
def fake_run(argv, **kwargs):
calls.append(list(argv))
completed = MagicMock()
completed.returncode = 0
completed.stdout = b""
completed.stderr = b""
return completed
runner = GatewayRunner(
runtime=RuntimeInfo(
kind=ContainerRuntime.PODMAN,
binary="/usr/bin/podman",
compose_args=("/usr/bin/podman", "compose"),
),
sandbox=detect_sandbox({}),
run=fake_run,
)
with patch.object(runner, "prepare", return_value=("/tmp/gw", "t" * 32, "/tmp/.env")):
result = runner.start(lan_publish=False)
self.assertTrue(result.ok)
compose_calls = [c for c in calls if c[:2] == ["/usr/bin/podman", "compose"]]
self.assertTrue(compose_calls)
argv = compose_calls[0]
self.assertIn("--profile", argv)
self.assertEqual(argv[argv.index("--profile") + 1], "cpu")
self.assertIn("up", argv)
self.assertIn("-d", argv)
self.assertIn("gateway", argv)
class TestImagePin(unittest.TestCase):
def test_rejects_latest_override(self):
import tempfile
from vocalinux.gateway_embed.runner import write_env_file
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
with patch.dict("os.environ", {"VOCAGATEWAY_IMAGE": "vocagateway:latest"}):
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
body = Path(env_path).read_text(encoding="utf-8")
self.assertIn("VOCAGATEWAY_IMAGE=vocagateway:v0.1.0", body)
self.assertNotIn(":latest", body)
def test_rejects_newline_image_injection(self):
import tempfile
from vocalinux.gateway_embed.runner import write_env_file
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
evil = "vocagateway:v0.1.0\nEVIL=1"
with patch.dict("os.environ", {"VOCAGATEWAY_IMAGE": evil}):
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
body = Path(env_path).read_text(encoding="utf-8")
self.assertIn("VOCAGATEWAY_IMAGE=vocagateway:v0.1.0\n", body)
self.assertNotIn("EVIL=", body)
def test_omits_loopback_public_url(self):
import tempfile
from vocalinux.gateway_embed.runner import write_env_file
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
write_env_file(
token="a" * 32,
lan_publish=True,
public_url="http://127.0.0.1:8765",
path=env_path,
)
body = Path(env_path).read_text(encoding="utf-8")
self.assertNotIn("VOCAGATEWAY_PUBLIC_URL=", body)
def test_read_lan_publish_from_env_matches_write(self):
import tempfile
from vocalinux.gateway_embed.runner import (
read_lan_publish_from_env,
write_env_file,
)
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
self.assertTrue(read_lan_publish_from_env(env_path))
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
self.assertFalse(read_lan_publish_from_env(env_path))
self.assertFalse(read_lan_publish_from_env(f"{tmp}/missing.env"))
class TestTokenFileCap(unittest.TestCase):
def test_oversized_token_file_regenerated(self):
import tempfile
from vocalinux.gateway_embed.paths_embed import ensure_token_file
with tempfile.TemporaryDirectory() as tmp:
path = f"{tmp}/token"
with open(path, "wb") as handle:
handle.write(b"a" * 9000)
token = ensure_token_file(path)
self.assertEqual(len(token), 64)
self.assertLess(Path(path).stat().st_size, 200)
class TestLanPublishGate(unittest.TestCase):
def _manager(self):
from vocalinux.gateway_embed.manager import GatewayEmbedManager
from vocalinux.gateway_embed.runner import GatewayRunner
from vocalinux.gateway_embed.runtime import RuntimeInfo
runner = GatewayRunner(
runtime=RuntimeInfo(
kind=ContainerRuntime.PODMAN,
binary="/usr/bin/podman",
compose_args=("/usr/bin/podman", "compose"),
),
sandbox=detect_sandbox({}),
run=MagicMock(),
)
return GatewayEmbedManager(runner=runner), runner
def test_lan_toggle_without_republish_not_pairable(self):
"""Desired LAN alone must not advertise Pairable on a loopback bind."""
from vocalinux.gateway_embed.pairing import PairingInfo
manager, runner = self._manager()
runner.managed_by_us = True
manager.lan_publish = True
manager._compose_lan_publish = False
manager._token = "t" * 32
self.assertFalse(manager._effective_lan_for_pairing())
captured = {}
def fake_fetch(base_url, token, *, public_url=None, fetch_qr=True, timeout=3.0):
captured["public_url"] = public_url
return PairingInfo(
version=1,
url="http://127.0.0.1:8765",
token=token,
display_url=None,
raw_payload={"v": 1, "url": "http://127.0.0.1:8765", "token": token},
)
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
with patch("vocalinux.gateway_embed.manager.fetch_pairing", side_effect=fake_fetch):
status = manager.refresh_status()
self.assertIsNone(captured.get("public_url"))
self.assertEqual(status, GatewayStatus.LIVE)
self.assertIsNotNone(manager.pairing)
self.assertFalse(manager.pairing.pairable)
def test_apply_lan_publish_republishes_when_managed(self):
from vocalinux.gateway_embed.pairing import PairingInfo
from vocalinux.gateway_embed.runner import RunnerResult
manager, runner = self._manager()
runner.managed_by_us = True
manager.lan_publish = False
manager._compose_lan_publish = False
manager._pairing = PairingInfo(
version=1,
url="http://127.0.0.1:8765",
token="t" * 32,
display_url=None,
raw_payload={},
)
calls = {"n": 0}
def fake_republish(*, lan_publish, public_url=None):
calls["n"] += 1
self.assertTrue(lan_publish)
return RunnerResult(ok=True, message="republished")
with patch("threading.Thread") as fake_thread:
fake_thread.return_value = MagicMock()
with patch.object(runner, "republish", side_effect=fake_republish):
with patch.object(manager, "refresh_status", return_value=GatewayStatus.LIVE):
manager.apply_lan_publish(True)
self.assertIsNone(manager.pairing)
self.assertTrue(manager._republish_started)
# Run worker synchronously (Thread.start was mocked).
manager._republish_worker()
self.assertEqual(calls["n"], 1)
self.assertTrue(manager._compose_lan_publish)
self.assertTrue(manager.lan_publish)
self.assertFalse(manager._republish_started)
def test_start_worker_snapshots_lan_if_switch_flips_mid_start(self):
from vocalinux.gateway_embed.runner import RunnerResult
manager, runner = self._manager()
manager.lan_publish = True
manager._compose_lan_publish = None
def fake_start(*, lan_publish, public_url=None):
self.assertTrue(lan_publish)
manager.lan_publish = False
runner.managed_by_us = True
return RunnerResult(ok=True, message="started")
with patch("threading.Thread") as fake_thread:
fake_thread.return_value = MagicMock()
with patch(
"vocalinux.gateway_embed.paths_embed.ensure_token_file",
return_value="t" * 32,
):
with patch.object(runner, "ensure_runtime"):
with patch.object(runner, "start", side_effect=fake_start):
with patch.object(manager, "_start_polling"):
with patch.object(manager, "refresh_status"):
manager._start_worker()
self.assertIs(manager._compose_lan_publish, True)
self.assertFalse(manager.lan_publish)
self.assertTrue(manager._republish_started)
def test_republish_worker_snapshots_lan_if_switch_flips_mid_republish(self):
from vocalinux.gateway_embed.runner import RunnerResult
manager, runner = self._manager()
runner.managed_by_us = True
manager.lan_publish = True
manager._compose_lan_publish = False
manager._republish_started = True
def fake_republish(*, lan_publish, public_url=None):
self.assertTrue(lan_publish)
manager.lan_publish = False
return RunnerResult(ok=True, message="republished")
with patch("threading.Thread") as fake_thread:
fake_thread.return_value = MagicMock()
with patch.object(runner, "republish", side_effect=fake_republish):
with patch.object(manager, "refresh_status"):
manager._republish_worker()
self.assertIs(manager._compose_lan_publish, True)
self.assertFalse(manager.lan_publish)
self.assertTrue(manager._republish_started)
def test_live_detail_warns_when_lan_off_but_compose_still_open(self):
from vocalinux.gateway_embed.pairing import PairingInfo
manager, runner = self._manager()
runner.managed_by_us = True
manager.lan_publish = False
manager._compose_lan_publish = True
manager._token = "t" * 32
def fake_fetch(base_url, token, *, public_url=None, fetch_qr=True, timeout=3.0):
return PairingInfo(
version=1,
url="http://127.0.0.1:8765",
token=token,
display_url=None,
raw_payload={"v": 1, "url": "http://127.0.0.1:8765", "token": token},
)
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
with patch("vocalinux.gateway_embed.manager.fetch_pairing", side_effect=fake_fetch):
status = manager.refresh_status()
self.assertEqual(status, GatewayStatus.LIVE)
self.assertIn("still open on the LAN", manager.status_detail)
self.assertFalse(manager._effective_lan_for_pairing())
def test_omits_bridge_public_url_from_env(self):
import tempfile
from vocalinux.gateway_embed.runner import write_env_file
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
write_env_file(
token="a" * 32,
lan_publish=True,
public_url="http://172.17.0.2:8765",
path=env_path,
)
body = Path(env_path).read_text(encoding="utf-8")
self.assertNotIn("VOCAGATEWAY_PUBLIC_URL=", body)
class TestOrphanComposeDetect(unittest.TestCase):
"""Leftover compose after Quit must not look Stopped."""
def _manager(self):
from vocalinux.gateway_embed.manager import GatewayEmbedManager
from vocalinux.gateway_embed.runner import GatewayRunner
from vocalinux.gateway_embed.runtime import RuntimeInfo
runner = GatewayRunner(
runtime=RuntimeInfo(
kind=ContainerRuntime.PODMAN,
binary="/usr/bin/podman",
compose_args=("/usr/bin/podman", "compose"),
),
sandbox=detect_sandbox({}),
run=MagicMock(),
)
runner.managed_by_us = False
return GatewayEmbedManager(runner=runner), runner
def test_refresh_status_does_not_stay_stopped_for_orphan(self):
manager, runner = self._manager()
self.assertFalse(manager.managed_by_us)
self.assertEqual(manager.status, GatewayStatus.STOPPED)
with patch.object(runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
status = manager.refresh_status()
self.assertEqual(status, GatewayStatus.LIVE)
self.assertNotEqual(status, GatewayStatus.STOPPED)
self.assertFalse(manager.managed_by_us)
stoppable = {
GatewayStatus.STARTING,
GatewayStatus.LIVE,
GatewayStatus.PAIRABLE,
GatewayStatus.READY,
GatewayStatus.ERROR,
}
self.assertIn(status, stoppable)
def test_refresh_status_starting_when_compose_up_but_not_live(self):
manager, runner = self._manager()
with patch.object(runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=False, ready=False, error="")
status = manager.refresh_status()
self.assertEqual(status, GatewayStatus.STARTING)
self.assertNotEqual(status, GatewayStatus.STOPPED)
def test_runtime_ready_probe_adopts_orphan_and_starts_polling(self):
manager, runner = self._manager()
self.assertTrue(manager.runtime_ready)
self.assertFalse(manager._runtime_detect_started)
with patch.object(runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
with patch.object(manager, "_start_polling") as poll:
manager.begin_runtime_detection()
self.assertEqual(manager.status, GatewayStatus.LIVE)
poll.assert_called_once()
def test_orphan_probe_loads_existing_token(self):
import tempfile
manager, runner = self._manager()
with tempfile.TemporaryDirectory() as tmp:
token_path = f"{tmp}/token"
existing = "ab" * 32
with open(token_path, "w", encoding="utf-8") as handle:
handle.write(existing + "\n")
with patch(
"vocalinux.gateway_embed.paths_embed.token_file_path",
return_value=token_path,
):
with patch.object(runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=False, ready=False, error="")
with patch.object(manager, "_start_polling"):
manager.begin_runtime_detection()
self.assertEqual(manager._token, existing)
def test_stop_worker_stops_orphan_without_managed_by_us(self):
from vocalinux.gateway_embed.runner import RunnerResult
manager, runner = self._manager()
self.assertFalse(manager.managed_by_us)
with patch.object(
runner, "stop", return_value=RunnerResult(ok=True, message="stopped")
) as stop:
manager._stop_worker()
stop.assert_called_once_with(wipe_volumes=False)
self.assertEqual(manager.status, GatewayStatus.STOPPED)
self.assertFalse(manager.managed_by_us)
def test_stop_async_starts_worker_for_orphan(self):
manager, runner = self._manager()
self.assertFalse(manager.managed_by_us)
with patch("threading.Thread") as fake_thread:
started = MagicMock()
fake_thread.return_value = started
manager.stop_async()
fake_thread.assert_called_once()
kwargs = fake_thread.call_args.kwargs
self.assertEqual(kwargs.get("name"), "vocalinux-gateway-stop")
self.assertEqual(kwargs.get("target").__func__, manager._stop_worker.__func__)
started.start.assert_called_once()
def test_orphan_adopt_restores_lan_on_from_env(self):
import tempfile
from vocalinux.gateway_embed.runner import write_env_file
manager, runner = self._manager()
self.assertFalse(runner.managed_by_us)
manager.lan_publish = False
manager._compose_lan_publish = None
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
with patch(
"vocalinux.gateway_embed.runner.env_file_path",
return_value=env_path,
):
with patch.object(runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
with patch.object(manager, "_start_polling"):
manager.begin_runtime_detection()
self.assertIs(manager._compose_lan_publish, True)
self.assertTrue(manager.lan_publish)
self.assertTrue(runner.managed_by_us)
self.assertTrue(manager.managed_by_us)
def test_orphan_adopt_restores_lan_off_from_loopback_env(self):
import tempfile
from vocalinux.gateway_embed.runner import write_env_file
manager, runner = self._manager()
manager.lan_publish = True
manager._compose_lan_publish = None
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
write_env_file(token="a" * 32, lan_publish=False, path=env_path)
with patch(
"vocalinux.gateway_embed.runner.env_file_path",
return_value=env_path,
):
with patch.object(runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
with patch.object(manager, "_start_polling"):
manager.begin_runtime_detection()
self.assertIs(manager._compose_lan_publish, False)
self.assertFalse(manager.lan_publish)
self.assertTrue(runner.managed_by_us)
def test_apply_lan_publish_republishes_after_orphan_lan_adopt(self):
import tempfile
from vocalinux.gateway_embed.runner import RunnerResult, write_env_file
manager, runner = self._manager()
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
with patch(
"vocalinux.gateway_embed.runner.env_file_path",
return_value=env_path,
):
with patch.object(runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
with patch.object(manager, "_start_polling"):
manager.begin_runtime_detection()
self.assertTrue(runner.managed_by_us)
self.assertIs(manager._compose_lan_publish, True)
calls = {"n": 0}
def fake_republish(*, lan_publish, public_url=None):
calls["n"] += 1
self.assertFalse(lan_publish)
return RunnerResult(ok=True, message="republished")
with patch("threading.Thread") as fake_thread:
fake_thread.return_value = MagicMock()
with patch.object(runner, "republish", side_effect=fake_republish):
with patch.object(manager, "refresh_status", return_value=GatewayStatus.LIVE):
manager.apply_lan_publish(False)
self.assertTrue(manager._republish_started)
manager._republish_worker()
self.assertEqual(calls["n"], 1)
self.assertIs(manager._compose_lan_publish, False)
self.assertFalse(manager.lan_publish)
class TestFailedRepublishEnvRestore(unittest.TestCase):
"""Failed force-recreate must not leave .env lying about the live bind."""
def _podman_runner(self, run):
from vocalinux.gateway_embed.runner import GatewayRunner
from vocalinux.gateway_embed.runtime import RuntimeInfo
return GatewayRunner(
runtime=RuntimeInfo(
kind=ContainerRuntime.PODMAN,
binary="/usr/bin/podman",
compose_args=("/usr/bin/podman", "compose"),
),
sandbox=detect_sandbox({}),
run=run,
)
def test_failed_republish_restores_previous_env_publish_host(self):
import os
import tempfile
from vocalinux.gateway_embed.runner import (
read_lan_publish_from_env,
write_env_file,
)
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
checkout = f"{tmp}/checkout"
os.makedirs(checkout)
Path(checkout, "compose.yaml").write_text("services: {}\n", encoding="utf-8")
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
self.assertTrue(read_lan_publish_from_env(env_path))
def fake_run(argv, **_kwargs):
completed = MagicMock()
completed.returncode = 1
completed.stdout = b""
completed.stderr = b"force-recreate failed"
return completed
runner = self._podman_runner(fake_run)
runner.managed_by_us = True
with patch("vocalinux.gateway_embed.runner.env_file_path", return_value=env_path):
with patch(
"vocalinux.gateway_embed.runner.ensure_gateway_checkout",
return_value=checkout,
):
with patch(
"vocalinux.gateway_embed.runner.ensure_token_file",
return_value="a" * 32,
):
result = runner.republish(lan_publish=False)
self.assertFalse(result.ok)
self.assertTrue(read_lan_publish_from_env(env_path))
body = Path(env_path).read_text(encoding="utf-8")
self.assertIn("VOCAGATEWAY_PUBLISH_HOST=0.0.0.0", body)
self.assertNotIn("VOCAGATEWAY_PUBLISH_HOST=127.0.0.1", body)
def test_failed_republish_reverts_manager_lan_publish_to_compose(self):
from vocalinux.gateway_embed.manager import GatewayEmbedManager
from vocalinux.gateway_embed.runner import RunnerResult
runner = self._podman_runner(MagicMock())
runner.managed_by_us = True
manager = GatewayEmbedManager(runner=runner)
manager.lan_publish = False
manager._compose_lan_publish = True
manager._republish_started = True
def fake_republish(*, lan_publish, public_url=None):
self.assertFalse(lan_publish)
return RunnerResult(ok=False, message="force-recreate failed")
with patch("threading.Thread") as fake_thread:
fake_thread.return_value = MagicMock()
with patch.object(runner, "republish", side_effect=fake_republish):
manager._republish_worker()
self.assertIs(manager._compose_lan_publish, True)
self.assertTrue(manager.lan_publish)
self.assertEqual(manager.status, GatewayStatus.ERROR)
self.assertIn("previous bind is still in use", manager.status_detail)
self.assertIn("Stop and Run", manager.status_detail)
self.assertFalse(manager._republish_started)
def test_orphan_adopt_after_failed_republish_still_sees_lan_from_env(self):
import os
import tempfile
from vocalinux.gateway_embed.manager import GatewayEmbedManager
from vocalinux.gateway_embed.runner import (
read_lan_publish_from_env,
write_env_file,
)
with tempfile.TemporaryDirectory() as tmp:
env_path = f"{tmp}/.env"
checkout = f"{tmp}/checkout"
os.makedirs(checkout)
Path(checkout, "compose.yaml").write_text("services: {}\n", encoding="utf-8")
write_env_file(token="a" * 32, lan_publish=True, path=env_path)
def fake_run(argv, **_kwargs):
completed = MagicMock()
completed.returncode = 1
completed.stdout = b""
completed.stderr = b"force-recreate failed"
return completed
runner = self._podman_runner(fake_run)
runner.managed_by_us = True
with patch("vocalinux.gateway_embed.runner.env_file_path", return_value=env_path):
with patch(
"vocalinux.gateway_embed.runner.ensure_gateway_checkout",
return_value=checkout,
):
with patch(
"vocalinux.gateway_embed.runner.ensure_token_file",
return_value="a" * 32,
):
result = runner.republish(lan_publish=False)
self.assertFalse(result.ok)
self.assertTrue(read_lan_publish_from_env(env_path))
next_runner = self._podman_runner(MagicMock())
manager = GatewayEmbedManager(runner=next_runner)
manager.lan_publish = False
manager._compose_lan_publish = None
with patch.object(next_runner, "is_compose_running", return_value=True):
with patch("vocalinux.gateway_embed.manager.probe_health") as health:
health.return_value = MagicMock(live=True, ready=False, error="")
with patch.object(manager, "_start_polling"):
manager.begin_runtime_detection()
self.assertTrue(manager.lan_publish)
self.assertIs(manager._compose_lan_publish, True)
self.assertTrue(next_runner.managed_by_us)
class TestManagerListenerCleanup(unittest.TestCase):
def test_remove_listener_stops_callbacks(self):
"""Settings destroy must be able to detach without further emits."""
from vocalinux.gateway_embed.manager import GatewayEmbedManager
from vocalinux.gateway_embed.runner import GatewayRunner
from vocalinux.gateway_embed.runtime import RuntimeInfo
runner = GatewayRunner(
runtime=RuntimeInfo(
kind=ContainerRuntime.PODMAN,
binary="/usr/bin/podman",
compose_args=("/usr/bin/podman", "compose"),
),
sandbox=detect_sandbox({}),
run=MagicMock(),
)
manager = GatewayEmbedManager(runner=runner)
seen: list[str] = []
def listener(status, detail):
seen.append(detail)
manager.add_listener(listener)
manager._emit(manager.status, "one")
manager.remove_listener(listener)
manager._emit(manager.status, "two")
self.assertEqual(seen, ["one"])
if __name__ == "__main__":
unittest.main()