diff --git a/CODEOWNERS b/CODEOWNERS index 8f541414a3c3af..b56dc2cd76ea20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -846,6 +846,8 @@ build.json @home-assistant/supervisor /tests/components/kraken/ @eifinger /homeassistant/components/kulersky/ @emlove /tests/components/kulersky/ @emlove +/homeassistant/components/labs/ @home-assistant/core +/tests/components/labs/ @home-assistant/core /homeassistant/components/lacrosse_view/ @IceBotYT /tests/components/lacrosse_view/ @IceBotYT /homeassistant/components/lamarzocco/ @zweckj diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 95f4c8e333463a..425f079e596c0e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -176,6 +176,8 @@ STAGE_0_INTEGRATIONS = ( # Load logging and http deps as soon as possible ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None), + # Setup labs for preview features + ("labs", {"labs"}, STAGE_0_SUBSTAGE_TIMEOUT), # Setup frontend ("frontend", FRONTEND_INTEGRATIONS, None), # Setup recorder @@ -212,6 +214,7 @@ "backup", "frontend", "hardware", + "labs", "logger", "network", "system_health", diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index cb782b258d946e..f423546b053a38 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -11,6 +11,11 @@ import voluptuous as vol +from homeassistant.components.labs import ( + EVENT_LABS_UPDATED, + EventLabsUpdatedData, + async_is_preview_feature_enabled, +) from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance from homeassistant.components.recorder.models import ( StatisticData, @@ -30,10 +35,14 @@ UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -110,6 +119,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Notify backup listeners hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) + # Subscribe to labs feature updates for kitchen_sink preview repair + @callback + def _async_labs_updated(event: Event[EventLabsUpdatedData]) -> None: + """Handle labs feature update event.""" + if ( + event.data["domain"] == "kitchen_sink" + and event.data["preview_feature"] == "special_repair" + ): + _async_update_special_repair(hass) + + entry.async_on_unload( + hass.bus.async_listen(EVENT_LABS_UPDATED, _async_labs_updated) + ) + + # Check if lab feature is currently enabled and create repair if so + _async_update_special_repair(hass) + return True @@ -137,6 +163,27 @@ async def async_remove_config_entry_device( return True +@callback +def _async_update_special_repair(hass: HomeAssistant) -> None: + """Create or delete the special repair issue. + + Creates a repair issue when the special_repair lab feature is enabled, + and deletes it when disabled. This demonstrates how lab features can interact + with Home Assistant's repair system. + """ + if async_is_preview_feature_enabled(hass, DOMAIN, "special_repair"): + async_create_issue( + hass, + DOMAIN, + "kitchen_sink_special_repair_issue", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="special_repair", + ) + else: + async_delete_issue(hass, DOMAIN, "kitchen_sink_special_repair_issue") + + async def _notify_backup_listeners(hass: HomeAssistant) -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() diff --git a/homeassistant/components/kitchen_sink/manifest.json b/homeassistant/components/kitchen_sink/manifest.json index ae2462afbbdaee..4cf532d72ff18a 100644 --- a/homeassistant/components/kitchen_sink/manifest.json +++ b/homeassistant/components/kitchen_sink/manifest.json @@ -5,6 +5,13 @@ "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/kitchen_sink", "iot_class": "calculated", + "preview_features": { + "special_repair": { + "feedback_url": "https://community.home-assistant.io", + "learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink", + "report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink" + } + }, "quality_scale": "internal", "single_config_entry": true } diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index ad490f9c32fe92..0b816675cfc7d2 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -71,6 +71,10 @@ }, "title": "The blinker fluid is empty and needs to be refilled" }, + "special_repair": { + "description": "This is a special repair created by a preview feature! This demonstrates how lab features can interact with the Home Assistant repair system. You can disable this by turning off the kitchen sink special repair feature in Settings > System > Labs.", + "title": "Special repair feature preview" + }, "transmogrifier_deprecated": { "description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API", "title": "The transmogrifier component is deprecated" @@ -103,6 +107,14 @@ } } }, + "preview_features": { + "special_repair": { + "description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how lab features can interact with other Home Assistant integrations.", + "disable_confirmation": "This will remove the special repair issue. Don't worry, this is just a demonstration feature.", + "enable_confirmation": "This will create a special repair issue to demonstrate Labs preview features. This is just an example and won't affect your actual system.", + "name": "Special repair" + } + }, "services": { "test_service_1": { "description": "Fake action for testing", diff --git a/homeassistant/components/labs/__init__.py b/homeassistant/components/labs/__init__.py new file mode 100644 index 00000000000000..aa89cb225b61ed --- /dev/null +++ b/homeassistant/components/labs/__init__.py @@ -0,0 +1,310 @@ +"""The Home Assistant Labs integration. + +This integration provides preview features that can be toggled on/off by users. +Integrations can register lab preview features in their manifest.json which will appear +in the Home Assistant Labs UI for users to enable or disable. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.backup import async_get_manager +from homeassistant.core import HomeAssistant, callback +from homeassistant.generated.labs import LABS_PREVIEW_FEATURES +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_custom_components + +from .const import ( + DOMAIN, + EVENT_LABS_UPDATED, + LABS_DATA, + STORAGE_KEY, + STORAGE_VERSION, + EventLabsUpdatedData, + LabPreviewFeature, + LabsData, + LabsStoreData, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +__all__ = [ + "EVENT_LABS_UPDATED", + "EventLabsUpdatedData", + "async_is_preview_feature_enabled", +] + + +class LabsStorage(Store[LabsStoreData]): + """Custom Store for Labs that converts between runtime and storage formats. + + Runtime format: {"preview_feature_status": {(domain, preview_feature)}} + Storage format: {"preview_feature_status": [{"domain": str, "preview_feature": str}]} + + Only enabled features are saved to storage - if stored, it's enabled. + """ + + async def _async_load_data(self) -> LabsStoreData | None: + """Load data and convert from storage format to runtime format.""" + raw_data = await super()._async_load_data() + if raw_data is None: + return None + + status_list = raw_data.get("preview_feature_status", []) + + # Convert list of objects to runtime set - if stored, it's enabled + return { + "preview_feature_status": { + (item["domain"], item["preview_feature"]) for item in status_list + } + } + + def _write_data(self, path: str, data: dict) -> None: + """Convert from runtime format to storage format and write. + + Only saves enabled features - disabled is the default. + """ + # Extract the actual data (has version/key wrapper) + actual_data = data.get("data", data) + + # Check if this is Labs data (has preview_feature_status key) + if "preview_feature_status" not in actual_data: + # Not Labs data, write as-is + super()._write_data(path, data) + return + + preview_status = actual_data["preview_feature_status"] + + # Convert from runtime format (set of tuples) to storage format (list of dicts) + status_list = [ + {"domain": domain, "preview_feature": preview_feature} + for domain, preview_feature in preview_status + ] + + # Build the final data structure with converted format + data_copy = data.copy() + data_copy["data"] = {"preview_feature_status": status_list} + + super()._write_data(path, data_copy) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Labs component.""" + store = LabsStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True) + data = await store.async_load() + + if data is None: + data = {"preview_feature_status": set()} + + # Scan ALL integrations for lab preview features (loaded or not) + lab_preview_features = await _async_scan_all_preview_features(hass) + + # Clean up preview features that no longer exist + if lab_preview_features: + valid_keys = { + (pf.domain, pf.preview_feature) for pf in lab_preview_features.values() + } + stale_keys = data["preview_feature_status"] - valid_keys + + if stale_keys: + _LOGGER.debug( + "Removing %d stale preview features: %s", + len(stale_keys), + stale_keys, + ) + data["preview_feature_status"] -= stale_keys + + await store.async_save(data) + + hass.data[LABS_DATA] = LabsData( + store=store, + data=data, + preview_features=lab_preview_features, + ) + + websocket_api.async_register_command(hass, websocket_list_preview_features) + websocket_api.async_register_command(hass, websocket_update_preview_feature) + + return True + + +def _populate_preview_features( + preview_features: dict[str, LabPreviewFeature], + domain: str, + labs_preview_features: dict[str, dict[str, str]], + is_built_in: bool = True, +) -> None: + """Populate preview features dictionary from integration preview_features. + + Args: + preview_features: Dictionary to populate + domain: Integration domain + labs_preview_features: Dictionary of preview feature definitions from manifest + is_built_in: Whether this is a built-in integration + """ + for preview_feature_key, preview_feature_data in labs_preview_features.items(): + preview_feature = LabPreviewFeature( + domain=domain, + preview_feature=preview_feature_key, + is_built_in=is_built_in, + feedback_url=preview_feature_data.get("feedback_url"), + learn_more_url=preview_feature_data.get("learn_more_url"), + report_issue_url=preview_feature_data.get("report_issue_url"), + ) + preview_features[preview_feature.full_key] = preview_feature + + +async def _async_scan_all_preview_features( + hass: HomeAssistant, +) -> dict[str, LabPreviewFeature]: + """Scan ALL available integrations for lab preview features (loaded or not).""" + preview_features: dict[str, LabPreviewFeature] = {} + + # Load pre-generated built-in lab preview features (already includes all data) + for domain, domain_preview_features in LABS_PREVIEW_FEATURES.items(): + _populate_preview_features( + preview_features, domain, domain_preview_features, is_built_in=True + ) + + # Scan custom components + custom_integrations = await async_get_custom_components(hass) + _LOGGER.debug( + "Loaded %d built-in + scanning %d custom integrations for lab preview features", + len(preview_features), + len(custom_integrations), + ) + + for integration in custom_integrations.values(): + if labs_preview_features := integration.preview_features: + _populate_preview_features( + preview_features, + integration.domain, + labs_preview_features, + is_built_in=False, + ) + + _LOGGER.debug("Loaded %d total lab preview features", len(preview_features)) + return preview_features + + +@callback +def async_is_preview_feature_enabled( + hass: HomeAssistant, domain: str, preview_feature: str +) -> bool: + """Check if a lab preview feature is enabled. + + Args: + hass: HomeAssistant instance + domain: Integration domain + preview_feature: Preview feature name + + Returns: + True if the preview feature is enabled, False otherwise + """ + if LABS_DATA not in hass.data: + return False + + labs_data = hass.data[LABS_DATA] + return (domain, preview_feature) in labs_data.data["preview_feature_status"] + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "labs/list"}) +def websocket_list_preview_features( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List all lab preview features filtered by loaded integrations.""" + labs_data = hass.data[LABS_DATA] + loaded_components = hass.config.components + + preview_features: list[dict[str, Any]] = [ + preview_feature.to_dict( + (preview_feature.domain, preview_feature.preview_feature) + in labs_data.data["preview_feature_status"] + ) + for preview_feature_key, preview_feature in labs_data.preview_features.items() + if preview_feature.domain in loaded_components + ] + + connection.send_result(msg["id"], {"features": preview_features}) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "labs/update", + vol.Required("domain"): str, + vol.Required("preview_feature"): str, + vol.Required("enabled"): bool, + vol.Optional("create_backup", default=False): bool, + } +) +@websocket_api.async_response +async def websocket_update_preview_feature( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update a lab preview feature state.""" + domain = msg["domain"] + preview_feature = msg["preview_feature"] + enabled = msg["enabled"] + create_backup = msg["create_backup"] + + labs_data = hass.data[LABS_DATA] + + # Build preview_feature_id for lookup + preview_feature_id = f"{domain}.{preview_feature}" + + # Validate preview feature exists + if preview_feature_id not in labs_data.preview_features: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_FOUND, + f"Preview feature {preview_feature_id} not found", + ) + return + + # Create backup if requested and enabling + if create_backup and enabled: + try: + backup_manager = async_get_manager(hass) + await backup_manager.async_create_automatic_backup() + except Exception as err: # noqa: BLE001 - websocket handlers can catch broad exceptions + connection.send_error( + msg["id"], + websocket_api.ERR_UNKNOWN_ERROR, + f"Error creating backup: {err}", + ) + return + + # Update storage (only store enabled features, remove if disabled) + if enabled: + labs_data.data["preview_feature_status"].add((domain, preview_feature)) + else: + labs_data.data["preview_feature_status"].discard((domain, preview_feature)) + + # Save changes immediately + await labs_data.store.async_save(labs_data.data) + + # Fire event + event_data: EventLabsUpdatedData = { + "domain": domain, + "preview_feature": preview_feature, + "enabled": enabled, + } + hass.bus.async_fire(EVENT_LABS_UPDATED, event_data) + + connection.send_result(msg["id"]) diff --git a/homeassistant/components/labs/const.py b/homeassistant/components/labs/const.py new file mode 100644 index 00000000000000..80a60d19717fec --- /dev/null +++ b/homeassistant/components/labs/const.py @@ -0,0 +1,77 @@ +"""Constants for the Home Assistant Labs integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, TypedDict + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.storage import Store + +DOMAIN = "labs" + +STORAGE_KEY = "core.labs" +STORAGE_VERSION = 1 + +EVENT_LABS_UPDATED = "labs_updated" + + +class EventLabsUpdatedData(TypedDict): + """Event data for labs_updated event.""" + + domain: str + preview_feature: str + enabled: bool + + +@dataclass(frozen=True, kw_only=True, slots=True) +class LabPreviewFeature: + """Lab preview feature definition.""" + + domain: str + preview_feature: str + is_built_in: bool = True + feedback_url: str | None = None + learn_more_url: str | None = None + report_issue_url: str | None = None + + @property + def full_key(self) -> str: + """Return the full key for the preview feature (domain.preview_feature).""" + return f"{self.domain}.{self.preview_feature}" + + def to_dict(self, enabled: bool) -> dict[str, str | bool | None]: + """Return a serialized version of the preview feature. + + Args: + enabled: Whether the preview feature is currently enabled + + Returns: + Dictionary with preview feature data including enabled status + """ + return { + "preview_feature": self.preview_feature, + "domain": self.domain, + "enabled": enabled, + "is_built_in": self.is_built_in, + "feedback_url": self.feedback_url, + "learn_more_url": self.learn_more_url, + "report_issue_url": self.report_issue_url, + } + + +type LabsStoreData = dict[str, set[tuple[str, str]]] + + +@dataclass +class LabsData: + """Storage class for Labs global data.""" + + store: Store[LabsStoreData] + data: LabsStoreData + preview_features: dict[str, LabPreviewFeature] = field(default_factory=dict) + + +LABS_DATA: HassKey[LabsData] = HassKey(DOMAIN) diff --git a/homeassistant/components/labs/manifest.json b/homeassistant/components/labs/manifest.json new file mode 100644 index 00000000000000..5a97f72a09f709 --- /dev/null +++ b/homeassistant/components/labs/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "labs", + "name": "Home Assistant Labs", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/labs", + "integration_type": "system", + "iot_class": "calculated", + "quality_scale": "internal" +} diff --git a/homeassistant/components/labs/strings.json b/homeassistant/components/labs/strings.json new file mode 100644 index 00000000000000..23aa06e50fac6b --- /dev/null +++ b/homeassistant/components/labs/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant Labs" +} diff --git a/homeassistant/generated/labs.py b/homeassistant/generated/labs.py new file mode 100644 index 00000000000000..b1e06b0fb13352 --- /dev/null +++ b/homeassistant/generated/labs.py @@ -0,0 +1,14 @@ +"""Automatically generated file. + +To update, run python3 -m script.hassfest +""" + +LABS_PREVIEW_FEATURES = { + "kitchen_sink": { + "special_repair": { + "feedback_url": "https://community.home-assistant.io", + "learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink", + "report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink", + }, + }, +} diff --git a/homeassistant/loader.py b/homeassistant/loader.py index fc10223a182fc7..62382e59d0eca9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -266,6 +266,7 @@ class Manifest(TypedDict, total=False): loggers: list[str] import_executor: bool single_config_entry: bool + preview_features: dict[str, dict[str, str]] def async_setup(hass: HomeAssistant) -> None: @@ -900,6 +901,11 @@ def bluetooth(self) -> list[dict[str, str | int]] | None: """Return Integration bluetooth entries.""" return self.manifest.get("bluetooth") + @property + def preview_features(self) -> dict[str, dict[str, str]] | None: + """Return Integration preview features entries.""" + return self.manifest.get("preview_features") + @property def dhcp(self) -> list[dict[str, str | bool]] | None: """Return Integration dhcp entries.""" diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 43a6cc7678b832..0e7c6c83d9b3c3 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -21,6 +21,7 @@ icons, integration_info, json, + labs, manifest, metadata, mqtt, @@ -47,6 +48,7 @@ icons, integration_info, json, + labs, manifest, mqtt, quality_scale, diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 447b3ec79b846d..fe48e8a8607df4 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -102,6 +102,7 @@ def visit_Import(self, node: ast.Import) -> None: "input_number", "input_select", "input_text", + "labs", "media_source", "onboarding", "panel_custom", @@ -130,6 +131,7 @@ def visit_Import(self, node: ast.Import) -> None: # This would be a circular dep ("http", "network"), ("http", "cloud"), + ("labs", "backup"), # This would be a circular dep ("zha", "homeassistant_hardware"), ("zha", "homeassistant_sky_connect"), diff --git a/script/hassfest/labs.py b/script/hassfest/labs.py new file mode 100644 index 00000000000000..c4cb8b77cf9b13 --- /dev/null +++ b/script/hassfest/labs.py @@ -0,0 +1,79 @@ +"""Generate lab preview features file.""" + +from __future__ import annotations + +from .model import Config, Integration +from .serializer import format_python_namespace + + +def generate_and_validate(integrations: dict[str, Integration]) -> str: + """Validate and generate lab preview features data.""" + labs_dict: dict[str, dict[str, dict[str, str]]] = {} + + for domain in sorted(integrations): + integration = integrations[domain] + preview_features = integration.manifest.get("preview_features") + + if not preview_features: + continue + + if not isinstance(preview_features, dict): + integration.add_error( + "labs", + f"preview_features must be a dict, got {type(preview_features).__name__}", + ) + continue + + # Extract features with full data + domain_preview_features: dict[str, dict[str, str]] = {} + for preview_feature_id, preview_feature_config in preview_features.items(): + if not isinstance(preview_feature_id, str): + integration.add_error( + "labs", + f"preview_features keys must be strings, got {type(preview_feature_id).__name__}", + ) + break + if not isinstance(preview_feature_config, dict): + integration.add_error( + "labs", + f"preview_features[{preview_feature_id}] must be a dict, got {type(preview_feature_config).__name__}", + ) + break + # Include the full feature configuration + domain_preview_features[preview_feature_id] = { + "feedback_url": preview_feature_config.get("feedback_url", ""), + "learn_more_url": preview_feature_config.get("learn_more_url", ""), + "report_issue_url": preview_feature_config.get("report_issue_url", ""), + } + else: + # Only add if all features are valid + if domain_preview_features: + labs_dict[domain] = domain_preview_features + + return format_python_namespace( + { + "LABS_PREVIEW_FEATURES": labs_dict, + } + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate lab preview features file.""" + labs_path = config.root / "homeassistant/generated/labs.py" + config.cache["labs"] = content = generate_and_validate(integrations) + + if config.specific_integrations: + return + + if not labs_path.exists() or labs_path.read_text() != content: + config.add_error( + "labs", + "File labs.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate lab preview features file.""" + labs_path = config.root / "homeassistant/generated/labs.py" + labs_path.write_text(config.cache["labs"]) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 74aad78dc6a46f..5828ed329bcb5a 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -279,6 +279,17 @@ def verify_wildcard(value: str) -> str: vol.Optional("disabled"): str, vol.Optional("iot_class"): vol.In(SUPPORTED_IOT_CLASSES), vol.Optional("single_config_entry"): bool, + vol.Optional("preview_features"): vol.Schema( + { + cv.slug: vol.Schema( + { + vol.Optional("feedback_url"): vol.Url(), + vol.Optional("learn_more_url"): vol.Url(), + vol.Optional("report_issue_url"): vol.Url(), + } + ) + } + ), } ) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d8763fe00eb65d..80b19dedf97cb3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2189,6 +2189,7 @@ class Rule: "input_text", "intent_script", "intent", + "labs", "logbook", "logger", "lovelace", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 6ded948f181d64..ca5983073b10be 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -329,6 +329,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: flow_title=UNDEFINED, require_step_title=False, ), + vol.Optional("preview_features"): cv.schema_with_slug_keys( + { + vol.Required("name"): translation_value_validator, + vol.Required("description"): translation_value_validator, + vol.Optional("enable_confirmation"): translation_value_validator, + vol.Optional("disable_confirmation"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), vol.Optional("selector"): cv.schema_with_slug_keys( { vol.Optional("options"): cv.schema_with_slug_keys( diff --git a/script/json_schemas/manifest_schema.json b/script/json_schemas/manifest_schema.json index 7349f12b55abf7..8b0ea255f93a13 100644 --- a/script/json_schemas/manifest_schema.json +++ b/script/json_schemas/manifest_schema.json @@ -356,6 +356,32 @@ }, "uniqueItems": true }, + "preview_features": { + "description": "Preview features that can be enabled/disabled by users via the Labs UI.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#preview-features", + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "properties": { + "feedback_url": { + "description": "URL where users can provide feedback about the feature.", + "type": "string", + "format": "uri" + }, + "learn_more_url": { + "description": "URL where users can learn more about the feature.", + "type": "string", + "format": "uri" + }, + "report_issue_url": { + "description": "URL where users can report issues with the feature.", + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false + } + }, "disabled": { "description": "The reason for the integration being disabled.", "type": "string" diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 6d7b0af1d5d740..8439f104501333 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.labs import EVENT_LABS_UPDATED from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.statistics import ( StatisticMeanType, @@ -18,6 +19,7 @@ ) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -370,3 +372,133 @@ async def test_service( {"field_1": 1, "field_2": "auto", "field_3": 1, "field_4": "forwards"}, blocking=True, ) + + +@pytest.mark.parametrize( + ("preview_feature_enabled", "should_create_issue"), + [ + (False, False), + (True, True), + ], + ids=["preview_feature_disabled", "preview_feature_enabled"], +) +async def test_special_repair_preview_feature_state( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_ws_client: WebSocketGenerator, + preview_feature_enabled: bool, + should_create_issue: bool, +) -> None: + """Test that special repair issue is created/removed based on preview feature state.""" + assert await async_setup_component(hass, "labs", {}) + assert await async_setup_component(hass, "repairs", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + if preview_feature_enabled: + ws_client = await hass_ws_client(hass) + + # Enable the special repair preview feature + await ws_client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Wait for event handling + await hass.async_block_till_done() + + # Check if issue exists based on preview feature state + issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue") + if should_create_issue: + assert issue is not None + assert issue.domain == DOMAIN + assert issue.translation_key == "special_repair" + assert issue.is_fixable is False + assert issue.severity == ir.IssueSeverity.WARNING + else: + assert issue is None + + +async def test_special_repair_preview_feature_toggle( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that special repair issue is created/deleted when preview feature is toggled.""" + # Setup repairs and kitchen_sink first + assert await async_setup_component(hass, "labs", {}) + assert await async_setup_component(hass, "repairs", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + # Enable the special repair preview feature + await ws_client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + # Check issue exists + issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue") + assert issue is not None + + # Disable the special repair preview feature + await ws_client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": False, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + # Check issue is removed + issue = issue_registry.async_get_issue(DOMAIN, "kitchen_sink_special_repair_issue") + assert issue is None + + +async def test_preview_feature_event_handler_registered( + hass: HomeAssistant, +) -> None: + """Test that preview feature event handler is registered on setup.""" + # Setup kitchen_sink + assert await async_setup_component(hass, "labs", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + # Track if event is handled + events_received = [] + + def track_event(event): + events_received.append(event) + + hass.bus.async_listen(EVENT_LABS_UPDATED, track_event) + await hass.async_block_till_done() + + # Fire a labs updated event for kitchen_sink preview feature + hass.bus.async_fire( + EVENT_LABS_UPDATED, + {"feature_id": "kitchen_sink.special_repair", "enabled": True}, + ) + await hass.async_block_till_done() + + # Verify event was received by our tracker + assert len(events_received) == 1 + assert events_received[0].data["feature_id"] == "kitchen_sink.special_repair" diff --git a/tests/components/labs/__init__.py b/tests/components/labs/__init__.py new file mode 100644 index 00000000000000..12eb7f9be974a2 --- /dev/null +++ b/tests/components/labs/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Labs integration.""" diff --git a/tests/components/labs/test_init.py b/tests/components/labs/test_init.py new file mode 100644 index 00000000000000..c8bd0e69d00ceb --- /dev/null +++ b/tests/components/labs/test_init.py @@ -0,0 +1,423 @@ +"""Tests for the Home Assistant Labs integration setup.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.labs import ( + EVENT_LABS_UPDATED, + LabsStorage, + async_is_preview_feature_enabled, + async_setup, +) +from homeassistant.components.labs.const import LABS_DATA, LabPreviewFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.loader import Integration + + +async def test_async_setup(hass: HomeAssistant) -> None: + """Test the Labs integration setup.""" + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + # Verify WebSocket commands are registered + assert "labs/list" in hass.data["websocket_api"] + assert "labs/update" in hass.data["websocket_api"] + + +async def test_async_is_preview_feature_enabled_not_setup(hass: HomeAssistant) -> None: + """Test checking if preview feature is enabled before setup returns False.""" + # Don't set up labs integration + result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + assert result is False + + +async def test_async_is_preview_feature_enabled_nonexistent( + hass: HomeAssistant, +) -> None: + """Test checking if non-existent preview feature is enabled.""" + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + result = async_is_preview_feature_enabled( + hass, "kitchen_sink", "nonexistent_feature" + ) + assert result is False + + +async def test_async_is_preview_feature_enabled_when_enabled( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test checking if preview feature is enabled.""" + # Load kitchen_sink integration so preview feature exists + hass.config.components.add("kitchen_sink") + + # Enable a preview feature via storage + hass_storage["core.labs"] = { + "version": 1, + "minor_version": 1, + "key": "core.labs", + "data": { + "preview_feature_status": [ + {"domain": "kitchen_sink", "preview_feature": "special_repair"} + ] + }, + } + + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + assert result is True + + +async def test_async_is_preview_feature_enabled_when_disabled( + hass: HomeAssistant, +) -> None: + """Test checking if preview feature is disabled (not in storage).""" + # Load kitchen_sink integration so preview feature exists + hass.config.components.add("kitchen_sink") + + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + result = async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + assert result is False + + +@pytest.mark.parametrize( + ("features_to_store", "expected_enabled", "expected_cleaned"), + [ + # Single stale feature cleanup + ( + [ + {"domain": "kitchen_sink", "preview_feature": "special_repair"}, + {"domain": "nonexistent_domain", "preview_feature": "fake_feature"}, + ], + [("kitchen_sink", "special_repair")], + [("nonexistent_domain", "fake_feature")], + ), + # Multiple stale features cleanup + ( + [ + {"domain": "kitchen_sink", "preview_feature": "special_repair"}, + {"domain": "stale_domain_1", "preview_feature": "old_feature"}, + {"domain": "stale_domain_2", "preview_feature": "another_old"}, + {"domain": "stale_domain_3", "preview_feature": "yet_another"}, + ], + [("kitchen_sink", "special_repair")], + [ + ("stale_domain_1", "old_feature"), + ("stale_domain_2", "another_old"), + ("stale_domain_3", "yet_another"), + ], + ), + # All features cleaned (no integrations loaded) + ( + [{"domain": "nonexistent", "preview_feature": "fake"}], + [], + [("nonexistent", "fake")], + ), + ], +) +async def test_storage_cleanup_stale_features( + hass: HomeAssistant, + hass_storage: dict[str, Any], + features_to_store: list[dict[str, str]], + expected_enabled: list[tuple[str, str]], + expected_cleaned: list[tuple[str, str]], +) -> None: + """Test that stale preview features are removed from storage on setup.""" + # Load kitchen_sink only if we expect any features to remain + if expected_enabled: + hass.config.components.add("kitchen_sink") + + hass_storage["core.labs"] = { + "version": 1, + "minor_version": 1, + "key": "core.labs", + "data": {"preview_feature_status": features_to_store}, + } + + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + # Verify expected features are preserved + for domain, feature in expected_enabled: + assert async_is_preview_feature_enabled(hass, domain, feature) + + # Verify stale features were cleaned up + for domain, feature in expected_cleaned: + assert not async_is_preview_feature_enabled(hass, domain, feature) + + +async def test_event_fired_on_preview_feature_update(hass: HomeAssistant) -> None: + """Test that labs_updated event is fired when preview feature is toggled.""" + # Load kitchen_sink integration + hass.config.components.add("kitchen_sink") + + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + events = [] + + def event_listener(event): + events.append(event) + + hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener) + + # Fire event manually to test listener (websocket handler does this) + hass.bus.async_fire( + EVENT_LABS_UPDATED, + { + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + }, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["domain"] == "kitchen_sink" + assert events[0].data["preview_feature"] == "special_repair" + assert events[0].data["enabled"] is True + + +@pytest.mark.parametrize( + ("domain", "preview_feature", "expected"), + [ + ("kitchen_sink", "special_repair", True), + ("other", "nonexistent", False), + ("kitchen_sink", "nonexistent", False), + ], +) +async def test_async_is_preview_feature_enabled( + hass: HomeAssistant, + hass_storage: dict[str, Any], + domain: str, + preview_feature: str, + expected: bool, +) -> None: + """Test async_is_preview_feature_enabled.""" + # Enable the kitchen_sink.special_repair preview feature via storage + hass_storage["core.labs"] = { + "version": 1, + "minor_version": 1, + "key": "core.labs", + "data": { + "preview_feature_status": [ + {"domain": "kitchen_sink", "preview_feature": "special_repair"} + ] + }, + } + + await async_setup(hass, {}) + await hass.async_block_till_done() + + result = async_is_preview_feature_enabled(hass, domain, preview_feature) + assert result is expected + + +async def test_multiple_setups_idempotent(hass: HomeAssistant) -> None: + """Test that calling async_setup multiple times is safe.""" + result1 = await async_setup(hass, {}) + assert result1 is True + + result2 = await async_setup(hass, {}) + assert result2 is True + + # Verify store is still accessible + assert LABS_DATA in hass.data + + +async def test_storage_load_missing_preview_feature_status_key( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test loading storage when preview_feature_status key is missing.""" + # Storage data without preview_feature_status key + hass_storage["core.labs"] = { + "version": 1, + "minor_version": 1, + "key": "core.labs", + "data": {}, # Missing preview_feature_status + } + + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + # Should initialize correctly - verify no feature is enabled + assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + + +async def test_preview_feature_full_key(hass: HomeAssistant) -> None: + """Test that preview feature full_key property returns correct format.""" + feature = LabPreviewFeature( + domain="test_domain", + preview_feature="test_feature", + feedback_url="https://feedback.example.com", + ) + + assert feature.full_key == "test_domain.test_feature" + + +async def test_preview_feature_to_dict_with_all_urls(hass: HomeAssistant) -> None: + """Test LabPreviewFeature.to_dict with all URLs populated.""" + feature = LabPreviewFeature( + domain="test_domain", + preview_feature="test_feature", + feedback_url="https://feedback.example.com", + learn_more_url="https://learn.example.com", + report_issue_url="https://issue.example.com", + ) + + result = feature.to_dict(enabled=True) + + assert result == { + "preview_feature": "test_feature", + "domain": "test_domain", + "enabled": True, + "is_built_in": True, + "feedback_url": "https://feedback.example.com", + "learn_more_url": "https://learn.example.com", + "report_issue_url": "https://issue.example.com", + } + + +async def test_preview_feature_to_dict_with_no_urls(hass: HomeAssistant) -> None: + """Test LabPreviewFeature.to_dict with no URLs (all None).""" + feature = LabPreviewFeature( + domain="test_domain", + preview_feature="test_feature", + ) + + result = feature.to_dict(enabled=False) + + assert result == { + "preview_feature": "test_feature", + "domain": "test_domain", + "enabled": False, + "is_built_in": True, + "feedback_url": None, + "learn_more_url": None, + "report_issue_url": None, + } + + +async def test_storage_load_returns_none_when_no_file( + hass: HomeAssistant, +) -> None: + """Test storage load when no file exists (returns None).""" + # Create a storage instance but don't write any data + store = LabsStorage(hass, 1, "test_labs_none.json") + + # Mock the parent Store's _async_load_data to return None + # This simulates the edge case where Store._async_load_data returns None + # This tests line 60: return None + async def mock_load_none(): + return None + + with patch.object(Store, "_async_load_data", new=mock_load_none): + result = await store.async_load() + assert result is None + + +async def test_custom_integration_with_preview_features( + hass: HomeAssistant, +) -> None: + """Test that custom integrations with preview features are loaded.""" + # Create a mock custom integration with preview features + mock_integration = Mock(spec=Integration) + mock_integration.domain = "custom_test" + mock_integration.preview_features = { + "test_feature": { + "feedback_url": "https://feedback.test", + "learn_more_url": "https://learn.test", + } + } + + with patch( + "homeassistant.components.labs.async_get_custom_components", + return_value={"custom_test": mock_integration}, + ): + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + # Verify the custom integration's preview feature can be checked + # (This confirms it was loaded properly) + assert not async_is_preview_feature_enabled(hass, "custom_test", "test_feature") + + +@pytest.mark.parametrize( + ("is_custom", "expected_is_built_in"), + [ + (False, True), # Built-in integration + (True, False), # Custom integration + ], +) +async def test_preview_feature_is_built_in_flag( + hass: HomeAssistant, + is_custom: bool, + expected_is_built_in: bool, +) -> None: + """Test that preview features have correct is_built_in flag.""" + if is_custom: + # Create a mock custom integration + mock_integration = Mock(spec=Integration) + mock_integration.domain = "custom_test" + mock_integration.preview_features = { + "custom_feature": {"feedback_url": "https://feedback.test"} + } + with patch( + "homeassistant.components.labs.async_get_custom_components", + return_value={"custom_test": mock_integration}, + ): + assert await async_setup(hass, {}) + await hass.async_block_till_done() + feature_key = "custom_test.custom_feature" + else: + # Load built-in kitchen_sink integration + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + feature_key = "kitchen_sink.special_repair" + + labs_data = hass.data[LABS_DATA] + assert feature_key in labs_data.preview_features + feature = labs_data.preview_features[feature_key] + assert feature.is_built_in is expected_is_built_in + + +@pytest.mark.parametrize( + ("is_built_in", "expected_default"), + [ + (True, True), + (False, False), + (None, True), # Default value when not specified + ], +) +async def test_preview_feature_to_dict_is_built_in( + hass: HomeAssistant, + is_built_in: bool | None, + expected_default: bool, +) -> None: + """Test that to_dict correctly handles is_built_in field.""" + if is_built_in is None: + # Test default value + feature = LabPreviewFeature( + domain="test_domain", + preview_feature="test_feature", + ) + else: + feature = LabPreviewFeature( + domain="test_domain", + preview_feature="test_feature", + is_built_in=is_built_in, + ) + + assert feature.is_built_in is expected_default + result = feature.to_dict(enabled=True) + assert result["is_built_in"] is expected_default diff --git a/tests/components/labs/test_websocket_api.py b/tests/components/labs/test_websocket_api.py new file mode 100644 index 00000000000000..a832469dffa648 --- /dev/null +++ b/tests/components/labs/test_websocket_api.py @@ -0,0 +1,654 @@ +"""Tests for the Home Assistant Labs WebSocket API.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import ANY, AsyncMock, patch + +import pytest + +from homeassistant.components.labs import ( + EVENT_LABS_UPDATED, + async_is_preview_feature_enabled, + async_setup, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockUser +from tests.typing import WebSocketGenerator + + +@pytest.mark.parametrize( + ("load_integration", "expected_features"), + [ + (False, []), # No integration loaded + ( + True, # Integration loaded + [ + { + "preview_feature": "special_repair", + "domain": "kitchen_sink", + "enabled": False, + "is_built_in": True, + "feedback_url": ANY, + "learn_more_url": ANY, + "report_issue_url": ANY, + } + ], + ), + ], +) +async def test_websocket_list_preview_features( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + load_integration: bool, + expected_features: list, +) -> None: + """Test listing preview features with different integration states.""" + if load_integration: + hass.config.components.add("kitchen_sink") + + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "labs/list"}) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == {"features": expected_features} + + +async def test_websocket_update_preview_feature_enable( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test enabling a preview feature via WebSocket.""" + # Load kitchen_sink integration + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Track events + events = [] + + def event_listener(event): + events.append(event) + + hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener) + + # Enable the preview feature + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] is None + + # Verify event was fired + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data["domain"] == "kitchen_sink" + assert events[0].data["preview_feature"] == "special_repair" + assert events[0].data["enabled"] is True + + # Verify feature is now enabled + assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + + +async def test_websocket_update_preview_feature_disable( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test disabling a preview feature via WebSocket.""" + # Pre-populate storage with enabled preview feature + hass_storage["core.labs"] = { + "version": 1, + "minor_version": 1, + "key": "core.labs", + "data": { + "preview_feature_status": [ + {"domain": "kitchen_sink", "preview_feature": "special_repair"} + ] + }, + } + + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": False, + } + ) + + msg = await client.receive_json() + assert msg["success"] + + # Verify feature is disabled + assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + + +async def test_websocket_update_nonexistent_feature( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test updating a preview feature that doesn't exist.""" + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "nonexistent", + "preview_feature": "feature", + "enabled": True, + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + assert "not found" in msg["error"]["message"].lower() + + +async def test_websocket_update_unavailable_preview_feature( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test updating a preview feature whose integration is not loaded still works.""" + # Don't load kitchen_sink integration + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Preview feature is pre-loaded, so update succeeds even though integration isn't loaded + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] is None + + +@pytest.mark.parametrize( + "command_type", + ["labs/list", "labs/update"], +) +async def test_websocket_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, + command_type: str, +) -> None: + """Test that websocket commands require admin privileges.""" + # Remove admin privileges + hass_admin_user.groups = [] + + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + command = {"type": command_type} + if command_type == "labs/update": + command.update( + { + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + + await client.send_json_auto_id(command) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + +async def test_websocket_update_validates_enabled_parameter( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that enabled parameter must be boolean.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Try with string instead of boolean + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": "true", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + # Validation error from voluptuous + + +async def test_storage_persists_preview_feature_across_calls( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that storage persists preview feature state across multiple calls.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Enable the preview feature + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg = await client.receive_json() + assert msg["success"] + + # List preview features - should show enabled + await client.send_json_auto_id({"type": "labs/list"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"]["features"][0]["enabled"] is True + + # Disable preview feature + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": False, + } + ) + msg = await client.receive_json() + assert msg["success"] + + # List preview features - should show disabled + await client.send_json_auto_id({"type": "labs/list"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"]["features"][0]["enabled"] is False + + +async def test_preview_feature_urls_present( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that preview features include feedback and report URLs.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "labs/list"}) + msg = await client.receive_json() + + assert msg["success"] + feature = msg["result"]["features"][0] + assert "feedback_url" in feature + assert "learn_more_url" in feature + assert "report_issue_url" in feature + assert feature["feedback_url"] is not None + assert feature["learn_more_url"] is not None + assert feature["report_issue_url"] is not None + + +@pytest.mark.parametrize( + ( + "create_backup", + "backup_fails", + "enabled", + "should_call_backup", + "should_succeed", + ), + [ + # Enable with successful backup + (True, False, True, True, True), + # Enable with failed backup + (True, True, True, True, False), + # Disable ignores backup flag + (True, False, False, False, True), + ], + ids=[ + "enable_with_backup_success", + "enable_with_backup_failure", + "disable_ignores_backup", + ], +) +async def test_websocket_update_preview_feature_backup_scenarios( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + create_backup: bool, + backup_fails: bool, + enabled: bool, + should_call_backup: bool, + should_succeed: bool, +) -> None: + """Test various backup scenarios when updating preview features.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Mock the backup manager + mock_backup_manager = AsyncMock() + if backup_fails: + mock_backup_manager.async_create_automatic_backup = AsyncMock( + side_effect=Exception("Backup failed") + ) + else: + mock_backup_manager.async_create_automatic_backup = AsyncMock() + + with patch( + "homeassistant.components.labs.async_get_manager", + return_value=mock_backup_manager, + ): + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": enabled, + "create_backup": create_backup, + } + ) + msg = await client.receive_json() + + if should_succeed: + assert msg["success"] + if should_call_backup: + mock_backup_manager.async_create_automatic_backup.assert_called_once() + else: + mock_backup_manager.async_create_automatic_backup.assert_not_called() + else: + assert not msg["success"] + assert msg["error"]["code"] == "unknown_error" + assert "backup" in msg["error"]["message"].lower() + # Verify preview feature was NOT enabled + assert not async_is_preview_feature_enabled( + hass, "kitchen_sink", "special_repair" + ) + + +async def test_websocket_list_multiple_enabled_features( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test listing when multiple preview features are enabled.""" + # Pre-populate with multiple enabled features + hass_storage["core.labs"] = { + "version": 1, + "data": { + "preview_feature_status": [ + {"domain": "kitchen_sink", "preview_feature": "special_repair"}, + ] + }, + } + + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "labs/list"}) + msg = await client.receive_json() + + assert msg["success"] + features = msg["result"]["features"] + assert len(features) >= 1 + # Verify at least one is enabled + enabled_features = [f for f in features if f["enabled"]] + assert len(enabled_features) == 1 + + +async def test_websocket_update_rapid_toggle( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test rapid toggling of a preview feature.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Enable + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg1 = await client.receive_json() + assert msg1["success"] + + # Disable immediately + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": False, + } + ) + msg2 = await client.receive_json() + assert msg2["success"] + + # Enable again + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg3 = await client.receive_json() + assert msg3["success"] + + # Final state should be enabled + assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + + +async def test_websocket_update_same_state_idempotent( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that enabling an already-enabled feature is idempotent.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Enable feature + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg1 = await client.receive_json() + assert msg1["success"] + + # Enable again (should be idempotent) + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + msg2 = await client.receive_json() + assert msg2["success"] + + # Should still be enabled + assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + + +async def test_websocket_list_filtered_by_loaded_components( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that list only shows features from loaded integrations.""" + # Don't load kitchen_sink - its preview feature shouldn't appear + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "labs/list"}) + msg = await client.receive_json() + + assert msg["success"] + # Should be empty since kitchen_sink isn't loaded + assert msg["result"]["features"] == [] + + # Now load kitchen_sink + hass.config.components.add("kitchen_sink") + + await client.send_json_auto_id({"type": "labs/list"}) + msg = await client.receive_json() + + assert msg["success"] + # Now should have kitchen_sink features + assert len(msg["result"]["features"]) >= 1 + + +async def test_websocket_update_with_missing_required_field( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that missing required fields are rejected.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Missing 'enabled' field + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + # enabled is missing + } + ) + msg = await client.receive_json() + + assert not msg["success"] + # Should get validation error + + +async def test_websocket_event_data_structure( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that event data has correct structure.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + events = [] + + def event_listener(event): + events.append(event) + + hass.bus.async_listen(EVENT_LABS_UPDATED, event_listener) + + # Enable a feature + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + } + ) + await client.receive_json() + await hass.async_block_till_done() + + assert len(events) == 1 + event_data = events[0].data + # Verify all required fields are present + assert "domain" in event_data + assert "preview_feature" in event_data + assert "enabled" in event_data + assert event_data["domain"] == "kitchen_sink" + assert event_data["preview_feature"] == "special_repair" + assert event_data["enabled"] is True + assert isinstance(event_data["enabled"], bool) + + +async def test_websocket_backup_timeout_handling( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test handling of backup timeout/long-running backup.""" + hass.config.components.add("kitchen_sink") + assert await async_setup(hass, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + # Mock backup manager with timeout + mock_backup_manager = AsyncMock() + mock_backup_manager.async_create_automatic_backup = AsyncMock( + side_effect=TimeoutError("Backup timed out") + ) + + with patch( + "homeassistant.components.labs.async_get_manager", + return_value=mock_backup_manager, + ): + await client.send_json_auto_id( + { + "type": "labs/update", + "domain": "kitchen_sink", + "preview_feature": "special_repair", + "enabled": True, + "create_backup": True, + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unknown_error"