mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[core] Add esphome logs over web_server HTTP SSE (#17110)
This commit is contained in:
@@ -14,7 +14,7 @@ import pytest
|
||||
from esphome import helpers
|
||||
from esphome.address_cache import AddressCache
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import ProgressBar
|
||||
from esphome.helpers import ProgressBar, format_ip_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host):
|
||||
assert actual is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("family", "sockaddr", "expected"),
|
||||
(
|
||||
(socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"),
|
||||
(socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"),
|
||||
(
|
||||
socket.AF_INET6,
|
||||
("fe80::1", 8080, 0, 7),
|
||||
"http://[fe80::1%257]:8080/events",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_format_ip_url(family, sockaddr, expected):
|
||||
assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected
|
||||
|
||||
|
||||
@settings(deadline=None)
|
||||
@given(value=ip_addresses(v=4).map(str))
|
||||
def test_is_ip_address__valid(value):
|
||||
|
||||
@@ -52,6 +52,7 @@ from esphome.__main__ import (
|
||||
has_non_ip_address,
|
||||
has_ota,
|
||||
has_resolvable_address,
|
||||
has_web_server_logging,
|
||||
has_web_server_ota,
|
||||
mqtt_get_ip,
|
||||
parse_args,
|
||||
@@ -80,6 +81,7 @@ from esphome.const import (
|
||||
CONF_DISABLED,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
@@ -94,6 +96,7 @@ from esphome.const import (
|
||||
CONF_TOPIC,
|
||||
CONF_USE_ADDRESS,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
KEY_CORE,
|
||||
@@ -816,6 +819,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non
|
||||
assert result == ["192.168.1.100"]
|
||||
|
||||
|
||||
def test_choose_upload_log_host_logging_web_server_only_ip() -> None:
|
||||
"""A web_server-only device with a static IP resolves to that IP for logs."""
|
||||
setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100")
|
||||
|
||||
result = choose_upload_log_host(
|
||||
default="OTA",
|
||||
check_default=None,
|
||||
purpose=Purpose.LOGGING,
|
||||
)
|
||||
assert result == ["192.168.1.100"]
|
||||
|
||||
|
||||
def test_choose_upload_log_host_logging_web_server_only_mdns() -> None:
|
||||
"""A web_server-only device with a .local name resolves to that hostname."""
|
||||
setup_core(config={CONF_WEB_SERVER: {}}, address="test.local")
|
||||
|
||||
result = choose_upload_log_host(
|
||||
default="OTA",
|
||||
check_default=None,
|
||||
purpose=Purpose.LOGGING,
|
||||
)
|
||||
assert result == ["test.local"]
|
||||
|
||||
|
||||
def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None:
|
||||
"""A resolvable device with only ota: fails logs with a missing-api message."""
|
||||
setup_core(
|
||||
@@ -855,6 +882,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None:
|
||||
assert "set 'use_address'" in msg
|
||||
|
||||
|
||||
def test_unresolved_default_error_logging_suggests_web_server() -> None:
|
||||
"""The missing-api log message lists web_server among the remediations."""
|
||||
setup_core(
|
||||
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
|
||||
)
|
||||
|
||||
msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"])
|
||||
assert "no 'api:' component is configured" in msg
|
||||
assert "'web_server:'" in msg
|
||||
|
||||
|
||||
def test_unresolved_default_error_upload_with_ota_is_generic() -> None:
|
||||
"""With ota: present the upload error stays generic, not transport-specific."""
|
||||
setup_core(
|
||||
@@ -2534,6 +2572,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None:
|
||||
assert has_ota() is True
|
||||
|
||||
|
||||
def test_has_web_server_logging_default() -> None:
|
||||
"""has_web_server_logging is True for a default web_server (v2, log on)."""
|
||||
setup_core(config={CONF_WEB_SERVER: {}})
|
||||
assert has_web_server_logging() is True
|
||||
|
||||
|
||||
def test_has_web_server_logging_without_config() -> None:
|
||||
"""has_web_server_logging is False when web_server is not configured."""
|
||||
setup_core(config={CONF_API: {}})
|
||||
assert has_web_server_logging() is False
|
||||
|
||||
|
||||
def test_has_web_server_logging_v1_has_no_events_stream() -> None:
|
||||
"""has_web_server_logging is False for v1, which has no /events endpoint."""
|
||||
setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}})
|
||||
assert has_web_server_logging() is False
|
||||
|
||||
|
||||
def test_has_web_server_logging_respects_log_disabled() -> None:
|
||||
"""has_web_server_logging is False when the web_server log option is off."""
|
||||
setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}})
|
||||
assert has_web_server_logging() is False
|
||||
|
||||
|
||||
def test_upload_program_web_server_only_auto_dispatches(
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_run_ota: Mock,
|
||||
@@ -3102,6 +3164,77 @@ def test_show_logs_network_with_mqtt_only(
|
||||
)
|
||||
|
||||
|
||||
@patch("esphome.web_server_logs.run_logs")
|
||||
def test_show_logs_web_server(
|
||||
mock_run_logs: Mock,
|
||||
) -> None:
|
||||
"""A web_server-only device streams logs over the HTTP SSE endpoint."""
|
||||
setup_core(
|
||||
config={
|
||||
"logger": {},
|
||||
CONF_WEB_SERVER: {CONF_PORT: 80},
|
||||
# No API or MQTT configured
|
||||
},
|
||||
platform=PLATFORM_ESP32,
|
||||
)
|
||||
mock_run_logs.return_value = 0
|
||||
|
||||
result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"])
|
||||
|
||||
assert result == 0
|
||||
mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None)
|
||||
|
||||
|
||||
@patch("esphome.web_server_logs.run_logs")
|
||||
def test_show_logs_web_server_with_auth_and_port(
|
||||
mock_run_logs: Mock,
|
||||
) -> None:
|
||||
"""web_server port and basic-auth credentials are forwarded to the streamer."""
|
||||
setup_core(
|
||||
config={
|
||||
"logger": {},
|
||||
CONF_WEB_SERVER: {
|
||||
CONF_PORT: 8080,
|
||||
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"},
|
||||
},
|
||||
},
|
||||
platform=PLATFORM_ESP32,
|
||||
)
|
||||
mock_run_logs.return_value = 0
|
||||
|
||||
result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"])
|
||||
|
||||
assert result == 0
|
||||
mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret")
|
||||
|
||||
|
||||
@patch("esphome.web_server_logs.run_logs")
|
||||
@patch("esphome.mqtt.show_logs")
|
||||
def test_show_logs_mqtt_preferred_over_web_server(
|
||||
mock_mqtt_show_logs: Mock,
|
||||
mock_run_logs: Mock,
|
||||
) -> None:
|
||||
"""With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server)."""
|
||||
setup_core(
|
||||
config={
|
||||
"logger": {},
|
||||
"mqtt": {CONF_BROKER: "mqtt.local"},
|
||||
CONF_WEB_SERVER: {CONF_PORT: 80},
|
||||
},
|
||||
platform=PLATFORM_ESP32,
|
||||
)
|
||||
mock_mqtt_show_logs.return_value = 0
|
||||
|
||||
args = MockArgs(
|
||||
topic="esphome/logs", username="user", password="pass", client_id="client"
|
||||
)
|
||||
result = show_logs(CORE.config, args, ["192.168.1.100"])
|
||||
|
||||
assert result == 0
|
||||
mock_mqtt_show_logs.assert_called_once()
|
||||
mock_run_logs.assert_not_called()
|
||||
|
||||
|
||||
def test_show_logs_no_method_configured() -> None:
|
||||
"""Test show_logs when no remote logging method is configured."""
|
||||
setup_core(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Unit tests for esphome.web_server_helpers module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.const import (
|
||||
CONF_AUTH,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.web_server_helpers import (
|
||||
get_web_server_connection,
|
||||
resolve_web_server_urls,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_web_server_urls_maps_ipv4_and_ipv6(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Each resolved address becomes an (ip, url) pair with IPv6 bracketing."""
|
||||
addr_infos = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)),
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"esphome.web_server_helpers.resolve_ip_address",
|
||||
lambda *args, **kwargs: addr_infos,
|
||||
)
|
||||
|
||||
assert resolve_web_server_urls("dev.local", 80, "/events") == [
|
||||
("192.168.1.5", "http://192.168.1.5:80/events"),
|
||||
("fe80::1", "http://[fe80::1%257]:80/events"),
|
||||
]
|
||||
|
||||
|
||||
def test_get_web_server_connection_without_auth() -> None:
|
||||
"""Port is returned and credentials are None when no auth is configured."""
|
||||
config = {CONF_WEB_SERVER: {CONF_PORT: 80}}
|
||||
|
||||
assert get_web_server_connection(config) == (80, None, None)
|
||||
|
||||
|
||||
def test_get_web_server_connection_with_auth() -> None:
|
||||
"""Port and HTTP Basic credentials are returned when auth is configured."""
|
||||
config = {
|
||||
CONF_WEB_SERVER: {
|
||||
CONF_PORT: 8080,
|
||||
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"},
|
||||
}
|
||||
}
|
||||
|
||||
assert get_web_server_connection(config) == (8080, "admin", "secret")
|
||||
|
||||
|
||||
def test_get_web_server_connection_missing_component() -> None:
|
||||
"""A config without web_server raises a clear error."""
|
||||
with pytest.raises(EsphomeError, match="web_server.*not configured"):
|
||||
get_web_server_connection({})
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Unit tests for esphome.web_server_logs module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
import logging
|
||||
import socket
|
||||
from typing import Self
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from esphome import web_server_logs
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.web_server_logs import (
|
||||
EVENTS_PATH,
|
||||
WebServerLogsError,
|
||||
_build_urls,
|
||||
_consume,
|
||||
_stream,
|
||||
run_logs,
|
||||
)
|
||||
|
||||
# A realistic slice of the web_server /events SSE stream: an initial ping
|
||||
# carrying the config, a state frame, two log frames (one multi-line), plus
|
||||
# comment/id/retry lines that must be ignored.
|
||||
SSE_LINES = [
|
||||
"retry: 30000",
|
||||
"id: 12345",
|
||||
"event: ping",
|
||||
'data: {"title":"dev","log":true}',
|
||||
"",
|
||||
"event: state",
|
||||
'data: {"id":"sensor-x","state":"ON"}',
|
||||
"",
|
||||
"event: log",
|
||||
"data: \x1b[0;32m[I][main:001]: hello\x1b[0m",
|
||||
"",
|
||||
": keepalive-comment",
|
||||
"event: log",
|
||||
"data: line one",
|
||||
"data: line two",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for a streamed ``requests`` response."""
|
||||
|
||||
def __init__(self, status_code: int, lines: list[str]) -> None:
|
||||
self.status_code = status_code
|
||||
self._lines = lines
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
def iter_lines(self) -> Iterator[bytes]:
|
||||
for line in self._lines:
|
||||
yield line.encode("utf8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_parser() -> MagicMock:
|
||||
"""A LogParser whose parse_line returns the raw line unchanged."""
|
||||
parser = MagicMock()
|
||||
parser.parse_line.side_effect = lambda line, time_str: line
|
||||
return parser
|
||||
|
||||
|
||||
def _patch_resolve(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
addr_infos: list[tuple[int, int, int, str, tuple]],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"esphome.web_server_helpers.resolve_ip_address",
|
||||
lambda *args, **kwargs: addr_infos,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_urls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An IPv4 host resolves to a plain http://ip:port/events URL."""
|
||||
_patch_resolve(
|
||||
monkeypatch,
|
||||
[(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))],
|
||||
)
|
||||
|
||||
assert _build_urls(["dev.local"], 80) == [
|
||||
("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}")
|
||||
]
|
||||
|
||||
|
||||
def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""IPv6 literals are bracketed; link-local addresses get a %25 zone index."""
|
||||
_patch_resolve(
|
||||
monkeypatch,
|
||||
[(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))],
|
||||
)
|
||||
|
||||
assert _build_urls(["dev.local"], 8080) == [
|
||||
("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}")
|
||||
]
|
||||
|
||||
|
||||
def test_build_urls_dedups_and_skips_unresolvable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Duplicate resolved IPs collapse to one URL; resolve errors are skipped."""
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]:
|
||||
calls.append(host)
|
||||
if host == "bad":
|
||||
raise EsphomeError("nope")
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))]
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve)
|
||||
|
||||
# "good" and "dup" both resolve to 10.0.0.1, "bad" raises.
|
||||
assert _build_urls(["good", "bad", "dup"], 80) == [
|
||||
("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}")
|
||||
]
|
||||
assert calls == ["good", "bad", "dup"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _consume (SSE parsing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_consume_emits_only_log_frames(
|
||||
monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock
|
||||
) -> None:
|
||||
"""Only event: log data lines are printed; ping/state/comments are ignored."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
|
||||
|
||||
_consume(_FakeResponse(200, SSE_LINES), fake_parser)
|
||||
|
||||
assert printed == [
|
||||
"\x1b[0;32m[I][main:001]: hello\x1b[0m",
|
||||
"line one",
|
||||
"line two",
|
||||
]
|
||||
|
||||
|
||||
def test_consume_ignores_unterminated_trailing_frame(
|
||||
monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock
|
||||
) -> None:
|
||||
"""A log frame without its terminating blank line is not emitted."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
|
||||
|
||||
_consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser)
|
||||
|
||||
assert printed == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stream_returns_false_when_connect_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fake_parser: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A failed connection logs a warning and reports not-connected."""
|
||||
|
||||
def boom(*args: object, **kwargs: object) -> _FakeResponse:
|
||||
raise requests.ConnectionError("refused")
|
||||
|
||||
monkeypatch.setattr(requests, "get", boom)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert (
|
||||
_stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False
|
||||
)
|
||||
assert "Could not connect to 10.0.0.1" in caplog.text
|
||||
|
||||
|
||||
def test_stream_returns_true_when_established_then_dropped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fake_parser: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A mid-stream drop after connecting reports connected so we reconnect."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
|
||||
|
||||
class _DroppingResponse(_FakeResponse):
|
||||
def iter_lines(self) -> Iterator[bytes]:
|
||||
yield b"event: log"
|
||||
yield b"data: before-drop"
|
||||
yield b""
|
||||
raise requests.exceptions.ChunkedEncodingError("connection lost")
|
||||
|
||||
monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, []))
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert (
|
||||
_stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True
|
||||
)
|
||||
assert printed == ["before-drop"]
|
||||
assert "reconnecting" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_logs_streams_then_reconnects_until_interrupt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A dropped stream reconnects; KeyboardInterrupt during the pause exits 0."""
|
||||
monkeypatch.setattr(
|
||||
web_server_logs,
|
||||
"_build_urls",
|
||||
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
|
||||
)
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
|
||||
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES))
|
||||
|
||||
def stop(_delay: float) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(web_server_logs.time, "sleep", stop)
|
||||
|
||||
assert run_logs(["dev.local"], 80, None, None) == 0
|
||||
# The single stream was consumed before the reconnect pause interrupted us.
|
||||
# run_logs renders through the real LogParser, which prefixes a timestamp,
|
||||
# so assert on the payloads rather than exact equality.
|
||||
assert len(printed) == 3
|
||||
assert "[I][main:001]: hello" in printed[0]
|
||||
assert "line one" in printed[1]
|
||||
assert "line two" in printed[2]
|
||||
|
||||
|
||||
def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Username + password are forwarded as HTTP Basic auth on the request."""
|
||||
monkeypatch.setattr(
|
||||
web_server_logs,
|
||||
"_build_urls",
|
||||
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
|
||||
)
|
||||
monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_get(url: str, **kwargs: object) -> _FakeResponse:
|
||||
captured.update(kwargs)
|
||||
captured["url"] = url
|
||||
return _FakeResponse(200, SSE_LINES)
|
||||
|
||||
monkeypatch.setattr(requests, "get", fake_get)
|
||||
monkeypatch.setattr(
|
||||
web_server_logs.time,
|
||||
"sleep",
|
||||
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||
)
|
||||
|
||||
assert run_logs(["dev.local"], 80, "admin", "secret") == 0
|
||||
auth = captured["auth"]
|
||||
assert isinstance(auth, HTTPBasicAuth)
|
||||
assert (auth.username, auth.password) == ("admin", "secret")
|
||||
assert captured["stream"] is True
|
||||
assert captured["headers"] == {"Accept": "text/event-stream"}
|
||||
|
||||
|
||||
def test_run_logs_no_auth_when_credentials_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No auth object is sent when username/password are not configured."""
|
||||
monkeypatch.setattr(
|
||||
web_server_logs,
|
||||
"_build_urls",
|
||||
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
|
||||
)
|
||||
monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_get(url: str, **kwargs: object) -> _FakeResponse:
|
||||
captured.update(kwargs)
|
||||
return _FakeResponse(200, SSE_LINES)
|
||||
|
||||
monkeypatch.setattr(requests, "get", fake_get)
|
||||
monkeypatch.setattr(
|
||||
web_server_logs.time,
|
||||
"sleep",
|
||||
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||
)
|
||||
|
||||
assert run_logs(["dev.local"], 80, None, None) == 0
|
||||
assert captured["auth"] is None
|
||||
|
||||
|
||||
def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""HTTP 401 aborts with a clear error rather than reconnecting forever."""
|
||||
monkeypatch.setattr(
|
||||
web_server_logs,
|
||||
"_build_urls",
|
||||
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
|
||||
)
|
||||
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, []))
|
||||
|
||||
with pytest.raises(WebServerLogsError, match="Authentication failed"):
|
||||
run_logs(["dev.local"], 80, "admin", "bad")
|
||||
|
||||
|
||||
def test_run_logs_retries_on_transient_status(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A transient non-200 (e.g. 503) is logged and the loop retries."""
|
||||
monkeypatch.setattr(
|
||||
web_server_logs,
|
||||
"_build_urls",
|
||||
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
|
||||
)
|
||||
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, []))
|
||||
monkeypatch.setattr(
|
||||
web_server_logs.time,
|
||||
"sleep",
|
||||
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
assert run_logs(["dev.local"], 80, None, None) == 0
|
||||
assert "Unexpected HTTP 503" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", (403, 404))
|
||||
def test_run_logs_raises_on_permanent_status(
|
||||
monkeypatch: pytest.MonkeyPatch, status: int
|
||||
) -> None:
|
||||
"""A permanent 403/404 aborts instead of retrying the endpoint forever."""
|
||||
monkeypatch.setattr(
|
||||
web_server_logs,
|
||||
"_build_urls",
|
||||
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
|
||||
)
|
||||
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, []))
|
||||
|
||||
with pytest.raises(WebServerLogsError, match=str(status)):
|
||||
run_logs(["dev.local"], 80, None, None)
|
||||
|
||||
|
||||
def test_run_logs_backs_off_on_repeated_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Consecutive unreachable attempts grow the reconnect delay up to the cap."""
|
||||
monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: [])
|
||||
delays: list[float] = []
|
||||
|
||||
def record(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
if len(delays) >= 4:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(web_server_logs.time, "sleep", record)
|
||||
|
||||
assert run_logs(["dev.local"], 80, None, None) == 0
|
||||
# 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0).
|
||||
assert delays == [2.0, 4.0, 8.0, 10.0]
|
||||
|
||||
|
||||
def test_run_logs_reports_unresolvable(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""When no host resolves, an error is logged and the loop pauses/retries."""
|
||||
monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: [])
|
||||
|
||||
# Let the first reconnect pause pass so the loop continues, then interrupt
|
||||
# on the second so the retry path (the ``continue``) is exercised.
|
||||
sleeps = {"n": 0}
|
||||
|
||||
def sleep(_delay: float) -> None:
|
||||
sleeps["n"] += 1
|
||||
if sleeps["n"] >= 2:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(web_server_logs.time, "sleep", sleep)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
assert run_logs(["dev.local"], 80, None, None) == 0
|
||||
assert sleeps["n"] == 2
|
||||
assert "Could not resolve" in caplog.text
|
||||
@@ -46,7 +46,7 @@ def _patch_resolve(
|
||||
for host, port in hosts
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
|
||||
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
|
||||
)
|
||||
|
||||
|
||||
@@ -475,7 +475,7 @@ def test_run_ota_resolution_failure(
|
||||
def _raise(*_args, **_kwargs):
|
||||
raise EsphomeError("dns failed")
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
|
||||
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise)
|
||||
|
||||
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
|
||||
|
||||
@@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode(
|
||||
def _raise(*_args, **_kwargs):
|
||||
raise EsphomeError("dns failed")
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
|
||||
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise)
|
||||
monkeypatch.setattr(CORE, "dashboard", True)
|
||||
try:
|
||||
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
|
||||
@@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails(
|
||||
def _resolve(host, port, address_cache=None): # noqa: ARG001
|
||||
return addr_lookup[host]
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
|
||||
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve)
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
@@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception(
|
||||
def _resolve(host, port, address_cache=None): # noqa: ARG001
|
||||
return addr_lookup[host]
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
|
||||
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve)
|
||||
|
||||
exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware)
|
||||
|
||||
@@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host(
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
|
||||
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id(
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
|
||||
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
|
||||
)
|
||||
|
||||
with patch(
|
||||
|
||||
Reference in New Issue
Block a user