mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 11:07:33 +00:00
[dashboard] Remove legacy web dashboard (#17124)
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
import pathlib
|
||||
|
||||
|
||||
def get_fixture_path(filename: str) -> pathlib.Path:
|
||||
"""Get path of fixture."""
|
||||
return pathlib.Path(__file__).parent.joinpath("fixtures", filename)
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Common fixtures for dashboard tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from esphome.dashboard.core import ESPHomeDashboard
|
||||
from esphome.dashboard.entries import DashboardEntries
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(tmp_path: Path) -> MagicMock:
|
||||
"""Create mock dashboard settings."""
|
||||
settings = MagicMock()
|
||||
settings.config_dir = str(tmp_path)
|
||||
settings.absolute_config_dir = tmp_path
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dashboard(mock_settings: MagicMock) -> Mock:
|
||||
"""Create a mock dashboard."""
|
||||
dashboard = Mock(spec=ESPHomeDashboard)
|
||||
dashboard.settings = mock_settings
|
||||
dashboard.entries = Mock()
|
||||
dashboard.entries.async_all.return_value = []
|
||||
dashboard.stop_event = Mock()
|
||||
dashboard.stop_event.is_set.return_value = True
|
||||
dashboard.ping_request = Mock()
|
||||
dashboard.ignored_devices = set()
|
||||
dashboard.bus = Mock()
|
||||
dashboard.bus.async_fire = Mock()
|
||||
return dashboard
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries:
|
||||
"""Create a DashboardEntries instance for testing."""
|
||||
return DashboardEntries(mock_dashboard)
|
||||
@@ -1,47 +0,0 @@
|
||||
substitutions:
|
||||
name: picoproxy
|
||||
friendly_name: Pico Proxy
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
project:
|
||||
name: esphome.bluetooth-proxy
|
||||
version: "1.0"
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
|
||||
api:
|
||||
logger:
|
||||
ota:
|
||||
improv_serial:
|
||||
|
||||
dashboard_import:
|
||||
package_import_url: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main
|
||||
|
||||
button:
|
||||
- platform: factory_reset
|
||||
id: resetf
|
||||
- platform: safe_mode
|
||||
name: Safe Mode Boot
|
||||
entity_category: diagnostic
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: pm11
|
||||
name: "pm 1.0µm"
|
||||
lambda: return 1.0;
|
||||
- platform: template
|
||||
id: pm251
|
||||
name: "pm 2.5µm"
|
||||
lambda: return 2.5;
|
||||
- platform: template
|
||||
id: pm101
|
||||
name: "pm 10µm"
|
||||
lambda: return 10;
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Unit tests for esphome.dashboard.dns module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from icmplib import NameLookupError
|
||||
import pytest
|
||||
|
||||
from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dns_cache_fixture() -> DNSCache:
|
||||
"""Create a DNSCache instance."""
|
||||
return DNSCache()
|
||||
|
||||
|
||||
def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses when hostname is not in cache."""
|
||||
now = time.monotonic()
|
||||
result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses when cache entry is expired."""
|
||||
now = time.monotonic()
|
||||
# Add entry that's already expired
|
||||
dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"])
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result is None
|
||||
# Expired entry should still be in cache (not removed by get_cached_addresses)
|
||||
assert "example.com" in dns_cache_fixture._cache
|
||||
|
||||
|
||||
def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses with valid cache entry."""
|
||||
now = time.monotonic()
|
||||
# Add entry that expires in 60 seconds
|
||||
dns_cache_fixture._cache["example.com"] = (
|
||||
now + 60,
|
||||
["192.168.1.10", "192.168.1.11"],
|
||||
)
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result == ["192.168.1.10", "192.168.1.11"]
|
||||
# Entry should still be in cache
|
||||
assert "example.com" in dns_cache_fixture._cache
|
||||
|
||||
|
||||
def test_get_cached_addresses_hostname_normalization(
|
||||
dns_cache_fixture: DNSCache,
|
||||
) -> None:
|
||||
"""Test get_cached_addresses normalizes hostname."""
|
||||
now = time.monotonic()
|
||||
# Add entry with lowercase hostname
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"])
|
||||
|
||||
# Test with various forms
|
||||
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [
|
||||
"192.168.1.10"
|
||||
]
|
||||
assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [
|
||||
"192.168.1.10"
|
||||
]
|
||||
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [
|
||||
"192.168.1.10"
|
||||
]
|
||||
|
||||
|
||||
def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses with IPv6 addresses."""
|
||||
now = time.monotonic()
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"])
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result == ["2001:db8::1", "fe80::1"]
|
||||
|
||||
|
||||
def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses with empty address list."""
|
||||
now = time.monotonic()
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, [])
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test get_cached_addresses when cache contains an exception."""
|
||||
now = time.monotonic()
|
||||
# Store an exception (from failed resolution)
|
||||
dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed"))
|
||||
|
||||
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||
assert result is None # Should return None for exceptions
|
||||
|
||||
|
||||
def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None:
|
||||
"""Test that get_cached_addresses never calls async_resolve."""
|
||||
now = time.monotonic()
|
||||
|
||||
with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve:
|
||||
# Test non-cached
|
||||
result = dns_cache_fixture.get_cached_addresses("uncached.com", now)
|
||||
assert result is None
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
# Test expired
|
||||
dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"])
|
||||
result = dns_cache_fixture.get_cached_addresses("expired.com", now)
|
||||
assert result is None
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
# Test valid
|
||||
dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"])
|
||||
result = dns_cache_fixture.get_cached_addresses("valid.com", now)
|
||||
assert result == ["192.168.1.10"]
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resolve_wrapper_ip_address() -> None:
|
||||
"""Test _async_resolve_wrapper returns IP address directly."""
|
||||
result = await _async_resolve_wrapper("192.168.1.10")
|
||||
assert result == ["192.168.1.10"]
|
||||
|
||||
result = await _async_resolve_wrapper("2001:db8::1")
|
||||
assert result == ["2001:db8::1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resolve_wrapper_local_fallback_success() -> None:
|
||||
"""Test _async_resolve_wrapper falls back to bare hostname for .local."""
|
||||
mock_resolve = AsyncMock()
|
||||
# First call (device.local) fails, second call (device) succeeds
|
||||
mock_resolve.side_effect = [
|
||||
NameLookupError("device.local"),
|
||||
["192.168.1.50"],
|
||||
]
|
||||
|
||||
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
|
||||
result = await _async_resolve_wrapper("device.local")
|
||||
|
||||
assert result == ["192.168.1.50"]
|
||||
assert mock_resolve.call_count == 2
|
||||
mock_resolve.assert_any_call("device.local")
|
||||
mock_resolve.assert_any_call("device")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resolve_wrapper_local_fallback_both_fail() -> None:
|
||||
"""Test _async_resolve_wrapper returns exception when both fail."""
|
||||
mock_resolve = AsyncMock()
|
||||
original_exception = NameLookupError("device.local")
|
||||
mock_resolve.side_effect = [
|
||||
original_exception,
|
||||
NameLookupError("device"),
|
||||
]
|
||||
|
||||
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
|
||||
result = await _async_resolve_wrapper("device.local")
|
||||
|
||||
# Should return the original exception, not the fallback exception
|
||||
assert result is original_exception
|
||||
assert mock_resolve.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resolve_wrapper_non_local_no_fallback() -> None:
|
||||
"""Test _async_resolve_wrapper doesn't fallback for non-.local hostnames."""
|
||||
mock_resolve = AsyncMock()
|
||||
original_exception = NameLookupError("device.example.com")
|
||||
mock_resolve.side_effect = original_exception
|
||||
|
||||
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
|
||||
result = await _async_resolve_wrapper("device.example.com")
|
||||
|
||||
assert result is original_exception
|
||||
# Should only try the original hostname, no fallback
|
||||
assert mock_resolve.call_count == 1
|
||||
mock_resolve.assert_called_once_with("device.example.com")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resolve_wrapper_local_success_no_fallback() -> None:
|
||||
"""Test _async_resolve_wrapper doesn't fallback when .local succeeds."""
|
||||
mock_resolve = AsyncMock(return_value=["192.168.1.50"])
|
||||
|
||||
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
|
||||
result = await _async_resolve_wrapper("device.local")
|
||||
|
||||
assert result == ["192.168.1.50"]
|
||||
# Should only try once since it succeeded
|
||||
assert mock_resolve.call_count == 1
|
||||
mock_resolve.assert_called_once_with("device.local")
|
||||
@@ -1,240 +0,0 @@
|
||||
"""Unit tests for esphome.dashboard.status.mdns module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from zeroconf import AddressResolver, IPVersion
|
||||
|
||||
from esphome.dashboard.const import DashboardEvent
|
||||
from esphome.dashboard.status.mdns import MDNSStatus
|
||||
from esphome.zeroconf import DiscoveredImport
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mdns_status(mock_dashboard: Mock) -> MDNSStatus:
|
||||
"""Create an MDNSStatus instance in async context."""
|
||||
# We're in an async context so get_running_loop will work
|
||||
return MDNSStatus(mock_dashboard)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses when no zeroconf instance is available."""
|
||||
mdns_status.aiozc = None
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses when address is not in cache."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = False
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result is None
|
||||
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses when address is found in cache."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result == ["192.168.1.10", "fe80::1"]
|
||||
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
|
||||
mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses with hostname having trailing dot."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local.")
|
||||
assert result == ["192.168.1.10"]
|
||||
# Should normalize to device.local. for zeroconf
|
||||
mock_resolver.assert_called_once_with("device.local.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses with uppercase hostname."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("DEVICE.LOCAL")
|
||||
assert result == ["192.168.1.10"]
|
||||
# Should normalize to device.local. for zeroconf
|
||||
mock_resolver.assert_called_once_with("device.local.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses with simple hostname (no domain)."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device")
|
||||
assert result == ["192.168.1.10"]
|
||||
# Should append .local. for zeroconf
|
||||
mock_resolver.assert_called_once_with("device.local.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses returning only IPv6 addresses."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"]
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result == ["fe80::1", "2001:db8::1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None:
|
||||
"""Test get_cached_addresses returning empty list from cache."""
|
||||
mdns_status.aiozc = Mock()
|
||||
mdns_status.aiozc.zeroconf = Mock()
|
||||
|
||||
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||
mock_info = Mock(spec=AddressResolver)
|
||||
mock_info.load_from_cache.return_value = True
|
||||
mock_info.parsed_scoped_addresses.return_value = []
|
||||
mock_resolver.return_value = mock_info
|
||||
|
||||
result = mdns_status.get_cached_addresses("device.local")
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_setup_success(mock_dashboard: Mock) -> None:
|
||||
"""Test successful async_setup."""
|
||||
mdns_status = MDNSStatus(mock_dashboard)
|
||||
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
|
||||
mock_zc.return_value = Mock()
|
||||
result = mdns_status.async_setup()
|
||||
assert result is True
|
||||
assert mdns_status.aiozc is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_setup_failure(mock_dashboard: Mock) -> None:
|
||||
"""Test async_setup with OSError."""
|
||||
mdns_status = MDNSStatus(mock_dashboard)
|
||||
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
|
||||
mock_zc.side_effect = OSError("Network error")
|
||||
result = mdns_status.async_setup()
|
||||
assert result is False
|
||||
assert mdns_status.aiozc is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None:
|
||||
"""Test _on_import_update when a device is added."""
|
||||
# Create a DiscoveredImport object
|
||||
discovered = DiscoveredImport(
|
||||
device_name="test_device",
|
||||
friendly_name="Test Device",
|
||||
package_import_url="https://example.com/package",
|
||||
project_name="test_project",
|
||||
project_version="1.0.0",
|
||||
network="wifi",
|
||||
)
|
||||
|
||||
# Call _on_import_update with a device
|
||||
mdns_status._on_import_update("test_device", discovered)
|
||||
|
||||
# Should fire IMPORTABLE_DEVICE_ADDED event
|
||||
mock_dashboard = mdns_status.dashboard
|
||||
mock_dashboard.bus.async_fire.assert_called_once()
|
||||
call_args = mock_dashboard.bus.async_fire.call_args
|
||||
assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED
|
||||
assert "device" in call_args[0][1]
|
||||
device_data = call_args[0][1]["device"]
|
||||
assert device_data["name"] == "test_device"
|
||||
assert device_data["friendly_name"] == "Test Device"
|
||||
assert device_data["project_name"] == "test_project"
|
||||
assert device_data["ignored"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None:
|
||||
"""Test _on_import_update when a device is ignored."""
|
||||
# Add device to ignored list
|
||||
mdns_status.dashboard.ignored_devices.add("ignored_device")
|
||||
|
||||
# Create a DiscoveredImport object for ignored device
|
||||
discovered = DiscoveredImport(
|
||||
device_name="ignored_device",
|
||||
friendly_name="Ignored Device",
|
||||
package_import_url="https://example.com/package",
|
||||
project_name="test_project",
|
||||
project_version="1.0.0",
|
||||
network="ethernet",
|
||||
)
|
||||
|
||||
# Call _on_import_update with an ignored device
|
||||
mdns_status._on_import_update("ignored_device", discovered)
|
||||
|
||||
# Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True
|
||||
mock_dashboard = mdns_status.dashboard
|
||||
mock_dashboard.bus.async_fire.assert_called_once()
|
||||
call_args = mock_dashboard.bus.async_fire.call_args
|
||||
assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED
|
||||
device_data = call_args[0][1]["device"]
|
||||
assert device_data["name"] == "ignored_device"
|
||||
assert device_data["ignored"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None:
|
||||
"""Test _on_import_update when a device is removed."""
|
||||
# Call _on_import_update with None (device removed)
|
||||
mdns_status._on_import_update("removed_device", None)
|
||||
|
||||
# Should fire IMPORTABLE_DEVICE_REMOVED event
|
||||
mdns_status.dashboard.bus.async_fire.assert_called_once_with(
|
||||
DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"}
|
||||
)
|
||||
@@ -1,288 +0,0 @@
|
||||
"""Tests for dashboard entries Path-related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
from esphome.dashboard.const import DashboardEvent
|
||||
from esphome.dashboard.entries import DashboardEntries, DashboardEntry
|
||||
|
||||
|
||||
def create_cache_key() -> tuple[int, int, float, int]:
|
||||
"""Helper to create a valid DashboardCacheKeyType."""
|
||||
return (0, 0, 0.0, 0)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_core():
|
||||
"""Set up CORE for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
CORE.config_path = Path(tmpdir) / "test.yaml"
|
||||
yield
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_dashboard_entry_path_initialization() -> None:
|
||||
"""Test DashboardEntry initializes with path correctly."""
|
||||
test_path = Path("/test/config/device.yaml")
|
||||
cache_key = create_cache_key()
|
||||
|
||||
entry = DashboardEntry(test_path, cache_key)
|
||||
|
||||
assert entry.path == test_path
|
||||
assert entry.cache_key == cache_key
|
||||
|
||||
|
||||
def test_dashboard_entry_path_with_absolute_path() -> None:
|
||||
"""Test DashboardEntry handles absolute paths."""
|
||||
# Use a truly absolute path for the platform
|
||||
test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml"
|
||||
cache_key = create_cache_key()
|
||||
|
||||
entry = DashboardEntry(test_path, cache_key)
|
||||
|
||||
assert entry.path == test_path
|
||||
assert entry.path.is_absolute()
|
||||
|
||||
|
||||
def test_dashboard_entry_path_with_relative_path() -> None:
|
||||
"""Test DashboardEntry handles relative paths."""
|
||||
test_path = Path("configs/device.yaml")
|
||||
cache_key = create_cache_key()
|
||||
|
||||
entry = DashboardEntry(test_path, cache_key)
|
||||
|
||||
assert entry.path == test_path
|
||||
assert not entry.path.is_absolute()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_entries_get_by_path(
|
||||
dashboard_entries: DashboardEntries, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test getting entry by path."""
|
||||
# Create a test file
|
||||
test_file = tmp_path / "device.yaml"
|
||||
test_file.write_text("test config")
|
||||
|
||||
# Update entries to load the file
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Verify the entry was loaded
|
||||
all_entries = dashboard_entries.async_all()
|
||||
assert len(all_entries) == 1
|
||||
entry = all_entries[0]
|
||||
assert entry.path == test_file
|
||||
|
||||
# Also verify get() works with Path
|
||||
result = dashboard_entries.get(test_file)
|
||||
assert result == entry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_entries_get_nonexistent_path(
|
||||
dashboard_entries: DashboardEntries,
|
||||
) -> None:
|
||||
"""Test getting non-existent entry returns None."""
|
||||
result = dashboard_entries.get("/nonexistent/path.yaml")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_entries_path_normalization(
|
||||
dashboard_entries: DashboardEntries, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test that paths are handled consistently."""
|
||||
# Create a test file
|
||||
test_file = tmp_path / "device.yaml"
|
||||
test_file.write_text("test config")
|
||||
|
||||
# Update entries to load the file
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Get the entry by path
|
||||
result = dashboard_entries.get(test_file)
|
||||
assert result is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_entries_path_with_spaces(
|
||||
dashboard_entries: DashboardEntries, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test handling paths with spaces."""
|
||||
# Create a test file with spaces in name
|
||||
test_file = tmp_path / "my device.yaml"
|
||||
test_file.write_text("test config")
|
||||
|
||||
# Update entries to load the file
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Get the entry by path
|
||||
result = dashboard_entries.get(test_file)
|
||||
assert result is not None
|
||||
assert result.path == test_file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_entries_path_with_special_chars(
|
||||
dashboard_entries: DashboardEntries, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test handling paths with special characters."""
|
||||
# Create a test file with special characters
|
||||
test_file = tmp_path / "device-01_test.yaml"
|
||||
test_file.write_text("test config")
|
||||
|
||||
# Update entries to load the file
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Get the entry by path
|
||||
result = dashboard_entries.get(test_file)
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_dashboard_entries_windows_path() -> None:
|
||||
"""Test handling Windows-style paths."""
|
||||
test_path = Path(r"C:\Users\test\esphome\device.yaml")
|
||||
cache_key = create_cache_key()
|
||||
|
||||
entry = DashboardEntry(test_path, cache_key)
|
||||
|
||||
assert entry.path == test_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_entries_path_to_cache_key_mapping(
|
||||
dashboard_entries: DashboardEntries, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test internal entries storage with paths and cache keys."""
|
||||
# Create test files
|
||||
file1 = tmp_path / "device1.yaml"
|
||||
file2 = tmp_path / "device2.yaml"
|
||||
file1.write_text("test config 1")
|
||||
file2.write_text("test config 2")
|
||||
|
||||
# Update entries to load the files
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Get entries and verify they have different cache keys
|
||||
entry1 = dashboard_entries.get(file1)
|
||||
entry2 = dashboard_entries.get(file2)
|
||||
|
||||
assert entry1 is not None
|
||||
assert entry2 is not None
|
||||
assert entry1.cache_key != entry2.cache_key
|
||||
|
||||
|
||||
def test_dashboard_entry_path_property() -> None:
|
||||
"""Test that path property returns expected value."""
|
||||
test_path = Path("/test/config/device.yaml")
|
||||
entry = DashboardEntry(test_path, create_cache_key())
|
||||
|
||||
assert entry.path == test_path
|
||||
assert isinstance(entry.path, Path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_entries_all_returns_entries_with_paths(
|
||||
dashboard_entries: DashboardEntries, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test that all() returns entries with their paths intact."""
|
||||
# Create test files
|
||||
files = [
|
||||
tmp_path / "device1.yaml",
|
||||
tmp_path / "device2.yaml",
|
||||
tmp_path / "device3.yaml",
|
||||
]
|
||||
|
||||
for file in files:
|
||||
file.write_text("test config")
|
||||
|
||||
# Update entries to load the files
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
all_entries = dashboard_entries.async_all()
|
||||
|
||||
assert len(all_entries) == len(files)
|
||||
retrieved_paths = [entry.path for entry in all_entries]
|
||||
assert set(retrieved_paths) == set(files)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_update_entries_removed_path(
|
||||
dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test that removed files trigger ENTRY_REMOVED event."""
|
||||
|
||||
# Create a test file
|
||||
test_file = tmp_path / "device.yaml"
|
||||
test_file.write_text("test config")
|
||||
|
||||
# First update to add the entry
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Verify entry was added
|
||||
all_entries = dashboard_entries.async_all()
|
||||
assert len(all_entries) == 1
|
||||
entry = all_entries[0]
|
||||
|
||||
# Delete the file
|
||||
test_file.unlink()
|
||||
|
||||
# Second update to detect removal
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Verify entry was removed
|
||||
all_entries = dashboard_entries.async_all()
|
||||
assert len(all_entries) == 0
|
||||
|
||||
# Verify ENTRY_REMOVED event was fired
|
||||
mock_dashboard.bus.async_fire.assert_any_call(
|
||||
DashboardEvent.ENTRY_REMOVED, {"entry": entry}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_update_entries_updated_path(
|
||||
dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test that modified files trigger ENTRY_UPDATED event."""
|
||||
|
||||
# Create a test file
|
||||
test_file = tmp_path / "device.yaml"
|
||||
test_file.write_text("test config")
|
||||
|
||||
# First update to add the entry
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Verify entry was added
|
||||
all_entries = dashboard_entries.async_all()
|
||||
assert len(all_entries) == 1
|
||||
entry = all_entries[0]
|
||||
original_cache_key = entry.cache_key
|
||||
|
||||
# Modify the file to change its mtime
|
||||
test_file.write_text("updated config")
|
||||
# Explicitly change the mtime to ensure it's different
|
||||
stat = test_file.stat()
|
||||
os.utime(test_file, (stat.st_atime, stat.st_mtime + 1))
|
||||
|
||||
# Second update to detect modification
|
||||
await dashboard_entries.async_update_entries()
|
||||
|
||||
# Verify entry is still there with updated cache key
|
||||
all_entries = dashboard_entries.async_all()
|
||||
assert len(all_entries) == 1
|
||||
updated_entry = all_entries[0]
|
||||
assert updated_entry == entry # Same entry object
|
||||
assert updated_entry.cache_key != original_cache_key # But cache key updated
|
||||
|
||||
# Verify ENTRY_UPDATED event was fired
|
||||
mock_dashboard.bus.async_fire.assert_any_call(
|
||||
DashboardEvent.ENTRY_UPDATED, {"entry": entry}
|
||||
)
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Tests for DashboardSettings (path resolution and authentication)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
from esphome.dashboard.settings import DashboardSettings
|
||||
from esphome.dashboard.util.password import password_hash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_settings(tmp_path: Path) -> DashboardSettings:
|
||||
"""Create DashboardSettings instance with temp directory."""
|
||||
settings = DashboardSettings()
|
||||
# Resolve symlinks to ensure paths match
|
||||
resolved_dir = tmp_path.resolve()
|
||||
settings.config_dir = resolved_dir
|
||||
settings.absolute_config_dir = resolved_dir
|
||||
return settings
|
||||
|
||||
|
||||
def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path with simple relative path."""
|
||||
result = dashboard_settings.rel_path("config.yaml")
|
||||
|
||||
expected = dashboard_settings.config_dir / "config.yaml"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path with multiple path components."""
|
||||
result = dashboard_settings.rel_path("subfolder", "device", "config.yaml")
|
||||
|
||||
expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path prevents directory traversal."""
|
||||
# This should raise ValueError as it tries to go outside config_dir
|
||||
with pytest.raises(ValueError):
|
||||
dashboard_settings.rel_path("..", "outside.yaml")
|
||||
|
||||
|
||||
def test_rel_path_absolute_path_within_config(
|
||||
dashboard_settings: DashboardSettings,
|
||||
) -> None:
|
||||
"""Test rel_path with absolute path that's within config dir."""
|
||||
internal_path = dashboard_settings.absolute_config_dir / "internal.yaml"
|
||||
|
||||
internal_path.touch()
|
||||
result = dashboard_settings.rel_path("internal.yaml")
|
||||
expected = dashboard_settings.config_dir / "internal.yaml"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rel_path_absolute_path_outside_config(
|
||||
dashboard_settings: DashboardSettings,
|
||||
) -> None:
|
||||
"""Test rel_path with absolute path outside config dir raises error."""
|
||||
outside_path = "/tmp/outside/config.yaml"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dashboard_settings.rel_path(outside_path)
|
||||
|
||||
|
||||
def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path with no arguments returns config_dir."""
|
||||
result = dashboard_settings.rel_path()
|
||||
assert result == dashboard_settings.config_dir
|
||||
|
||||
|
||||
def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path works with Path objects as arguments."""
|
||||
path_obj = Path("subfolder") / "config.yaml"
|
||||
result = dashboard_settings.rel_path(path_obj)
|
||||
|
||||
expected = dashboard_settings.config_dir / "subfolder" / "config.yaml"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path normalizes path separators."""
|
||||
# os.path.join normalizes slashes on Windows but preserves them on Unix
|
||||
# Test that providing components separately gives same result
|
||||
result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml")
|
||||
result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml")
|
||||
assert result1 == result2
|
||||
|
||||
# Also test that the result is as expected
|
||||
expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml"
|
||||
assert result1 == expected
|
||||
|
||||
|
||||
def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path handles paths with spaces."""
|
||||
result = dashboard_settings.rel_path("my folder", "my config.yaml")
|
||||
|
||||
expected = dashboard_settings.config_dir / "my folder" / "my config.yaml"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path handles paths with special characters."""
|
||||
result = dashboard_settings.rel_path("device-01_test", "config.yaml")
|
||||
|
||||
expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test that config_dir can be accessed and used with Path operations."""
|
||||
config_path = dashboard_settings.config_dir
|
||||
|
||||
assert config_path.exists()
|
||||
assert config_path.is_dir()
|
||||
assert config_path.is_absolute()
|
||||
|
||||
|
||||
def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test absolute_config_dir is a Path object."""
|
||||
assert isinstance(dashboard_settings.absolute_config_dir, Path)
|
||||
assert dashboard_settings.absolute_config_dir.exists()
|
||||
assert dashboard_settings.absolute_config_dir.is_dir()
|
||||
assert dashboard_settings.absolute_config_dir.is_absolute()
|
||||
|
||||
|
||||
def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path with symlink that points inside config dir."""
|
||||
target = dashboard_settings.absolute_config_dir / "target.yaml"
|
||||
target.touch()
|
||||
symlink = dashboard_settings.absolute_config_dir / "link.yaml"
|
||||
symlink.symlink_to(target)
|
||||
result = dashboard_settings.rel_path("link.yaml")
|
||||
expected = dashboard_settings.config_dir / "link.yaml"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path with symlink that points outside config dir."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp:
|
||||
symlink = dashboard_settings.absolute_config_dir / "external_link.yaml"
|
||||
symlink.symlink_to(tmp.name)
|
||||
with pytest.raises(ValueError):
|
||||
dashboard_settings.rel_path("external_link.yaml")
|
||||
|
||||
|
||||
def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path handles None arguments gracefully."""
|
||||
result = dashboard_settings.rel_path("None")
|
||||
expected = dashboard_settings.config_dir / "None"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test rel_path handles numeric arguments."""
|
||||
result = dashboard_settings.rel_path("123", "456.789")
|
||||
expected = dashboard_settings.config_dir / "123" / "456.789"
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None:
|
||||
"""Test that CORE.config_path.parent resolves to config_dir after parse_args.
|
||||
|
||||
This is a regression test for issue #11280 where binary download failed
|
||||
when using packages with secrets after the Path migration in 2025.10.0.
|
||||
|
||||
The issue was that after switching from os.path to Path:
|
||||
- Before: os.path.dirname("/config/.") → "/config"
|
||||
- After: Path("/config/.").parent → Path("/") (normalized first!)
|
||||
|
||||
The fix uses a sentinel file so .parent returns the correct directory:
|
||||
- Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config")
|
||||
"""
|
||||
# Create test directory structure with secrets and packages
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
|
||||
# Create secrets.yaml with obviously fake test values
|
||||
secrets_file = config_dir / "secrets.yaml"
|
||||
secrets_file.write_text(
|
||||
"wifi_ssid: TEST-DUMMY-SSID\n"
|
||||
"wifi_password: not-a-real-password-just-for-testing\n"
|
||||
)
|
||||
|
||||
# Create package file that uses secrets
|
||||
package_file = config_dir / "common.yaml"
|
||||
package_file.write_text(
|
||||
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
|
||||
)
|
||||
|
||||
# Create main device config that includes the package
|
||||
device_config = config_dir / "test-device.yaml"
|
||||
device_config.write_text(
|
||||
"esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n"
|
||||
)
|
||||
|
||||
# Set up dashboard settings with our test config directory
|
||||
settings = DashboardSettings()
|
||||
args = Namespace(
|
||||
configuration=str(config_dir),
|
||||
password=None,
|
||||
username=None,
|
||||
ha_addon=False,
|
||||
verbose=False,
|
||||
)
|
||||
settings.parse_args(args)
|
||||
|
||||
# Verify that CORE.config_path.parent correctly points to the config directory
|
||||
# This is critical for secret resolution in yaml_util.py which does:
|
||||
# main_config_dir = CORE.config_path.parent
|
||||
# main_secret_yml = main_config_dir / "secrets.yaml"
|
||||
assert CORE.config_path.parent == config_dir.resolve()
|
||||
assert (CORE.config_path.parent / "secrets.yaml").exists()
|
||||
assert (CORE.config_path.parent / "common.yaml").exists()
|
||||
|
||||
# Verify that CORE.config_path itself uses the sentinel file
|
||||
assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml"
|
||||
assert not CORE.config_path.exists() # Sentinel file doesn't actually exist
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings:
|
||||
"""Create DashboardSettings with auth configured, based on dashboard_settings."""
|
||||
dashboard_settings.username = "admin"
|
||||
dashboard_settings.using_password = True
|
||||
dashboard_settings.password_hash = password_hash("correctpassword")
|
||||
return dashboard_settings
|
||||
|
||||
|
||||
def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None:
|
||||
"""Test check_password returns True for correct username and password."""
|
||||
assert auth_settings.check_password("admin", "correctpassword") is True
|
||||
|
||||
|
||||
def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None:
|
||||
"""Test check_password returns False for wrong password."""
|
||||
assert auth_settings.check_password("admin", "wrongpassword") is False
|
||||
|
||||
|
||||
def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None:
|
||||
"""Test check_password returns False for wrong username."""
|
||||
assert auth_settings.check_password("notadmin", "correctpassword") is False
|
||||
|
||||
|
||||
def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None:
|
||||
"""Test check_password returns False when both are wrong."""
|
||||
assert auth_settings.check_password("notadmin", "wrongpassword") is False
|
||||
|
||||
|
||||
def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None:
|
||||
"""Test check_password returns True when auth is not configured."""
|
||||
assert dashboard_settings.check_password("anyone", "anything") is True
|
||||
|
||||
|
||||
def test_check_password_non_ascii_username(
|
||||
dashboard_settings: DashboardSettings,
|
||||
) -> None:
|
||||
"""Test check_password handles non-ASCII usernames without TypeError."""
|
||||
dashboard_settings.username = "\u00e9l\u00e8ve"
|
||||
dashboard_settings.using_password = True
|
||||
dashboard_settings.password_hash = password_hash("pass")
|
||||
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True
|
||||
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False
|
||||
assert dashboard_settings.check_password("other", "pass") is False
|
||||
|
||||
|
||||
def test_check_password_ha_addon_no_password(
|
||||
dashboard_settings: DashboardSettings,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test check_password doesn't crash in HA add-on mode without a password.
|
||||
|
||||
In HA add-on mode, using_ha_addon_auth can be True while using_password
|
||||
is False, leaving password_hash as b"". This must not raise TypeError
|
||||
in hmac.compare_digest.
|
||||
"""
|
||||
monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False)
|
||||
dashboard_settings.on_ha_addon = True
|
||||
dashboard_settings.using_password = False
|
||||
# password_hash stays as default b""
|
||||
assert dashboard_settings.check_password("anyone", "anything") is False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,219 +0,0 @@
|
||||
"""Tests for dashboard web_server Path-related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from esphome.dashboard import web_server
|
||||
|
||||
|
||||
def test_get_base_frontend_path_production() -> None:
|
||||
"""Test get_base_frontend_path in production mode."""
|
||||
mock_module = MagicMock()
|
||||
mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard")
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
|
||||
):
|
||||
result = web_server.get_base_frontend_path()
|
||||
assert result == Path("/usr/local/lib/esphome_dashboard")
|
||||
mock_module.where.assert_called_once()
|
||||
|
||||
|
||||
def test_get_base_frontend_path_dev_mode() -> None:
|
||||
"""Test get_base_frontend_path in development mode."""
|
||||
test_path = "/home/user/esphome/dashboard"
|
||||
|
||||
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
|
||||
result = web_server.get_base_frontend_path()
|
||||
|
||||
# The function uses Path.resolve() which resolves symlinks
|
||||
# The actual function adds "/" to the path, so we simulate that
|
||||
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
|
||||
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None:
|
||||
"""Test get_base_frontend_path in dev mode with trailing slash."""
|
||||
test_path = "/home/user/esphome/dashboard/"
|
||||
|
||||
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
|
||||
result = web_server.get_base_frontend_path()
|
||||
|
||||
# The function uses Path.resolve() which resolves symlinks
|
||||
expected = (Path.cwd() / test_path / "esphome_dashboard").resolve()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_get_base_frontend_path_dev_mode_relative_path() -> None:
|
||||
"""Test get_base_frontend_path with relative dev path."""
|
||||
test_path = "./dashboard"
|
||||
|
||||
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
|
||||
result = web_server.get_base_frontend_path()
|
||||
|
||||
# The function uses Path.resolve() which resolves symlinks
|
||||
# The actual function adds "/" to the path, so we simulate that
|
||||
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
|
||||
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
|
||||
assert result == expected
|
||||
assert result.is_absolute()
|
||||
|
||||
|
||||
def test_get_static_path_single_component() -> None:
|
||||
"""Test get_static_path with single path component."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path("/base/frontend")
|
||||
|
||||
result = web_server.get_static_path("file.js")
|
||||
|
||||
assert result == Path("/base/frontend") / "static" / "file.js"
|
||||
|
||||
|
||||
def test_get_static_path_multiple_components() -> None:
|
||||
"""Test get_static_path with multiple path components."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path("/base/frontend")
|
||||
|
||||
result = web_server.get_static_path("js", "esphome", "index.js")
|
||||
|
||||
assert (
|
||||
result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js"
|
||||
)
|
||||
|
||||
|
||||
def test_get_static_path_empty_args() -> None:
|
||||
"""Test get_static_path with no arguments."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path("/base/frontend")
|
||||
|
||||
result = web_server.get_static_path()
|
||||
|
||||
assert result == Path("/base/frontend") / "static"
|
||||
|
||||
|
||||
def test_get_static_path_with_pathlib_path() -> None:
|
||||
"""Test get_static_path with Path objects."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path("/base/frontend")
|
||||
|
||||
path_obj = Path("js") / "app.js"
|
||||
result = web_server.get_static_path(str(path_obj))
|
||||
|
||||
assert result == Path("/base/frontend") / "static" / "js" / "app.js"
|
||||
|
||||
|
||||
def test_get_static_file_url_production() -> None:
|
||||
"""Test get_static_file_url in production mode."""
|
||||
web_server.get_static_file_url.cache_clear()
|
||||
mock_module = MagicMock()
|
||||
mock_path = MagicMock(spec=Path)
|
||||
mock_path.read_bytes.return_value = b"test content"
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
|
||||
patch("esphome.dashboard.web_server.get_static_path") as mock_get_path,
|
||||
):
|
||||
mock_get_path.return_value = mock_path
|
||||
result = web_server.get_static_file_url("js/app.js")
|
||||
assert result.startswith("./static/js/app.js?hash=")
|
||||
|
||||
|
||||
def test_get_static_file_url_dev_mode() -> None:
|
||||
"""Test get_static_file_url in development mode."""
|
||||
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}):
|
||||
web_server.get_static_file_url.cache_clear()
|
||||
result = web_server.get_static_file_url("js/app.js")
|
||||
|
||||
assert result == "./static/js/app.js"
|
||||
|
||||
|
||||
def test_get_static_file_url_index_js_special_case() -> None:
|
||||
"""Test get_static_file_url replaces index.js with entrypoint."""
|
||||
web_server.get_static_file_url.cache_clear()
|
||||
mock_module = MagicMock()
|
||||
mock_module.entrypoint.return_value = "main.js"
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
|
||||
):
|
||||
result = web_server.get_static_file_url("js/esphome/index.js")
|
||||
assert result == "./static/js/esphome/main.js"
|
||||
|
||||
|
||||
def test_load_file_path(tmp_path: Path) -> None:
|
||||
"""Test loading a file."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
with test_file.open("rb") as f:
|
||||
content = f.read()
|
||||
assert content == b"test content"
|
||||
|
||||
|
||||
def test_load_file_compressed_path(tmp_path: Path) -> None:
|
||||
"""Test loading a compressed file."""
|
||||
test_file = tmp_path / "test.txt.gz"
|
||||
|
||||
with gzip.open(test_file, "wb") as gz:
|
||||
gz.write(b"compressed content")
|
||||
|
||||
with gzip.open(test_file, "rb") as gz:
|
||||
content = gz.read()
|
||||
assert content == b"compressed content"
|
||||
|
||||
|
||||
def test_path_normalization_in_static_path() -> None:
|
||||
"""Test that paths are normalized correctly."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path("/base/frontend")
|
||||
|
||||
# Test with separate components
|
||||
result1 = web_server.get_static_path("js", "app.js")
|
||||
result2 = web_server.get_static_path("js", "app.js")
|
||||
|
||||
assert result1 == result2
|
||||
assert result1 == Path("/base/frontend") / "static" / "js" / "app.js"
|
||||
|
||||
|
||||
def test_windows_path_handling() -> None:
|
||||
"""Test handling of Windows-style paths."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path(r"C:\Program Files\esphome\frontend")
|
||||
|
||||
result = web_server.get_static_path("js", "app.js")
|
||||
|
||||
# Path should handle this correctly on the platform
|
||||
expected = (
|
||||
Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js"
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_path_with_special_characters() -> None:
|
||||
"""Test paths with special characters."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path("/base/frontend")
|
||||
|
||||
result = web_server.get_static_path("js-modules", "app_v1.0.js")
|
||||
|
||||
assert (
|
||||
result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js"
|
||||
)
|
||||
|
||||
|
||||
def test_path_with_spaces() -> None:
|
||||
"""Test paths with spaces."""
|
||||
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
|
||||
mock_base.return_value = Path("/base/my frontend")
|
||||
|
||||
result = web_server.get_static_path("my js", "my app.js")
|
||||
|
||||
assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js"
|
||||
@@ -562,7 +562,7 @@ def test_determine_integration_tests(
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["esphome/dashboard/web_server.py"],
|
||||
return_value=["esphome/analyze_memory/helpers.py"],
|
||||
):
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is False
|
||||
@@ -914,7 +914,6 @@ def test_should_run_core_ci_with_branch() -> None:
|
||||
# picks them up because esphome's pyproject sets
|
||||
# include-package-data = true.
|
||||
(["esphome/idf_component.yml"], True),
|
||||
(["esphome/dashboard/templates/index.html"], True),
|
||||
(["esphome/components/api/api_pb2_service.json"], True),
|
||||
# Mixed: any triggering file is enough
|
||||
(["docs/README.md", "esphome/config.py"], True),
|
||||
|
||||
@@ -121,22 +121,6 @@ def test_friendly_name_slugify(value, expected):
|
||||
assert helpers.friendly_name_slugify(value) == expected
|
||||
|
||||
|
||||
def test_friendly_name_slugify_back_compat_shim():
|
||||
"""``esphome.dashboard.util.text`` keeps re-exporting for back-compat.
|
||||
|
||||
The function moved to ``esphome.helpers`` so the new
|
||||
device-builder dashboard backend can import it without depending
|
||||
on the legacy dashboard package, but downstream code that still
|
||||
imports from the old path keeps working until the dashboard
|
||||
module is removed.
|
||||
"""
|
||||
from esphome.dashboard.util.text import (
|
||||
friendly_name_slugify as legacy_friendly_name_slugify,
|
||||
)
|
||||
|
||||
assert legacy_friendly_name_slugify is helpers.friendly_name_slugify
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host",
|
||||
(
|
||||
|
||||
@@ -33,6 +33,7 @@ from esphome.__main__ import (
|
||||
command_clean_all,
|
||||
command_config,
|
||||
command_config_hash,
|
||||
command_dashboard,
|
||||
command_idedata,
|
||||
command_rename,
|
||||
command_run,
|
||||
@@ -3740,6 +3741,45 @@ def test_command_wizard(tmp_path: Path) -> None:
|
||||
mock_wizard.assert_called_once_with(config_file)
|
||||
|
||||
|
||||
def test_command_dashboard_errors_with_device_builder_redirect() -> None:
|
||||
"""The removed dashboard command points users to ESPHome Device Builder."""
|
||||
args = MockArgs()
|
||||
|
||||
with pytest.raises(EsphomeError, match="esphome-device-builder"):
|
||||
command_dashboard(args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
["esphome", "dashboard"],
|
||||
["esphome", "dashboard", "/config"],
|
||||
# Legacy flags must be accepted so old invocations reach the redirect
|
||||
# instead of failing on argparse "unrecognized arguments".
|
||||
["esphome", "dashboard", "--port", "6052", "/config"],
|
||||
["esphome", "dashboard", "--username", "u", "--password", "p", "--open-ui"],
|
||||
[
|
||||
"esphome",
|
||||
"dashboard",
|
||||
"--address",
|
||||
"0.0.0.0",
|
||||
"--socket",
|
||||
"/x",
|
||||
"--ha-addon",
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_run_esphome_dashboard_redirects_to_device_builder(
|
||||
argv: list[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""`esphome dashboard` still parses but fails with the redirect message."""
|
||||
result = run_esphome(argv)
|
||||
|
||||
assert result == 1
|
||||
assert "esphome-device-builder" in caplog.text
|
||||
|
||||
|
||||
def test_command_config_hash(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
|
||||
Reference in New Issue
Block a user