Files
hermes-agent/tests/tools/test_browser_lightpanda_serve.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

373 lines
15 KiB
Python

"""Tests for tools/browser_lightpanda.py — the ``lightpanda serve`` launcher
Browser Use mode uses when ``browser.engine`` is ``lightpanda``."""
import json
import os
import stat
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
import tools.browser_lightpanda as lp
# The autouse _isolate fixture swaps _binary_supports_http_cache for a lambda;
# the probe test needs the real (lru_cache-wrapped) function back.
_real_probe = lp._binary_supports_http_cache
class FakeProc:
def __init__(self, pid=4242, exit_code=None):
self.pid = pid
self._rc = exit_code
self.terminated = False
self.killed = False
def poll(self):
return self._rc
def terminate(self):
self.terminated = True
self._rc = -15
def kill(self):
self.killed = True
self._rc = -9
def wait(self, timeout=None):
return self._rc
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
state = tmp_path / "state"
state.mkdir()
monkeypatch.setattr(lp, "_state_dir", lambda: state)
# Never touch the developer's real ~/.local/bin/lightpanda.
monkeypatch.setattr(lp, "_home_candidates", lambda: [])
monkeypatch.setattr(lp, "_safe_start_time", lambda pid: 111)
lp._binary_supports_http_cache.cache_clear()
monkeypatch.setattr(lp, "_binary_supports_http_cache", lambda binary: True)
with lp._servers_lock:
lp._servers.clear()
yield state
with lp._servers_lock:
lp._servers.clear()
def _exe(path):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)
return path
class TestFindBinary:
@pytest.mark.platforms("posix")
def test_prefers_path(self, tmp_path, monkeypatch):
exe = _exe(tmp_path / "bin" / "lightpanda")
monkeypatch.setenv("PATH", str(tmp_path / "bin"))
monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda p: p)
assert lp.find_lightpanda_binary() == str(exe)
@pytest.mark.platforms("posix")
def test_falls_back_to_home_candidates(self, tmp_path, monkeypatch):
monkeypatch.setenv("PATH", str(tmp_path / "empty"))
monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda p: p)
exe = _exe(tmp_path / ".lightpanda" / "lightpanda")
monkeypatch.setattr(lp, "_home_candidates", lambda: [exe])
assert lp.find_lightpanda_binary() == str(exe)
def test_none_when_absent(self, tmp_path, monkeypatch):
monkeypatch.setenv("PATH", str(tmp_path / "empty"))
monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda p: p)
assert lp.find_lightpanda_binary() is None
@pytest.mark.platforms("windows")
def test_none_on_windows(self):
assert lp.find_lightpanda_binary() is None
class TestLaunch:
def _launch(self, monkeypatch, *, proc=None, ready=True, stderr=b"", **kw):
calls = {}
def fake_popen(argv, **kwargs):
calls["argv"] = argv
calls["kwargs"] = kwargs
if stderr:
kwargs["stderr"].write(stderr)
kwargs["stderr"].flush()
return proc or FakeProc()
monkeypatch.setattr(lp, "find_lightpanda_binary", lambda: "/opt/lightpanda")
monkeypatch.setattr(lp, "_pick_free_loopback_port", lambda: 43111)
monkeypatch.setattr(lp, "_cdp_ready", lambda url: ready)
monkeypatch.setattr(lp, "_browser_env", lambda: {"PATH": "/usr/bin"})
monkeypatch.setattr(lp.subprocess, "Popen", fake_popen)
server, err = lp.launch_lightpanda("lp_test", **kw)
return server, err, calls
@pytest.mark.platforms("posix")
def test_missing_binary_returns_install_hint(self, monkeypatch):
monkeypatch.setattr(lp, "find_lightpanda_binary", lambda: None)
server, err = lp.launch_lightpanda("lp_test")
assert server is None
assert "browser.engine" in err
assert lp.LIGHTPANDA_INSTALL_URL in err
def test_spawns_serve_on_loopback_without_timeout_flag(self, monkeypatch, _isolate):
server, err, calls = self._launch(monkeypatch)
assert err is None
assert calls["argv"] == [
"/opt/lightpanda", "serve", "--host", "127.0.0.1", "--port", "43111",
"--http-cache-dir", str(_isolate / "http-cache"),
]
kw = calls["kwargs"]
assert kw["stdin"] is subprocess.DEVNULL
assert kw["stdout"] is subprocess.DEVNULL
assert hasattr(kw["stderr"], "write") # a log file, never a pipe
assert kw["env"] == {"PATH": "/usr/bin"}
if os.name != "nt":
assert kw["start_new_session"] is True
assert server.cdp_url == "http://127.0.0.1:43111"
assert server.start_time == 111
assert lp.get_server("lp_test") is server
record = json.loads((_isolate / "lp_test.json").read_text(encoding="utf-8"))
assert record["pid"] == 4242
assert record["port"] == 43111
assert record["owner_pid"] == os.getpid()
assert record["start_time"] == 111
def test_http_cache_dir_is_shared_across_sessions(self, monkeypatch, _isolate):
_, _, first = self._launch(monkeypatch)
with lp._servers_lock:
lp._servers.clear()
_, _, second = self._launch(monkeypatch)
cache = str(_isolate / "http-cache")
assert first["argv"][first["argv"].index("--http-cache-dir") + 1] == cache
assert second["argv"][second["argv"].index("--http-cache-dir") + 1] == cache
assert Path(cache).is_dir()
assert not list(Path(cache).glob("*.json")) # never confused with a session record
def test_no_http_cache_flag_on_old_binary(self, monkeypatch, _isolate):
monkeypatch.setattr(lp, "_binary_supports_http_cache", lambda binary: False)
_, err, calls = self._launch(monkeypatch)
assert err is None
assert "--http-cache-dir" not in calls["argv"]
assert calls["argv"][-1] == "43111"
def test_http_cache_probe_caches_and_detects_flag(self, monkeypatch, tmp_path):
monkeypatch.setattr(lp, "_binary_supports_http_cache", _real_probe)
_real_probe.cache_clear()
exe = _exe(tmp_path / "lightpanda")
runs = []
def fake_run(argv, **kwargs):
runs.append(argv)
return subprocess.CompletedProcess(
argv, returncode=0,
stdout="--http-cache-dir <PATH>" if len(runs) == 1 else "",
)
monkeypatch.setattr(lp.subprocess, "run", fake_run)
assert lp._binary_supports_http_cache(str(exe)) is True
assert lp._binary_supports_http_cache(str(exe)) is True # cached, single probe
assert len(runs) == 1
lp._binary_supports_http_cache.cache_clear()
def fake_run_old(argv, **kwargs):
return subprocess.CompletedProcess(argv, returncode=0, stdout="no such flag")
monkeypatch.setattr(lp.subprocess, "run", fake_run_old)
assert lp._binary_supports_http_cache(str(exe)) is False
def fake_run_hangs(argv, **kwargs):
raise subprocess.TimeoutExpired(cmd=argv, timeout=3.0)
lp._binary_supports_http_cache.cache_clear()
monkeypatch.setattr(lp.subprocess, "run", fake_run_hangs)
assert lp._binary_supports_http_cache(str(exe)) is False
def test_block_private_networks_flag(self, monkeypatch):
_, err, calls = self._launch(monkeypatch, block_private_networks=True)
assert err is None
assert calls["argv"][-1] == "--block-private-networks"
def test_early_exit_reports_stderr_tail(self, monkeypatch):
server, err, _ = self._launch(
monkeypatch, proc=FakeProc(exit_code=1), ready=False,
stderr=b"info: starting\nFATAL app : unknown argument --bogus\n",
)
assert server is None
assert "exited with code 1" in err
assert "unknown argument --bogus" in err
assert lp.get_server("lp_test") is None
def test_ready_timeout_terminates_child(self, monkeypatch, _isolate):
monkeypatch.setattr(lp, "_READY_TIMEOUT_S", 0.05)
monkeypatch.setattr(lp, "_POLL_INTERVAL_S", 0.01)
proc = FakeProc()
server, err, _ = self._launch(monkeypatch, proc=proc, ready=False)
assert server is None
assert "did not expose" in err
assert proc.terminated is True
assert not (_isolate / "lp_test.json").exists()
assert lp.get_server("lp_test") is None
def test_spawn_failure_is_reported(self, monkeypatch):
monkeypatch.setattr(lp, "find_lightpanda_binary", lambda: "/opt/lightpanda")
monkeypatch.setattr(lp, "_pick_free_loopback_port", lambda: 1)
monkeypatch.setattr(lp, "_browser_env", lambda: {})
def boom(*a, **k):
raise OSError("exec format error")
monkeypatch.setattr(lp.subprocess, "Popen", boom)
server, err = lp.launch_lightpanda("lp_test")
assert server is None
assert "exec format error" in err
class TestStop:
def _seed(self, _isolate, name="lp_test", alive=True):
proc = FakeProc(pid=777, exit_code=None if alive else 0)
server = lp.LightpandaServer(name, 43111, proc, str(_isolate / f"{name}.log"), 111)
lp._write_record(server)
with lp._servers_lock:
lp._servers[name] = server
return server
def test_stop_uses_tree_kill_with_expected_start_and_removes_record(self, _isolate):
server = self._seed(_isolate)
killed = []
with patch(
"tools.process_registry.ProcessRegistry._terminate_host_pid",
side_effect=lambda pid, expected_start=None: killed.append((pid, expected_start)),
):
lp.stop_lightpanda("lp_test")
assert killed == [(777, 111)]
assert lp.get_server("lp_test") is None
assert not (_isolate / "lp_test.json").exists()
assert server.proc.terminated is False # tree-kill handled it
def test_stop_falls_back_to_terminate_when_tree_kill_fails(self, _isolate):
server = self._seed(_isolate)
with patch(
"tools.process_registry.ProcessRegistry._terminate_host_pid",
side_effect=RuntimeError("psutil missing"),
):
lp.stop_lightpanda("lp_test")
assert server.proc.terminated is True
def test_stop_dead_server_just_drops_record(self, _isolate):
self._seed(_isolate, alive=False)
with patch("tools.process_registry.ProcessRegistry._terminate_host_pid") as kill:
lp.stop_lightpanda("lp_test")
kill.assert_not_called()
assert not (_isolate / "lp_test.json").exists()
def test_stop_unknown_session_is_noop(self):
lp.stop_lightpanda("lp_nope") # must not raise
def test_stop_all(self, _isolate):
self._seed(_isolate, "lp_a")
self._seed(_isolate, "lp_b")
with patch("tools.process_registry.ProcessRegistry._terminate_host_pid") as kill:
lp.stop_all_lightpanda()
assert kill.call_count == 2
assert lp.get_server("lp_a") is None and lp.get_server("lp_b") is None
class TestReapOrphans:
def _record(self, _isolate, name, *, pid=999, owner_pid=1, port=43111, start_time=111):
(_isolate / f"{name}.json").write_text(
json.dumps({"pid": pid, "port": port, "owner_pid": owner_pid,
"start_time": start_time, "started_at": 0}),
encoding="utf-8",
)
return _isolate / f"{name}.json"
def test_live_other_owner_is_skipped(self, _isolate):
rec = self._record(_isolate, "lp_x", owner_pid=12345)
with patch("gateway.status._pid_exists", return_value=True), \
patch("tools.process_registry.ProcessRegistry._terminate_host_pid") as kill:
assert lp.reap_orphaned_lightpanda() == 0
kill.assert_not_called()
assert rec.exists()
def test_own_tracked_session_is_skipped(self, _isolate):
rec = self._record(_isolate, "lp_x", owner_pid=os.getpid())
with lp._servers_lock:
lp._servers["lp_x"] = lp.LightpandaServer("lp_x", 43111, FakeProc(), "", 111)
with patch("tools.process_registry.ProcessRegistry._terminate_host_pid") as kill:
assert lp.reap_orphaned_lightpanda() == 0
kill.assert_not_called()
assert rec.exists()
def test_dead_owner_verified_process_is_killed(self, _isolate):
rec = self._record(_isolate, "lp_x", owner_pid=12345, pid=999)
with patch("gateway.status._pid_exists", return_value=False), \
patch.object(lp, "_is_lightpanda_process", return_value=True), \
patch("tools.process_registry.ProcessRegistry._terminate_host_pid") as kill:
assert lp.reap_orphaned_lightpanda() == 1
kill.assert_called_once_with(999, expected_start=111)
assert not rec.exists()
def test_own_untracked_session_is_reaped(self, _isolate):
"""Owner alive but lost its in-memory tracking: reap, don't leak."""
self._record(_isolate, "lp_x", owner_pid=os.getpid(), pid=999)
with patch.object(lp, "_is_lightpanda_process", return_value=True), \
patch("tools.process_registry.ProcessRegistry._terminate_host_pid") as kill:
assert lp.reap_orphaned_lightpanda() == 1
kill.assert_called_once()
def test_unverified_pid_is_never_signalled(self, _isolate):
rec = self._record(_isolate, "lp_x", owner_pid=12345, pid=999)
with patch("gateway.status._pid_exists", return_value=False), \
patch.object(lp, "_is_lightpanda_process", return_value=False), \
patch("tools.process_registry.ProcessRegistry._terminate_host_pid") as kill:
assert lp.reap_orphaned_lightpanda() == 0
kill.assert_not_called()
assert not rec.exists()
def test_corrupt_record_is_removed(self, _isolate):
rec = _isolate / "lp_bad.json"
rec.write_text("{not json", encoding="utf-8")
assert lp.reap_orphaned_lightpanda() == 0
assert not rec.exists()
class TestProcessIdentity:
class _P:
def __init__(self, name, cmdline):
self._name, self._cmdline = name, cmdline
def name(self):
return self._name
def cmdline(self):
return self._cmdline
def test_matches_name_port_and_start_time(self):
proc = self._P("lightpanda", ["/opt/lightpanda", "serve", "--host", "127.0.0.1", "--port", "43111"])
with patch("psutil.Process", return_value=proc), \
patch("gateway.status.get_process_start_time", return_value=111):
assert lp._is_lightpanda_process(999, 43111, 111) is True
with patch("psutil.Process", return_value=proc), \
patch("gateway.status.get_process_start_time", return_value=222):
assert lp._is_lightpanda_process(999, 43111, 111) is False
def test_rejects_other_process_on_recycled_pid(self):
proc = self._P("chrome", ["chrome", "--remote-debugging-port=43111"])
with patch("psutil.Process", return_value=proc):
assert lp._is_lightpanda_process(999, 43111, None) is False
def test_rejects_lightpanda_on_other_port(self):
proc = self._P("lightpanda", ["lightpanda", "serve", "--port", "1"])
with patch("psutil.Process", return_value=proc):
assert lp._is_lightpanda_process(999, 43111, None) is False