Files
EvoScientist-Multi/tests/test_cli_deploy.py
T
Xiaohui Yan 3c5cc831c0 Feat/configurable bind host (#402)
* feat: configurable bind host for WebUI and langgraph dev (refs #400)

WebUI mode was only reachable from the machine running it: the front-end
got no bind interface, and `start_langgraph_dev(...)` was called without a
host, so both servers stayed on loopback with no way to widen them.

Adds two config fields with deliberately different defaults:

  webui_host        = 0.0.0.0    front-end serves the app shell, no secrets
  langgraph_dev_host = 127.0.0.1  unauthenticated API, agent can run shell

The design hinges on separating bind address from client address. Only
bind() uses the configured interface; every consumer that *connects*
(health probes, occupancy checks, async sub-agent self-dispatch) goes
through the new `_probe_host`, which maps a wildcard bind back to
loopback and honors a pinned interface verbatim. `_can_bind_port` is the
one exception and binds the literal host, since it must replicate the
bind the server itself will attempt.

  - manager.py: `_probe_host`, `_is_loopback_host`, `_format_hostport`;
    host kwarg threaded through the probes and `start_langgraph_dev`,
    which now emits `--host` and propagates
    EVOSCIENTIST_LANGGRAPH_DEV_HOST to the subprocess
  - sdk.py: `langgraph_dev_url` tracks host as well as port;
    EvoScientist.py reuses it instead of an inline f-string
  - server.py: `--host` flag mirroring `--port`, plus a red PUBLIC BIND
    banner whenever the bind is not provably loopback
  - webui.py: forwards both hosts; the front-end is widened via HOSTNAME
    because @evoscientist/webui ships no --host flag — its bin launcher
    does `HOSTNAME: process.env.HOSTNAME || "127.0.0.1"`. The warning is
    gated on the backend host only, so the shipped front-end default
    doesn't print a banner on every launch

Verified end to end against a live server: requesting 0.0.0.0 yields a
socket listening on 0.0.0.0 with the health probe correctly resolved to
127.0.0.1, while the default still binds 127.0.0.1 only.

Note: webui_host defaulting to 0.0.0.0 is a behavior change — upgrading
users will find the front-end reachable from the LAN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: default both bind hosts to 0.0.0.0, add --host and wizard host rendering (closes #400)

Completes the remaining items from #400.

  - `langgraph_dev_host` now defaults to 0.0.0.0, matching `webui_host`.
    Remote WebUI use needs both anyway (the UI reaches the backend from the
    browser, not server-side), so a loopback backend default just meant every
    remote user hit a silently failing UI. `_DEFAULT_HOST` and sdk's
    `DEFAULT_LANGGRAPH_DEV_HOST` follow, so there is one story about where
    these servers listen.

    SECURITY: this exposes an unauthenticated API whose agent can run shell
    commands. The red PUBLIC BIND banner consequently fires on every launch
    while exposed — kept deliberately, since the exposure is real and the
    escape hatch (`--host 127.0.0.1` / `config set langgraph_dev_host`) is
    only discoverable if we say so. READMEs now lead with the warning and
    document the SSH-tunnel alternative.

  - `EvoSci --host <ip>` on the WebUI launch path, driving both servers. In
    WebUI mode they are two halves of one surface; moving only one leaves the
    UI loading but unable to reach the agent. Blank values are dropped rather
    than written as an empty override that would beat the config file.

  - Onboarding wizard no longer prints hard-coded `http://127.0.0.1:{port}` /
    `http://localhost:{port}` (steps.py:160, :223) — both render the
    configured bind through `_base_url` / `_format_hostport`, so a pinned
    interface is reported honestly and a wildcard still shows loopback.

Verified against a live server: with no host argument at all, resolution
through EvoScientistConfig yields a socket listening on 0.0.0.0, a client URL
of http://127.0.0.1, and the warning gate returning True.

Still open and tracked separately: the front-end takes its backend URL from
browser input: `@evoscientist/webui` reads only HOSTNAME, PORT and
EVOSCIENTIST_LANGGRAPH_DEV_PORT, so advertising a backend URL needs a change
in that repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: bump setup-uv v6 -> v9.0.0 to drop the deprecated node20 runtime

GitHub now warns that setup-uv@v6 targets Node.js 20 and is being forced
onto Node.js 24. v7.0.0 is the release that made that switch, so anything
>= v7 clears the warning; v9.0.0 is current.

Pinned to the full tag deliberately: setup-uv stopped publishing major and
minor tags in v8.0.0 as supply-chain hardening, so `@v9` and `@v8` return
404 and would fail the job outright. Releases are immutable from v8 on, so
the full tag is as tamper-proof as a SHA. Comment left in lint.yml because
"simplifying" this back to `@v9` is an easy and CI-breaking mistake.

actions/checkout@v5 is already node24 and needs no change.

Note: v9.0.0 flips the `prune-cache` default to false (upstream did this to
ease load on PyPI infrastructure). None of these workflows set it, so they
follow the new default and Actions cache usage may grow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): correct --host help text and warn on public bind in non-WebUI modes

The --host help claimed "WebUI mode only", which is wrong in a way that
matters for security. `--host` writes `langgraph_dev_host` unconditionally,
and `_ensure_async_subagent_server` auto-starts that backend for tui / cli /
serve as well — the langgraph dev server is shared across UI modes. So the
flag narrows or widens the agent API in every mode, and only `webui_host` is
actually WebUI-specific. Reported against cli/commands.py.

The documentation error hid a real gap: the PUBLIC BIND banner lived only in
deploy/server.py and deploy/webui.py, so a plain `EvoSci` session bound
0.0.0.0 with no runtime signal whatsoever — and `--help` is opt-in, so
fixing the text alone would not surface it. Added the same banner to the
shared CLI path, gated on `is_async_subagents_available()`: ensure_langgraph_dev
fails soft (async degrades to in-process delegation), and warning about a
bind that never happened would be worse than staying quiet.

READMEs (EN + zh-CN) get the same correction — the warning block sat inside
the Desktop WebUI section and read as WebUI-scoped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deploy): strip the config-derived bind host, not just the CLI one

`deploy()` only stripped the `--host` branch. When the flag was omitted,
`getattr(config, "langgraph_dev_host", ...)` flowed unstripped into
`_is_port_occupied`, `is_langgraph_dev_running`, `start_langgraph_dev` and
the banner. `run_webui` already strips unconditionally; this aligns the two.

Reachable because `deploy()` reads through `getattr` and is routinely handed
duck-typed config objects (tests, embedders) that never run
`EvoScientistConfig.__post_init__`, which is what normally normalizes these
fields.

Worst case was not just a bad bind: `_is_loopback_host(" 127.0.0.1 ")` is
False, so a padded loopback value would print a false PUBLIC BIND warning
while binding a string socket.bind() rejects outright — a security banner
saying the opposite of the truth.

Three regression tests added, each verified to fail against the old code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: apply ruff format to the bind-host changes

The Lint workflow runs both `ruff check` and `ruff format --check`; I had
only been running the former locally, so five files landed unformatted and
failed CI. Whitespace and line-wrapping only — no semantic change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): keep the langgraph dev backend on loopback by default

