[web_server] Embed the interface and warn for AP only WiFi instead of hinting in the page

This commit is contained in:
J. Nick Koston
2026-08-19 13:42:52 -05:00
parent 9df8505c8d
commit 3b8b90b2cd
2 changed files with 140 additions and 118 deletions
+78 -63
View File
@@ -1,94 +1,109 @@
"""Tests for the web_server component index page builder and validation."""
"""Tests for web_server component helpers."""
import logging
import pytest
from esphome.components.web_server import (
_final_validate_ap_only_hosted_ui,
build_index_html,
_final_validate_ap_only,
serve_local,
wifi_is_ap_only,
)
from esphome.const import (
CONF_AP,
CONF_CSS_URL,
CONF_JS_URL,
CONF_LOCAL,
CONF_MANUAL_IP,
CONF_NETWORKS,
CONF_SSID,
CONF_STATIC_IP,
CONF_VERSION,
CONF_WIFI,
)
import esphome.final_validate as fv
JS_URL = "https://oi.esphome.io/v2/www.js"
def test_build_index_html_has_offline_hint() -> None:
"""A hidden hint is shown after a timeout unless www.js registered esp-app."""
html = build_index_html({CONF_JS_URL: JS_URL, CONF_CSS_URL: ""})
assert "<style>esp-app:defined+p{display:none}</style>" in html
assert "<esp-app></esp-app><p hidden>The web interface is not loading." in html
assert "local: true" in html
assert "<script>setTimeout(function(){document.querySelector('p').hidden=0}" in html
# The hint script must come before the hosted script, which may never finish loading
assert html.index("setTimeout") < html.index(f'<script src="{JS_URL}">')
def test_build_index_html_hint_disabled() -> None:
"""Plain page when the hint is not wanted (captive_portal)."""
html = build_index_html({CONF_JS_URL: JS_URL, CONF_CSS_URL: ""}, offline_hint=False)
assert f'<esp-app></esp-app><script src="{JS_URL}"></script>' in html
assert "<p" not in html
assert "<style>" not in html
def test_build_index_html_without_js_url() -> None:
"""No hosted script and no hint when js_url is empty."""
html = build_index_html({CONF_JS_URL: "", CONF_CSS_URL: ""})
assert "<script" not in html
assert "<p" not in html
AP_ONLY = {CONF_AP: {}}
AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}
STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]}
@pytest.mark.parametrize(
("web_server_config", "full_config", "expect_warning"),
("wifi_config", "expected"),
[(AP_ONLY, True), (AP_FALLBACK, False), (STA_ONLY, False), (None, False)],
)
def test_wifi_is_ap_only(wifi_config: dict | None, expected: bool) -> None:
assert wifi_is_ap_only(wifi_config) is expected
@pytest.mark.parametrize(
("web_server_config", "wifi_config", "expected"),
[
# AP only with a hosted page: warn.
({CONF_JS_URL: JS_URL}, {CONF_WIFI: {CONF_AP: {}}}, True),
# local: true serves the page from the device.
({CONF_JS_URL: JS_URL, CONF_LOCAL: True}, {CONF_WIFI: {CONF_AP: {}}}, False),
# No hosted script at all.
({CONF_JS_URL: ""}, {CONF_WIFI: {CONF_AP: {}}}, False),
# captive_portal serves its own local page.
(
{CONF_JS_URL: JS_URL},
{CONF_WIFI: {CONF_AP: {}}, "captive_portal": {}},
False,
),
# STA with AP fallback: the AP is rarely used, stay quiet.
(
{CONF_JS_URL: JS_URL},
{CONF_WIFI: {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}},
False,
),
# No AP, or no wifi at all (ethernet).
(
{CONF_JS_URL: JS_URL},
{CONF_WIFI: {CONF_NETWORKS: [{CONF_SSID: "x"}]}},
False,
),
({CONF_JS_URL: JS_URL}, {}, False),
# AP only: embed the interface, the AP has no internet.
({CONF_VERSION: 2}, AP_ONLY, True),
({CONF_VERSION: 3}, AP_ONLY, True),
# Explicit setting always wins.
({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, True),
# AP fallback, no AP, no wifi, or version 1 (no local mode): hosted page.
({CONF_VERSION: 2}, AP_FALLBACK, False),
({CONF_VERSION: 2}, STA_ONLY, False),
({CONF_VERSION: 2}, None, False),
({CONF_VERSION: 1}, AP_ONLY, False),
],
)
def test_final_validate_ap_only_hosted_ui_warning(
def test_serve_local(
web_server_config: dict, wifi_config: dict | None, expected: bool
) -> None:
"""The interface is embedded for AP only WiFi unless local is set explicitly."""
assert serve_local(web_server_config, wifi_config) is expected
@pytest.mark.parametrize(
("web_server_config", "full_config", "expected"),
[
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "http://192.168.4.1/"),
(
{CONF_VERSION: 2},
{CONF_WIFI: {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}}},
"http://10.0.0.1/",
),
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "not a captive portal"),
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "embedded in the firmware"),
({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, "stays blank"),
({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, None),
({CONF_VERSION: 2}, {CONF_WIFI: STA_ONLY}, None),
({CONF_VERSION: 2}, {}, None),
],
)
def test_final_validate_ap_only_warning(
web_server_config: dict,
full_config: dict,
expect_warning: bool,
expected: str | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""AP only configs with a hosted page get a hint to use local: true."""
"""AP only WiFi with web_server warns and names the AP address; others stay quiet."""
token = fv.full_config.set({"web_server": web_server_config, **full_config})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_only_hosted_ui(web_server_config)
_final_validate_ap_only(web_server_config)
finally:
fv.full_config.reset(token)
assert ("AP only" in caplog.text) is expect_warning
if expected is None:
assert "AP only" not in caplog.text
else:
assert expected in caplog.text
def test_final_validate_ap_only_with_captive_portal(
caplog: pytest.LogCaptureFixture,
) -> None:
"""With captive_portal the AP is captive, so that clause is left out."""
token = fv.full_config.set(
{"web_server": {CONF_VERSION: 2}, CONF_WIFI: AP_ONLY, "captive_portal": {}}
)
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_only({CONF_VERSION: 2})
finally:
fv.full_config.reset(token)
assert "AP only" in caplog.text
assert "captive portal" not in caplog.text