The backend is an unauthenticated API whose agent can run shell commands,
and it is auto-started in every UI mode (tui/cli/webui/serve/deploy) — so a
0.0.0.0 default put it on the network for users who never asked. Restore
127.0.0.1 as the default and make 0.0.0.0 an explicit opt-in.

webui_host keeps its 0.0.0.0 default: the front-end serves the app shell
only and holds no credentials. run_webui already prints a remote-backend
hint when the front-end is exposed and the backend is not.

Help text and both READMEs are reframed around widening rather than
narrowing; the escape-hatch tests are inverted to assert the public-bind
opt-in survives into argv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:15:49 +08:00

550 lines
20 KiB
Python

"""Tests for ``EvoSci deploy`` command flow.
Verifies the orchestration:
- workspace resolution (CLI > config > cwd)
- port resolution (CLI > config > default)
- host resolution (CLI > config > default) + the public-bind warning
- port collision pre-flight
- ccproxy lifecycle (only if OAuth configured)
- ``start_langgraph_dev(deploy_mode=True)`` invocation
- clean shutdown via signal handler / KeyboardInterrupt
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
import typer
from EvoScientist.deploy import server as deploy_server
def _make_config(
*,
default_workdir: str = "",
langgraph_dev_port: int = 6174,
langgraph_dev_host: str = "127.0.0.1",
anthropic_auth_mode: str = "api_key",
openai_auth_mode: str = "api_key",
log_level: str = "warning",
langgraph_dev_jobs_per_worker: int = 10,
langgraph_dev_file_persistence: bool = True,
dangerous_mode: bool = False,
):
return SimpleNamespace(
default_workdir=default_workdir,
langgraph_dev_port=langgraph_dev_port,
langgraph_dev_host=langgraph_dev_host,
anthropic_auth_mode=anthropic_auth_mode,
openai_auth_mode=openai_auth_mode,
log_level=log_level,
langgraph_dev_jobs_per_worker=langgraph_dev_jobs_per_worker,
langgraph_dev_file_persistence=langgraph_dev_file_persistence,
dangerous_mode=dangerous_mode,
)
class _ImmediateEvent:
"""Fake ``threading.Event``: first ``is_set()`` returns False so the
while-loop body runs once; subsequent calls return True, exiting the
loop. ``wait`` is a no-op (no real blocking)."""
def __init__(self):
self._called = 0
self.set_was_called = False
def is_set(self) -> bool:
self._called += 1
return self._called > 1
def wait(self, timeout: float | None = None):
return None
def set(self):
self.set_was_called = True
self._called = 99
def _run_deploy_once(
monkeypatch,
config,
*,
workdir: str | None = None,
port: int | None = None,
host: str | None = None,
debug: bool = False,
cwd: str | None = None,
port_occupied: bool = False,
langgraph_dev_running: bool = True, # health-check passes after start
tunnel: bool = False,
tunnel_url: str | None = None,
):
"""Run ``deploy()`` end-to-end with all external dependencies mocked.
Returns a ``captured`` dict with observation points."""
import EvoScientist.config as config_mod
captured: dict[str, Any] = {
"ccproxy_started": False,
"ccproxy_stopped": False,
"langgraph_dev_started": False,
"langgraph_dev_stopped": False,
"deploy_mode_passed": None,
"workspace_passed": None,
"port_passed": None,
"host_passed": None,
"printed": [],
"atexit_callbacks": [],
}
def _fake_get_effective_config(cli_overrides=None):
captured["cli_overrides"] = dict(cli_overrides or {})
merged = vars(config).copy()
merged.update(cli_overrides or {})
return SimpleNamespace(**merged)
monkeypatch.setattr(config_mod, "get_effective_config", _fake_get_effective_config)
monkeypatch.setattr(config_mod, "apply_config_to_env", lambda _cfg: None)
monkeypatch.setattr(deploy_server, "console", _SilentConsole(captured["printed"]))
# Workspace setup mocks
from EvoScientist import paths as paths_mod
monkeypatch.setattr(paths_mod, "set_workspace_root", lambda _p: None)
monkeypatch.setattr(paths_mod, "ensure_dirs", lambda: None)
# langgraph_dev.manager mocks
from EvoScientist.langgraph_dev import manager as lgm
monkeypatch.setattr(lgm, "_is_port_occupied", lambda _p, *_a, **_kw: port_occupied)
monkeypatch.setattr(
lgm,
"is_langgraph_dev_running",
lambda **_kw: langgraph_dev_running,
)
def _fake_start_langgraph_dev(
workspace_dir=None,
*,
port=None,
host=None,
file_persistence=True,
jobs_per_worker=10,
deploy_mode=False,
tunnel=False,
):
captured["langgraph_dev_started"] = True
captured["workspace_passed"] = str(workspace_dir) if workspace_dir else None
captured["port_passed"] = port
captured["host_passed"] = host
captured["deploy_mode_passed"] = deploy_mode
captured["jobs_per_worker_passed"] = jobs_per_worker
captured["file_persistence_passed"] = file_persistence
captured["tunnel_passed"] = tunnel
return SimpleNamespace(pid=99999)
def _fake_stop_langgraph_dev(_proc=None):
captured["langgraph_dev_stopped"] = True
monkeypatch.setattr(lgm, "start_langgraph_dev", _fake_start_langgraph_dev)
monkeypatch.setattr(lgm, "stop_langgraph_dev", _fake_stop_langgraph_dev)
# Never poll a real log for the tunnel URL in tests.
monkeypatch.setattr(
lgm, "read_tunnel_url", lambda *a, **k: tunnel_url, raising=False
)
# ccproxy mocks
from EvoScientist import ccproxy_manager as ccp
def _fake_maybe_start_ccproxy(_cfg):
captured["ccproxy_started"] = True
return SimpleNamespace(pid=88888)
def _fake_stop_ccproxy(_proc):
captured["ccproxy_stopped"] = True
monkeypatch.setattr(ccp, "maybe_start_ccproxy", _fake_maybe_start_ccproxy)
monkeypatch.setattr(ccp, "stop_ccproxy", _fake_stop_ccproxy)
# atexit mock — capture without executing (don't pollute test process)
import atexit
def _fake_atexit_register(fn, *args, **kwargs):
captured["atexit_callbacks"].append((fn.__name__, args, kwargs))
return fn
monkeypatch.setattr(atexit, "register", _fake_atexit_register)
# signal mock — capture handlers so tests can exercise _handle_shutdown.
# Returning a no-op original means deploy()'s finally block restores
# something harmless onto the real signal module.
import signal
# deploy() calls signal.signal twice per signum: first to install
# _handle_shutdown, then in finally to restore the original. We want the
# first (real) handler — keep first-write-wins semantics.
captured["signal_handlers"] = {}
def _capture_signal(signum, handler):
if signum not in captured["signal_handlers"]:
captured["signal_handlers"][signum] = handler
return lambda *_a, **_kw: None
monkeypatch.setattr(signal, "signal", _capture_signal)
# threading.Event mock — exits the wait loop after one iteration;
# factory captures the instance so tests can inspect set() calls.
import threading
def _make_event():
ev = _ImmediateEvent()
captured["event_instance"] = ev
return ev
monkeypatch.setattr(threading, "Event", _make_event)
# os.makedirs / os.getcwd
import os
monkeypatch.setattr(os, "makedirs", lambda *a, **k: None)
if cwd is not None:
monkeypatch.setattr(os, "getcwd", lambda: cwd)
deploy_server.deploy(
workdir=workdir, port=port, host=host, debug=debug, tunnel=tunnel
)
return captured
class _SilentConsole:
"""Stand-in for the Rich console — swallows all output so test runs
don't spew ANSI to the captured pytest output (but doesn't break the
code paths that call ``console.print`` / ``console.status``).
Optionally records what was printed so tests can assert on banners
(e.g. the public-bind warning) without letting them reach the terminal.
"""
def __init__(self, sink: list | None = None):
self._sink = sink
def print(self, *args, **kwargs):
if self._sink is not None:
self._sink.append(" ".join(str(a) for a in args))
def status(self, *args, **kwargs):
class _Ctx:
def __enter__(self_inner):
return self_inner
def __exit__(self_inner, *a):
return False
return _Ctx()
# =============================================================================
# Tests
# =============================================================================
def test_deploy_starts_langgraph_dev_with_deploy_mode_true(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config)
assert captured["langgraph_dev_started"] is True
assert captured["deploy_mode_passed"] is True, (
"deploy command MUST call start_langgraph_dev with deploy_mode=True"
)
def test_deploy_tunnel_default_off(monkeypatch, tmp_path):
"""Without ``--tunnel``, start_langgraph_dev is called with tunnel=False."""
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config)
assert captured["tunnel_passed"] is False
def test_deploy_tunnel_flag_passed_through(monkeypatch, tmp_path):
"""``--tunnel`` propagates to start_langgraph_dev(tunnel=True)."""
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(
monkeypatch,
config,
tunnel=True,
tunnel_url="https://demo-xyz.trycloudflare.com",
)
assert captured["tunnel_passed"] is True
def test_deploy_workdir_cli_arg_beats_config(monkeypatch, tmp_path):
cli_ws = tmp_path / "cli_ws"
cfg_ws = tmp_path / "cfg_ws"
config = _make_config(default_workdir=str(cfg_ws))
captured = _run_deploy_once(monkeypatch, config, workdir=str(cli_ws))
assert captured["workspace_passed"] == str(cli_ws)
def test_deploy_workdir_config_beats_cwd(monkeypatch, tmp_path):
cfg_ws = tmp_path / "cfg_ws"
config = _make_config(default_workdir=str(cfg_ws))
captured = _run_deploy_once(monkeypatch, config, cwd="/tmp/should_not_be_used")
assert captured["workspace_passed"] == str(cfg_ws)
def test_deploy_workdir_falls_back_to_cwd(monkeypatch, tmp_path):
config = _make_config(default_workdir="")
cwd = str(tmp_path / "cwd")
captured = _run_deploy_once(monkeypatch, config, cwd=cwd)
assert captured["workspace_passed"] == cwd
def test_deploy_port_cli_arg_beats_config(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_port=6174)
captured = _run_deploy_once(monkeypatch, config, port=7000)
assert captured["port_passed"] == 7000
def test_deploy_port_defaults_to_config(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_port=6543)
captured = _run_deploy_once(monkeypatch, config)
assert captured["port_passed"] == 6543
def test_deploy_host_cli_arg_beats_config(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host="127.0.0.1")
captured = _run_deploy_once(monkeypatch, config, host="192.168.1.5")
assert captured["host_passed"] == "192.168.1.5"
def test_deploy_host_defaults_to_config(monkeypatch, tmp_path):
config = _make_config(
default_workdir=str(tmp_path), langgraph_dev_host="192.168.1.5"
)
captured = _run_deploy_once(monkeypatch, config)
assert captured["host_passed"] == "192.168.1.5"
def test_deploy_host_falls_back_when_config_lacks_field(monkeypatch, tmp_path):
"""A config object missing the field entirely still resolves to the
module default rather than passing None down to socket.bind()."""
config = _make_config(default_workdir=str(tmp_path))
del config.langgraph_dev_host
captured = _run_deploy_once(monkeypatch, config)
assert captured["host_passed"] == "127.0.0.1"
def test_deploy_host_whitespace_collapses_to_default(monkeypatch, tmp_path):
"""``--host " "`` would reach socket.bind() as an empty string and raise
an opaque gaierror; it must degrade to the default instead."""
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config, host=" ")
assert captured["host_passed"] == "127.0.0.1"
def test_deploy_host_public_opt_in(monkeypatch, tmp_path):
"""``--host 0.0.0.0`` must actually widen the bind even though the config
default keeps the unauthenticated backend on loopback."""
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host="127.0.0.1")
captured = _run_deploy_once(monkeypatch, config, host="0.0.0.0")
assert captured["host_passed"] == "0.0.0.0"
def test_deploy_host_is_stripped(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config, host=" 0.0.0.0 ")
assert captured["host_passed"] == "0.0.0.0"
def test_deploy_config_host_is_stripped(monkeypatch, tmp_path):
"""Stripping must not depend on the value arriving via --host.
``EvoScientistConfig.__post_init__`` normalizes these fields, but deploy()
reads through ``getattr`` and is handed duck-typed config objects that
never run it — an unstripped value would reach socket.bind()."""
config = _make_config(
default_workdir=str(tmp_path), langgraph_dev_host=" 192.168.1.5 "
)
captured = _run_deploy_once(monkeypatch, config)
assert captured["host_passed"] == "192.168.1.5"
def test_deploy_whitespace_config_host_collapses_to_default(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host=" ")
captured = _run_deploy_once(monkeypatch, config)
assert captured["host_passed"] == "127.0.0.1"
def test_deploy_padded_loopback_config_host_suppresses_warning(monkeypatch, tmp_path):
"""Consequence of the unstripped path: `_is_loopback_host` would not match
" 127.0.0.1 ", so a padded loopback config would print a false PUBLIC BIND
warning while binding a value socket.bind() rejects outright."""
config = _make_config(
default_workdir=str(tmp_path), langgraph_dev_host=" 127.0.0.1 "
)
captured = _run_deploy_once(monkeypatch, config)
assert captured["host_passed"] == "127.0.0.1"
assert not any("PUBLIC BIND" in line for line in captured["printed"])
@pytest.mark.parametrize("exposed_host", ["0.0.0.0", "192.168.1.5", "::"])
def test_deploy_warns_on_public_bind(monkeypatch, tmp_path, exposed_host):
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config, host=exposed_host)
assert any("PUBLIC BIND" in line for line in captured["printed"]), (
f"binding {exposed_host} reaches other machines and MUST warn"
)
@pytest.mark.parametrize("loopback_host", ["127.0.0.1", "::1", "localhost"])
def test_deploy_no_warning_on_loopback_bind(monkeypatch, tmp_path, loopback_host):
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config, host=loopback_host)
assert not any("PUBLIC BIND" in line for line in captured["printed"]), (
f"{loopback_host} is unreachable off-box — warning would be noise"
)
@pytest.mark.parametrize("bad_port", [0, -1, 70000])
def test_deploy_refuses_invalid_port(monkeypatch, tmp_path, bad_port):
"""CLI must reject out-of-range ports (port=0 was the original silent-fail
case: ``port or default`` treated 0 as falsy)."""
config = _make_config(default_workdir=str(tmp_path))
with pytest.raises(typer.Exit) as exc:
_run_deploy_once(monkeypatch, config, port=bad_port)
assert exc.value.exit_code == 1
def test_deploy_no_ccproxy_when_api_key_auth(monkeypatch, tmp_path):
config = _make_config(
default_workdir=str(tmp_path),
anthropic_auth_mode="api_key",
openai_auth_mode="api_key",
)
captured = _run_deploy_once(monkeypatch, config)
assert captured["ccproxy_started"] is False
def test_deploy_starts_ccproxy_when_anthropic_oauth(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path), anthropic_auth_mode="oauth")
captured = _run_deploy_once(monkeypatch, config)
assert captured["ccproxy_started"] is True
def test_deploy_starts_ccproxy_when_openai_oauth(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path), openai_auth_mode="oauth")
captured = _run_deploy_once(monkeypatch, config)
assert captured["ccproxy_started"] is True
def test_deploy_refuses_when_port_occupied_by_evosci(monkeypatch, tmp_path):
"""If port is occupied AND it's serving an EvoSci langgraph dev,
refuse with exit code 1."""
config = _make_config(default_workdir=str(tmp_path))
with pytest.raises(typer.Exit) as exc:
_run_deploy_once(
monkeypatch,
config,
port_occupied=True,
langgraph_dev_running=True, # /ok responds → existing EvoSci instance
)
assert exc.value.exit_code == 1
def test_deploy_refuses_when_port_occupied_by_foreign(monkeypatch, tmp_path):
"""If port is occupied but /ok doesn't respond, treat as foreign process
and refuse."""
config = _make_config(default_workdir=str(tmp_path))
with pytest.raises(typer.Exit) as exc:
_run_deploy_once(
monkeypatch,
config,
port_occupied=True,
langgraph_dev_running=False, # foreign process holds the port
)
assert exc.value.exit_code == 1
def test_deploy_registers_cleanup_for_langgraph_dev(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config)
names = [name for (name, _args, _kw) in captured["atexit_callbacks"]]
assert "_fake_stop_langgraph_dev" in names, (
"deploy must register stop_langgraph_dev via atexit for clean shutdown"
)
def test_deploy_registers_cleanup_for_ccproxy_when_oauth(monkeypatch, tmp_path):
config = _make_config(default_workdir=str(tmp_path), anthropic_auth_mode="oauth")
captured = _run_deploy_once(monkeypatch, config)
names = [name for (name, _args, _kw) in captured["atexit_callbacks"]]
assert "_fake_stop_ccproxy" in names
def test_deploy_registers_signal_handlers_for_shutdown(monkeypatch, tmp_path):
"""deploy() must register SIGINT and SIGTERM handlers so external signals
trigger clean shutdown via the shutdown_event wait loop."""
import signal
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config)
handlers = captured["signal_handlers"]
assert signal.SIGINT in handlers, "deploy must register a SIGINT handler"
assert signal.SIGTERM in handlers, "deploy must register a SIGTERM handler"
assert callable(handlers[signal.SIGINT])
assert callable(handlers[signal.SIGTERM])
def test_handle_shutdown_sigterm_sets_shutdown_event(monkeypatch, tmp_path):
"""Invoking the captured SIGTERM handler must set deploy()'s shutdown_event.
SIGTERM is used (not SIGINT) because the SIGINT branch of _handle_shutdown
calls signal.default_int_handler which raises KeyboardInterrupt — that
would terminate the test process rather than exercise the event path.
"""
import signal
config = _make_config(default_workdir=str(tmp_path))
captured = _run_deploy_once(monkeypatch, config)
event = captured["event_instance"]
assert event is not None, "deploy() must have constructed a threading.Event"
# During the normal helper run the wait loop exits via is_set() flipping
# to True (not via set()), so set_was_called should still be False here.
assert event.set_was_called is False, (
"Sanity check: helper's _ImmediateEvent should exit naturally without "
"set() being called; if this fails, the helper changed behavior."
)
sigterm_handler = captured["signal_handlers"][signal.SIGTERM]
sigterm_handler(signal.SIGTERM, None)
assert event.set_was_called is True, (
"_handle_shutdown(SIGTERM, None) must call shutdown_event.set()"
)