diff --git a/homeassistant/components/automation/trace.py b/homeassistant/components/automation/trace.py index f5e93e09c38d..a2c2e40c80c6 100644 --- a/homeassistant/components/automation/trace.py +++ b/homeassistant/components/automation/trace.py @@ -2,148 +2,25 @@ from __future__ import annotations from contextlib import contextmanager -import datetime as dt -from itertools import count -from typing import Any, Deque -from homeassistant.components.trace.const import DATA_TRACE, STORED_TRACES -from homeassistant.components.trace.utils import LimitedSizeDict -from homeassistant.core import Context -from homeassistant.helpers.trace import TraceElement, trace_id_set -from homeassistant.util import dt as dt_util +from homeassistant.components.trace import AutomationTrace, async_store_trace # mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any -class AutomationTrace: - """Container for automation trace.""" - - _run_ids = count(0) - - def __init__( - self, - key: tuple[str, str], - config: dict[str, Any], - context: Context, - ): - """Container for automation trace.""" - self._action_trace: dict[str, Deque[TraceElement]] | None = None - self._condition_trace: dict[str, Deque[TraceElement]] | None = None - self._config: dict[str, Any] = config - self.context: Context = context - self._error: Exception | None = None - self._state: str = "running" - self.run_id: str = str(next(self._run_ids)) - self._timestamp_finish: dt.datetime | None = None - self._timestamp_start: dt.datetime = dt_util.utcnow() - self._key: tuple[str, str] = key - self._variables: dict[str, Any] | None = None - - def set_action_trace(self, trace: dict[str, Deque[TraceElement]]) -> None: - """Set action trace.""" - self._action_trace = trace - - def set_condition_trace(self, trace: dict[str, Deque[TraceElement]]) -> None: - """Set condition trace.""" - self._condition_trace = trace - - def set_error(self, ex: Exception) -> None: - """Set error.""" - self._error = ex - - def set_variables(self, variables: dict[str, Any]) -> None: - """Set variables.""" - self._variables = variables - - def finished(self) -> None: - """Set finish time.""" - self._timestamp_finish = dt_util.utcnow() - self._state = "stopped" - - def as_dict(self) -> dict[str, Any]: - """Return dictionary version of this AutomationTrace.""" - - result = self.as_short_dict() - - action_traces = {} - condition_traces = {} - if self._action_trace: - for key, trace_list in self._action_trace.items(): - action_traces[key] = [item.as_dict() for item in trace_list] - - if self._condition_trace: - for key, trace_list in self._condition_trace.items(): - condition_traces[key] = [item.as_dict() for item in trace_list] - - result.update( - { - "action_trace": action_traces, - "condition_trace": condition_traces, - "config": self._config, - "context": self.context, - "variables": self._variables, - } - ) - if self._error is not None: - result["error"] = str(self._error) - return result - - def as_short_dict(self) -> dict[str, Any]: - """Return a brief dictionary version of this AutomationTrace.""" - - last_action = None - last_condition = None - trigger = None - - if self._action_trace: - last_action = list(self._action_trace)[-1] - if self._condition_trace: - last_condition = list(self._condition_trace)[-1] - if self._variables: - trigger = self._variables.get("trigger", {}).get("description") - - result = { - "last_action": last_action, - "last_condition": last_condition, - "run_id": self.run_id, - "state": self._state, - "timestamp": { - "start": self._timestamp_start, - "finish": self._timestamp_finish, - }, - "trigger": trigger, - "domain": self._key[0], - "item_id": self._key[1], - } - if self._error is not None: - result["error"] = str(self._error) - if last_action is not None: - result["last_action"] = last_action - result["last_condition"] = last_condition - - return result - - @contextmanager def trace_automation(hass, item_id, config, context): - """Trace action execution of automation with automation_id.""" - key = ("automation", item_id) - trace = AutomationTrace(key, config, context) - trace_id_set((key, trace.run_id)) - - if key: - traces = hass.data[DATA_TRACE] - if key not in traces: - traces[key] = LimitedSizeDict(size_limit=STORED_TRACES) - traces[key][trace.run_id] = trace + """Trace action execution of automation with item_id.""" + trace = AutomationTrace(item_id, config, context) + async_store_trace(hass, trace) try: yield trace except Exception as ex: # pylint: disable=broad-except - if key: + if item_id: trace.set_error(ex) raise ex finally: - if key: + if item_id: trace.finished() diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 243cdaddd81c..b7586841eb79 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -36,8 +36,11 @@ from homeassistant.helpers.script import ( make_script_schema, ) from homeassistant.helpers.service import async_set_service_schema +from homeassistant.helpers.trace import trace_get, trace_path from homeassistant.loader import bind_hass +from .trace import trace_script + _LOGGER = logging.getLogger(__name__) DOMAIN = "script" @@ -221,7 +224,7 @@ async def _async_process_config(hass, config, component): ) script_entities = [ - ScriptEntity(hass, object_id, cfg) + ScriptEntity(hass, object_id, cfg, cfg.raw_config) for object_id, cfg in config.get(DOMAIN, {}).items() ] @@ -253,7 +256,7 @@ class ScriptEntity(ToggleEntity): icon = None - def __init__(self, hass, object_id, cfg): + def __init__(self, hass, object_id, cfg, raw_config): """Initialize the script.""" self.object_id = object_id self.icon = cfg.get(CONF_ICON) @@ -272,6 +275,7 @@ class ScriptEntity(ToggleEntity): variables=cfg.get(CONF_VARIABLES), ) self._changed = asyncio.Event() + self._raw_config = raw_config @property def should_poll(self): @@ -323,7 +327,7 @@ class ScriptEntity(ToggleEntity): {ATTR_NAME: self.script.name, ATTR_ENTITY_ID: self.entity_id}, context=context, ) - coro = self.script.async_run(variables, context) + coro = self._async_run(variables, context) if wait: await coro return @@ -335,6 +339,16 @@ class ScriptEntity(ToggleEntity): self.hass.async_create_task(coro) await self._changed.wait() + async def _async_run(self, variables, context): + with trace_script( + self.hass, self.object_id, self._raw_config, context + ) as script_trace: + script_trace.set_variables(variables) + # Prepare tracing the execution of the script's sequence + script_trace.set_action_trace(trace_get()) + with trace_path("sequence"): + return await self.script.async_run(variables, context) + async def async_turn_off(self, **kwargs): """Stop running the script. diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index 3860a4d0119d..5da8bec5a874 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -25,8 +25,21 @@ async def async_validate_config_item(hass, config, full_config=None): return config +class ScriptConfig(dict): + """Dummy class to allow adding attributes.""" + + raw_config = None + + async def _try_async_validate_config_item(hass, object_id, config, full_config=None): """Validate config item.""" + raw_config = None + try: + raw_config = dict(config) + except ValueError: + # Invalid config + pass + try: cv.slug(object_id) config = await async_validate_config_item(hass, config, full_config) @@ -34,6 +47,8 @@ async def _try_async_validate_config_item(hass, object_id, config, full_config=N async_log_exception(ex, DOMAIN, full_config or config, hass) return None + config = ScriptConfig(config) + config.raw_config = raw_config return config diff --git a/homeassistant/components/script/manifest.json b/homeassistant/components/script/manifest.json index b9d333ce553a..ab14889a60cf 100644 --- a/homeassistant/components/script/manifest.json +++ b/homeassistant/components/script/manifest.json @@ -2,6 +2,7 @@ "domain": "script", "name": "Scripts", "documentation": "https://www.home-assistant.io/integrations/script", + "dependencies": ["trace"], "codeowners": [ "@home-assistant/core" ], diff --git a/homeassistant/components/script/trace.py b/homeassistant/components/script/trace.py new file mode 100644 index 000000000000..09b22f981333 --- /dev/null +++ b/homeassistant/components/script/trace.py @@ -0,0 +1,23 @@ +"""Trace support for script.""" +from __future__ import annotations + +from contextlib import contextmanager + +from homeassistant.components.trace import ScriptTrace, async_store_trace + + +@contextmanager +def trace_script(hass, item_id, config, context): + """Trace execution of a script.""" + trace = ScriptTrace(item_id, config, context) + async_store_trace(hass, trace) + + try: + yield trace + except Exception as ex: # pylint: disable=broad-except + if item_id: + trace.set_error(ex) + raise ex + finally: + if item_id: + trace.finished() diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 0dc8cda6664f..43deefaa769a 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -1,12 +1,188 @@ -"""Support for automation and script tracing and debugging.""" +"""Support for script and automation tracing and debugging.""" +from __future__ import annotations + +import datetime as dt +from itertools import count +from typing import Any, Deque + +from homeassistant.core import Context +from homeassistant.helpers.trace import TraceElement, trace_id_set +import homeassistant.util.dt as dt_util + from . import websocket_api -from .const import DATA_TRACE +from .const import DATA_TRACE, STORED_TRACES +from .utils import LimitedSizeDict DOMAIN = "trace" async def async_setup(hass, config): """Initialize the trace integration.""" - hass.data.setdefault(DATA_TRACE, {}) + hass.data[DATA_TRACE] = {} websocket_api.async_setup(hass) return True + + +def async_store_trace(hass, trace): + """Store a trace if its item_id is valid.""" + key = trace.key + if key[1]: + traces = hass.data[DATA_TRACE] + if key not in traces: + traces[key] = LimitedSizeDict(size_limit=STORED_TRACES) + traces[key][trace.run_id] = trace + + +class ActionTrace: + """Base container for an script or automation trace.""" + + _run_ids = count(0) + + def __init__( + self, + key: tuple[str, str], + config: dict[str, Any], + context: Context, + ): + """Container for script trace.""" + self._action_trace: dict[str, Deque[TraceElement]] | None = None + self._config: dict[str, Any] = config + self.context: Context = context + self._error: Exception | None = None + self._state: str = "running" + self.run_id: str = str(next(self._run_ids)) + self._timestamp_finish: dt.datetime | None = None + self._timestamp_start: dt.datetime = dt_util.utcnow() + self.key: tuple[str, str] = key + self._variables: dict[str, Any] | None = None + trace_id_set((key, self.run_id)) + + def set_action_trace(self, trace: dict[str, Deque[TraceElement]]) -> None: + """Set action trace.""" + self._action_trace = trace + + def set_error(self, ex: Exception) -> None: + """Set error.""" + self._error = ex + + def set_variables(self, variables: dict[str, Any]) -> None: + """Set variables.""" + self._variables = variables + + def finished(self) -> None: + """Set finish time.""" + self._timestamp_finish = dt_util.utcnow() + self._state = "stopped" + + def as_dict(self) -> dict[str, Any]: + """Return dictionary version of this ActionTrace.""" + + result = self.as_short_dict() + + action_traces = {} + if self._action_trace: + for key, trace_list in self._action_trace.items(): + action_traces[key] = [item.as_dict() for item in trace_list] + + result.update( + { + "action_trace": action_traces, + "config": self._config, + "context": self.context, + "variables": self._variables, + } + ) + if self._error is not None: + result["error"] = str(self._error) + return result + + def as_short_dict(self) -> dict[str, Any]: + """Return a brief dictionary version of this ActionTrace.""" + + last_action = None + + if self._action_trace: + last_action = list(self._action_trace)[-1] + + result = { + "last_action": last_action, + "run_id": self.run_id, + "state": self._state, + "timestamp": { + "start": self._timestamp_start, + "finish": self._timestamp_finish, + }, + "domain": self.key[0], + "item_id": self.key[1], + } + if self._error is not None: + result["error"] = str(self._error) + if last_action is not None: + result["last_action"] = last_action + + return result + + +class AutomationTrace(ActionTrace): + """Container for automation trace.""" + + def __init__( + self, + item_id: str, + config: dict[str, Any], + context: Context, + ): + """Container for automation trace.""" + key = ("automation", item_id) + super().__init__(key, config, context) + self._condition_trace: dict[str, Deque[TraceElement]] | None = None + + def set_condition_trace(self, trace: dict[str, Deque[TraceElement]]) -> None: + """Set condition trace.""" + self._condition_trace = trace + + def as_dict(self) -> dict[str, Any]: + """Return dictionary version of this AutomationTrace.""" + + result = super().as_dict() + + condition_traces = {} + + if self._condition_trace: + for key, trace_list in self._condition_trace.items(): + condition_traces[key] = [item.as_dict() for item in trace_list] + result["condition_trace"] = condition_traces + + return result + + def as_short_dict(self) -> dict[str, Any]: + """Return a brief dictionary version of this AutomationTrace.""" + + result = super().as_short_dict() + + last_condition = None + trigger = None + + if self._condition_trace: + last_condition = list(self._condition_trace)[-1] + if self._variables: + trigger = self._variables.get("trigger", {}).get("description") + + result["trigger"] = trigger + result["last_condition"] = last_condition + + return result + + +class ScriptTrace(ActionTrace): + """Container for automation trace.""" + + def __init__( + self, + item_id: str, + config: dict[str, Any], + context: Context, + ): + """Container for automation trace.""" + key = ("script", item_id) + super().__init__(key, config, context) diff --git a/homeassistant/components/trace/const.py b/homeassistant/components/trace/const.py index 547bdb35c778..05942d7ee4dc 100644 --- a/homeassistant/components/trace/const.py +++ b/homeassistant/components/trace/const.py @@ -1,4 +1,4 @@ -"""Shared constants for automation and script tracing and debugging.""" +"""Shared constants for script and automation tracing and debugging.""" DATA_TRACE = "trace" -STORED_TRACES = 5 # Stored traces per automation +STORED_TRACES = 5 # Stored traces per script or automation diff --git a/homeassistant/components/trace/websocket_api.py b/homeassistant/components/trace/websocket_api.py index 1f42b50671ec..1b5270f6253e 100644 --- a/homeassistant/components/trace/websocket_api.py +++ b/homeassistant/components/trace/websocket_api.py @@ -28,7 +28,7 @@ from .utils import TraceJSONEncoder # mypy: allow-untyped-calls, allow-untyped-defs -TRACE_DOMAINS = ["automation"] +TRACE_DOMAINS = ["automation", "script"] @callback diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index d6de845248f1..ba39e19943b8 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -71,7 +71,7 @@ trace_path_stack_cv: ContextVar[list[str] | None] = ContextVar( ) # Copy of last variables variables_cv: ContextVar[Any | None] = ContextVar("variables_cv", default=None) -# Automation ID + Run ID +# (domain, item_id) + Run ID trace_id_cv: ContextVar[tuple[str, str] | None] = ContextVar( "trace_id_cv", default=None ) diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index e07c042d1d0e..8dc09731b79a 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -1,31 +1,42 @@ """Test Trace websocket API.""" -from unittest.mock import patch +import pytest from homeassistant.bootstrap import async_setup_component -from homeassistant.components import config from homeassistant.components.trace.const import STORED_TRACES from homeassistant.core import Context from tests.common import assert_lists_same -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 -def _find_run_id(traces, item_id): - """Find newest run_id for an automation.""" +def _find_run_id(traces, trace_type, item_id): + """Find newest run_id for an automation or script.""" for trace in reversed(traces): - if trace["item_id"] == item_id: + if trace["domain"] == trace_type and trace["item_id"] == item_id: return trace["run_id"] return None +def _find_traces(traces, trace_type, item_id): + """Find traces for an automation or script.""" + return [ + trace + for trace in traces + if trace["domain"] == trace_type and trace["item_id"] == item_id + ] + + +# TODO: Remove def _find_traces_for_automation(traces, item_id): """Find traces for an automation.""" return [trace for trace in traces if trace["item_id"] == item_id] -async def test_get_automation_trace(hass, hass_ws_client): - """Test tracing an automation.""" +@pytest.mark.parametrize( + "domain, prefix", [("automation", "action"), ("script", "sequence")] +) +async def test_get_trace(hass, hass_ws_client, domain, prefix): + """Test tracing an automation or script.""" id = 1 def next_id(): @@ -50,6 +61,9 @@ async def test_get_automation_trace(hass, hass_ws_client): }, "action": {"event": "another_event"}, } + if domain == "script": + sun_config = {"sequence": sun_config["action"]} + moon_config = {"sequence": moon_config["action"]} sun_action = { "limit": 10, @@ -63,40 +77,38 @@ async def test_get_automation_trace(hass, hass_ws_client): } moon_action = {"event": "another_event", "event_data": {}} - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - moon_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) + if domain == "automation": + assert await async_setup_component( + hass, domain, {domain: [sun_config, moon_config]} + ) + else: + assert await async_setup_component( + hass, domain, {domain: {"sun": sun_config, "moon": moon_config}} + ) client = await hass_ws_client() contexts = {} - # Trigger "sun" automation + # Trigger "sun" automation / run "sun" script context = Context() - hass.bus.async_fire("test_event", context=context) + if domain == "automation": + hass.bus.async_fire("test_event", context=context) + else: + await hass.services.async_call("script", "sun", context=context) await hass.async_block_till_done() # List traces await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - run_id = _find_run_id(response["result"], "sun") + run_id = _find_run_id(response["result"], domain, "sun") # Get trace await client.send_json( { "id": next_id(), "type": "trace/get", - "domain": "automation", + "domain": domain, "item_id": "sun", "run_id": run_id, } @@ -104,41 +116,47 @@ async def test_get_automation_trace(hass, hass_ws_client): response = await client.receive_json() assert response["success"] trace = response["result"] - assert trace["context"]["parent_id"] == context.id assert len(trace["action_trace"]) == 1 - assert len(trace["action_trace"]["action/0"]) == 1 - assert trace["action_trace"]["action/0"][0]["error"] - assert trace["action_trace"]["action/0"][0]["result"] == sun_action - assert trace["condition_trace"] == {} + assert len(trace["action_trace"][f"{prefix}/0"]) == 1 + assert trace["action_trace"][f"{prefix}/0"][0]["error"] + assert trace["action_trace"][f"{prefix}/0"][0]["result"] == sun_action assert trace["config"] == sun_config assert trace["context"] assert trace["error"] == "Unable to find service test.automation" assert trace["state"] == "stopped" - assert trace["trigger"] == "event 'test_event'" assert trace["item_id"] == "sun" - assert trace["variables"] + assert trace["variables"] is not None + if domain == "automation": + assert trace["condition_trace"] == {} + assert trace["context"]["parent_id"] == context.id + assert trace["trigger"] == "event 'test_event'" + else: + assert trace["context"]["id"] == context.id contexts[trace["context"]["id"]] = { "run_id": trace["run_id"], - "domain": "automation", + "domain": domain, "item_id": trace["item_id"], } - # Trigger "moon" automation, with passing condition - hass.bus.async_fire("test_event2") + # Trigger "moon" automation, with passing condition / run "moon" script + if domain == "automation": + hass.bus.async_fire("test_event2") + else: + await hass.services.async_call("script", "moon") await hass.async_block_till_done() # List traces await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - run_id = _find_run_id(response["result"], "moon") + run_id = _find_run_id(response["result"], domain, "moon") # Get trace await client.send_json( { "id": next_id(), "type": "trace/get", - "domain": "automation", + "domain": domain, "item_id": "moon", "run_id": run_id, } @@ -147,26 +165,36 @@ async def test_get_automation_trace(hass, hass_ws_client): assert response["success"] trace = response["result"] assert len(trace["action_trace"]) == 1 - assert len(trace["action_trace"]["action/0"]) == 1 - assert "error" not in trace["action_trace"]["action/0"][0] - assert trace["action_trace"]["action/0"][0]["result"] == moon_action - assert len(trace["condition_trace"]) == 1 - assert len(trace["condition_trace"]["condition/0"]) == 1 - assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": True} + assert len(trace["action_trace"][f"{prefix}/0"]) == 1 + assert "error" not in trace["action_trace"][f"{prefix}/0"][0] + assert trace["action_trace"][f"{prefix}/0"][0]["result"] == moon_action assert trace["config"] == moon_config assert trace["context"] assert "error" not in trace assert trace["state"] == "stopped" - assert trace["trigger"] == "event 'test_event2'" assert trace["item_id"] == "moon" - assert trace["variables"] + assert trace["variables"] is not None + + if domain == "automation": + assert len(trace["condition_trace"]) == 1 + assert len(trace["condition_trace"]["condition/0"]) == 1 + assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": True} + assert trace["trigger"] == "event 'test_event2'" contexts[trace["context"]["id"]] = { "run_id": trace["run_id"], - "domain": "automation", + "domain": domain, "item_id": trace["item_id"], } - # Trigger "moon" automation, with failing condition + if domain == "script": + # Check contexts + await client.send_json({"id": next_id(), "type": "trace/contexts"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == contexts + return + + # Trigger "moon" automation with failing condition hass.bus.async_fire("test_event3") await hass.async_block_till_done() @@ -174,14 +202,14 @@ async def test_get_automation_trace(hass, hass_ws_client): await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - run_id = _find_run_id(response["result"], "moon") + run_id = _find_run_id(response["result"], "automation", "moon") # Get trace await client.send_json( { "id": next_id(), "type": "trace/get", - "domain": "automation", + "domain": domain, "item_id": "moon", "run_id": run_id, } @@ -202,11 +230,11 @@ async def test_get_automation_trace(hass, hass_ws_client): assert trace["variables"] contexts[trace["context"]["id"]] = { "run_id": trace["run_id"], - "domain": "automation", + "domain": domain, "item_id": trace["item_id"], } - # Trigger "moon" automation, with passing condition + # Trigger "moon" automation with passing condition hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -214,14 +242,14 @@ async def test_get_automation_trace(hass, hass_ws_client): await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - run_id = _find_run_id(response["result"], "moon") + run_id = _find_run_id(response["result"], "automation", "moon") # Get trace await client.send_json( { "id": next_id(), "type": "trace/get", - "domain": "automation", + "domain": domain, "item_id": "moon", "run_id": run_id, } @@ -230,9 +258,9 @@ async def test_get_automation_trace(hass, hass_ws_client): assert response["success"] trace = response["result"] assert len(trace["action_trace"]) == 1 - assert len(trace["action_trace"]["action/0"]) == 1 - assert "error" not in trace["action_trace"]["action/0"][0] - assert trace["action_trace"]["action/0"][0]["result"] == moon_action + assert len(trace["action_trace"][f"{prefix}/0"]) == 1 + assert "error" not in trace["action_trace"][f"{prefix}/0"][0] + assert trace["action_trace"][f"{prefix}/0"][0]["result"] == moon_action assert len(trace["condition_trace"]) == 1 assert len(trace["condition_trace"]["condition/0"]) == 1 assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": True} @@ -245,7 +273,7 @@ async def test_get_automation_trace(hass, hass_ws_client): assert trace["variables"] contexts[trace["context"]["id"]] = { "run_id": trace["run_id"], - "domain": "automation", + "domain": domain, "item_id": trace["item_id"], } @@ -256,8 +284,9 @@ async def test_get_automation_trace(hass, hass_ws_client): assert response["result"] == contexts -async def test_automation_trace_overflow(hass, hass_ws_client): - """Test the number of stored traces per automation is limited.""" +@pytest.mark.parametrize("domain", ["automation", "script"]) +async def test_trace_overflow(hass, hass_ws_client, domain): + """Test the number of stored traces per automation or script is limited.""" id = 1 def next_id(): @@ -275,20 +304,18 @@ async def test_automation_trace_overflow(hass, hass_ws_client): "trigger": {"platform": "event", "event_type": "test_event2"}, "action": {"event": "another_event"}, } + if domain == "script": + sun_config = {"sequence": sun_config["action"]} + moon_config = {"sequence": moon_config["action"]} - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - moon_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) + if domain == "automation": + assert await async_setup_component( + hass, domain, {domain: [sun_config, moon_config]} + ) + else: + assert await async_setup_component( + hass, domain, {domain: {"sun": sun_config, "moon": moon_config}} + ) client = await hass_ws_client() @@ -297,37 +324,47 @@ async def test_automation_trace_overflow(hass, hass_ws_client): assert response["success"] assert response["result"] == [] - # Trigger "sun" and "moon" automation once - hass.bus.async_fire("test_event") - hass.bus.async_fire("test_event2") + # Trigger "sun" and "moon" automation / script once + if domain == "automation": + hass.bus.async_fire("test_event") + hass.bus.async_fire("test_event2") + else: + await hass.services.async_call("script", "sun") + await hass.services.async_call("script", "moon") await hass.async_block_till_done() # List traces await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - assert len(_find_traces_for_automation(response["result"], "moon")) == 1 - moon_run_id = _find_run_id(response["result"], "moon") - assert len(_find_traces_for_automation(response["result"], "sun")) == 1 + assert len(_find_traces(response["result"], domain, "moon")) == 1 + moon_run_id = _find_run_id(response["result"], domain, "moon") + assert len(_find_traces(response["result"], domain, "sun")) == 1 - # Trigger "moon" automation enough times to overflow the number of stored traces + # Trigger "moon" enough times to overflow the max number of stored traces for _ in range(STORED_TRACES): - hass.bus.async_fire("test_event2") + if domain == "automation": + hass.bus.async_fire("test_event2") + else: + await hass.services.async_call("script", "moon") await hass.async_block_till_done() await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - moon_traces = _find_traces_for_automation(response["result"], "moon") + moon_traces = _find_traces(response["result"], domain, "moon") assert len(moon_traces) == STORED_TRACES assert moon_traces[0] assert int(moon_traces[0]["run_id"]) == int(moon_run_id) + 1 assert int(moon_traces[-1]["run_id"]) == int(moon_run_id) + STORED_TRACES - assert len(_find_traces_for_automation(response["result"], "sun")) == 1 + assert len(_find_traces(response["result"], domain, "sun")) == 1 -async def test_list_automation_traces(hass, hass_ws_client): - """Test listing automation traces.""" +@pytest.mark.parametrize( + "domain, prefix", [("automation", "action"), ("script", "sequence")] +) +async def test_list_traces(hass, hass_ws_client, domain, prefix): + """Test listing automation and script traces.""" id = 1 def next_id(): @@ -352,20 +389,18 @@ async def test_list_automation_traces(hass, hass_ws_client): }, "action": {"event": "another_event"}, } + if domain == "script": + sun_config = {"sequence": sun_config["action"]} + moon_config = {"sequence": moon_config["action"]} - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - moon_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) + if domain == "automation": + assert await async_setup_component( + hass, domain, {domain: [sun_config, moon_config]} + ) + else: + assert await async_setup_component( + hass, domain, {domain: {"sun": sun_config, "moon": moon_config}} + ) client = await hass_ws_client() @@ -375,19 +410,17 @@ async def test_list_automation_traces(hass, hass_ws_client): assert response["result"] == [] await client.send_json( - { - "id": next_id(), - "type": "trace/list", - "domain": "automation", - "item_id": "sun", - } + {"id": next_id(), "type": "trace/list", "domain": domain, "item_id": "sun"} ) response = await client.receive_json() assert response["success"] assert response["result"] == [] - # Trigger "sun" automation - hass.bus.async_fire("test_event") + # Trigger "sun" automation / run "sun" script + if domain == "automation": + hass.bus.async_fire("test_event") + else: + await hass.services.async_call("script", "sun") await hass.async_block_till_done() # Get trace @@ -395,90 +428,98 @@ async def test_list_automation_traces(hass, hass_ws_client): response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 - assert len(_find_traces_for_automation(response["result"], "sun")) == 1 + assert len(_find_traces(response["result"], domain, "sun")) == 1 await client.send_json( - { - "id": next_id(), - "type": "trace/list", - "domain": "automation", - "item_id": "sun", - } + {"id": next_id(), "type": "trace/list", "domain": domain, "item_id": "sun"} ) response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 - assert len(_find_traces_for_automation(response["result"], "sun")) == 1 + assert len(_find_traces(response["result"], domain, "sun")) == 1 await client.send_json( - { - "id": next_id(), - "type": "trace/list", - "domain": "automation", - "item_id": "moon", - } + {"id": next_id(), "type": "trace/list", "domain": domain, "item_id": "moon"} ) response = await client.receive_json() assert response["success"] assert response["result"] == [] - # Trigger "moon" automation, with passing condition - hass.bus.async_fire("test_event2") + # Trigger "moon" automation, with passing condition / run "moon" script + if domain == "automation": + hass.bus.async_fire("test_event2") + else: + await hass.services.async_call("script", "moon") await hass.async_block_till_done() - # Trigger "moon" automation, with failing condition - hass.bus.async_fire("test_event3") + # Trigger "moon" automation, with failing condition / run "moon" script + if domain == "automation": + hass.bus.async_fire("test_event3") + else: + await hass.services.async_call("script", "moon") await hass.async_block_till_done() - # Trigger "moon" automation, with passing condition - hass.bus.async_fire("test_event2") + # Trigger "moon" automation, with passing condition / run "moon" script + if domain == "automation": + hass.bus.async_fire("test_event2") + else: + await hass.services.async_call("script", "moon") await hass.async_block_till_done() # Get trace await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - assert len(_find_traces_for_automation(response["result"], "moon")) == 3 - assert len(_find_traces_for_automation(response["result"], "sun")) == 1 - trace = _find_traces_for_automation(response["result"], "sun")[0] - assert trace["last_action"] == "action/0" - assert trace["last_condition"] is None + assert len(_find_traces(response["result"], domain, "moon")) == 3 + assert len(_find_traces(response["result"], domain, "sun")) == 1 + trace = _find_traces(response["result"], domain, "sun")[0] + assert trace["last_action"] == f"{prefix}/0" assert trace["error"] == "Unable to find service test.automation" assert trace["state"] == "stopped" assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event'" assert trace["item_id"] == "sun" + if domain == "automation": + assert trace["last_condition"] is None + assert trace["trigger"] == "event 'test_event'" - trace = _find_traces_for_automation(response["result"], "moon")[0] - assert trace["last_action"] == "action/0" - assert trace["last_condition"] == "condition/0" + trace = _find_traces(response["result"], domain, "moon")[0] + assert trace["last_action"] == f"{prefix}/0" assert "error" not in trace assert trace["state"] == "stopped" assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event2'" assert trace["item_id"] == "moon" + if domain == "automation": + assert trace["last_condition"] == "condition/0" + assert trace["trigger"] == "event 'test_event2'" - trace = _find_traces_for_automation(response["result"], "moon")[1] - assert trace["last_action"] is None - assert trace["last_condition"] == "condition/0" + trace = _find_traces(response["result"], domain, "moon")[1] assert "error" not in trace assert trace["state"] == "stopped" assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event3'" assert trace["item_id"] == "moon" + if domain == "automation": + assert trace["last_action"] is None + assert trace["last_condition"] == "condition/0" + assert trace["trigger"] == "event 'test_event3'" + else: + assert trace["last_action"] == f"{prefix}/0" - trace = _find_traces_for_automation(response["result"], "moon")[2] - assert trace["last_action"] == "action/0" - assert trace["last_condition"] == "condition/0" + trace = _find_traces(response["result"], domain, "moon")[2] + assert trace["last_action"] == f"{prefix}/0" assert "error" not in trace assert trace["state"] == "stopped" assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event2'" assert trace["item_id"] == "moon" + if domain == "automation": + assert trace["last_condition"] == "condition/0" + assert trace["trigger"] == "event 'test_event2'" -async def test_automation_breakpoints(hass, hass_ws_client): - """Test automation breakpoints.""" +@pytest.mark.parametrize( + "domain, prefix", [("automation", "action"), ("script", "sequence")] +) +async def test_breakpoints(hass, hass_ws_client, domain, prefix): + """Test automation and script breakpoints.""" id = 1 def next_id(): @@ -490,7 +531,7 @@ async def test_automation_breakpoints(hass, hass_ws_client): await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - trace = _find_traces_for_automation(response["result"], item_id)[-1] + trace = _find_traces(response["result"], domain, item_id)[-1] assert trace["last_action"] == expected_action assert trace["state"] == expected_state return trace["run_id"] @@ -510,19 +551,13 @@ async def test_automation_breakpoints(hass, hass_ws_client): {"event": "event8"}, ], } + if domain == "script": + sun_config = {"sequence": sun_config["action"]} - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) + if domain == "automation": + assert await async_setup_component(hass, domain, {domain: [sun_config]}) + else: + assert await async_setup_component(hass, domain, {domain: {"sun": sun_config}}) client = await hass_ws_client() @@ -530,7 +565,7 @@ async def test_automation_breakpoints(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/breakpoint/set", - "domain": "automation", + "domain": domain, "item_id": "sun", "node": "1", } @@ -554,9 +589,9 @@ async def test_automation_breakpoints(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/breakpoint/set", - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/1", + "node": f"{prefix}/1", } ) response = await client.receive_json() @@ -565,9 +600,9 @@ async def test_automation_breakpoints(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/breakpoint/set", - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/5", + "node": f"{prefix}/5", } ) response = await client.receive_json() @@ -579,30 +614,23 @@ async def test_automation_breakpoints(hass, hass_ws_client): assert_lists_same( response["result"], [ - { - "node": "action/1", - "run_id": "*", - "domain": "automation", - "item_id": "sun", - }, - { - "node": "action/5", - "run_id": "*", - "domain": "automation", - "item_id": "sun", - }, + {"node": f"{prefix}/1", "run_id": "*", "domain": domain, "item_id": "sun"}, + {"node": f"{prefix}/5", "run_id": "*", "domain": domain, "item_id": "sun"}, ], ) - # Trigger "sun" automation - hass.bus.async_fire("test_event") + # Trigger "sun" automation / run "sun" script + if domain == "automation": + hass.bus.async_fire("test_event") + else: + await hass.services.async_call("script", "sun") response = await client.receive_json() - run_id = await assert_last_action("sun", "action/1", "running") + run_id = await assert_last_action("sun", f"{prefix}/1", "running") assert response["event"] == { - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/1", + "node": f"{prefix}/1", "run_id": run_id, } @@ -610,7 +638,7 @@ async def test_automation_breakpoints(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/step", - "domain": "automation", + "domain": domain, "item_id": "sun", "run_id": run_id, } @@ -619,11 +647,11 @@ async def test_automation_breakpoints(hass, hass_ws_client): assert response["success"] response = await client.receive_json() - run_id = await assert_last_action("sun", "action/2", "running") + run_id = await assert_last_action("sun", f"{prefix}/2", "running") assert response["event"] == { - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/2", + "node": f"{prefix}/2", "run_id": run_id, } @@ -631,7 +659,7 @@ async def test_automation_breakpoints(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/continue", - "domain": "automation", + "domain": domain, "item_id": "sun", "run_id": run_id, } @@ -640,11 +668,11 @@ async def test_automation_breakpoints(hass, hass_ws_client): assert response["success"] response = await client.receive_json() - run_id = await assert_last_action("sun", "action/5", "running") + run_id = await assert_last_action("sun", f"{prefix}/5", "running") assert response["event"] == { - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/5", + "node": f"{prefix}/5", "run_id": run_id, } @@ -652,7 +680,7 @@ async def test_automation_breakpoints(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/stop", - "domain": "automation", + "domain": domain, "item_id": "sun", "run_id": run_id, } @@ -660,10 +688,13 @@ async def test_automation_breakpoints(hass, hass_ws_client): response = await client.receive_json() assert response["success"] await hass.async_block_till_done() - await assert_last_action("sun", "action/5", "stopped") + await assert_last_action("sun", f"{prefix}/5", "stopped") -async def test_automation_breakpoints_2(hass, hass_ws_client): +@pytest.mark.parametrize( + "domain, prefix", [("automation", "action"), ("script", "sequence")] +) +async def test_breakpoints_2(hass, hass_ws_client, domain, prefix): """Test execution resumes and breakpoints are removed after subscription removed.""" id = 1 @@ -676,7 +707,7 @@ async def test_automation_breakpoints_2(hass, hass_ws_client): await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - trace = _find_traces_for_automation(response["result"], item_id)[-1] + trace = _find_traces(response["result"], domain, item_id)[-1] assert trace["last_action"] == expected_action assert trace["state"] == expected_state return trace["run_id"] @@ -696,19 +727,13 @@ async def test_automation_breakpoints_2(hass, hass_ws_client): {"event": "event8"}, ], } + if domain == "script": + sun_config = {"sequence": sun_config["action"]} - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) + if domain == "automation": + assert await async_setup_component(hass, domain, {domain: [sun_config]}) + else: + assert await async_setup_component(hass, domain, {domain: {"sun": sun_config}}) client = await hass_ws_client() @@ -723,23 +748,26 @@ async def test_automation_breakpoints_2(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/breakpoint/set", - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/1", + "node": f"{prefix}/1", } ) response = await client.receive_json() assert response["success"] - # Trigger "sun" automation - hass.bus.async_fire("test_event") + # Trigger "sun" automation / run "sun" script + if domain == "automation": + hass.bus.async_fire("test_event") + else: + await hass.services.async_call("script", "sun") response = await client.receive_json() - run_id = await assert_last_action("sun", "action/1", "running") + run_id = await assert_last_action("sun", f"{prefix}/1", "running") assert response["event"] == { - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/1", + "node": f"{prefix}/1", "run_id": run_id, } @@ -750,14 +778,14 @@ async def test_automation_breakpoints_2(hass, hass_ws_client): response = await client.receive_json() assert response["success"] await hass.async_block_till_done() - await assert_last_action("sun", "action/8", "stopped") + await assert_last_action("sun", f"{prefix}/8", "stopped") # Should not be possible to set breakpoints await client.send_json( { "id": next_id(), "type": "trace/debug/breakpoint/set", - "domain": "automation", + "domain": domain, "item_id": "sun", "node": "1", } @@ -765,15 +793,21 @@ async def test_automation_breakpoints_2(hass, hass_ws_client): response = await client.receive_json() assert not response["success"] - # Trigger "sun" automation, should finish without stopping on breakpoints - hass.bus.async_fire("test_event") + # Trigger "sun" automation / script, should finish without stopping on breakpoints + if domain == "automation": + hass.bus.async_fire("test_event") + else: + await hass.services.async_call("script", "sun") await hass.async_block_till_done() - new_run_id = await assert_last_action("sun", "action/8", "stopped") + new_run_id = await assert_last_action("sun", f"{prefix}/8", "stopped") assert new_run_id != run_id -async def test_automation_breakpoints_3(hass, hass_ws_client): +@pytest.mark.parametrize( + "domain, prefix", [("automation", "action"), ("script", "sequence")] +) +async def test_breakpoints_3(hass, hass_ws_client, domain, prefix): """Test breakpoints can be cleared.""" id = 1 @@ -786,7 +820,7 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): await client.send_json({"id": next_id(), "type": "trace/list"}) response = await client.receive_json() assert response["success"] - trace = _find_traces_for_automation(response["result"], item_id)[-1] + trace = _find_traces(response["result"], domain, item_id)[-1] assert trace["last_action"] == expected_action assert trace["state"] == expected_state return trace["run_id"] @@ -806,19 +840,13 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): {"event": "event8"}, ], } + if domain == "script": + sun_config = {"sequence": sun_config["action"]} - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) + if domain == "automation": + assert await async_setup_component(hass, domain, {domain: [sun_config]}) + else: + assert await async_setup_component(hass, domain, {domain: {"sun": sun_config}}) client = await hass_ws_client() @@ -833,9 +861,9 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/breakpoint/set", - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/1", + "node": f"{prefix}/1", } ) response = await client.receive_json() @@ -845,23 +873,26 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/breakpoint/set", - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/5", + "node": f"{prefix}/5", } ) response = await client.receive_json() assert response["success"] - # Trigger "sun" automation - hass.bus.async_fire("test_event") + # Trigger "sun" automation / run "sun" script + if domain == "automation": + hass.bus.async_fire("test_event") + else: + await hass.services.async_call("script", "sun") response = await client.receive_json() - run_id = await assert_last_action("sun", "action/1", "running") + run_id = await assert_last_action("sun", f"{prefix}/1", "running") assert response["event"] == { - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/1", + "node": f"{prefix}/1", "run_id": run_id, } @@ -869,7 +900,7 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/continue", - "domain": "automation", + "domain": domain, "item_id": "sun", "run_id": run_id, } @@ -878,11 +909,11 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): assert response["success"] response = await client.receive_json() - run_id = await assert_last_action("sun", "action/5", "running") + run_id = await assert_last_action("sun", f"{prefix}/5", "running") assert response["event"] == { - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/5", + "node": f"{prefix}/5", "run_id": run_id, } @@ -890,7 +921,7 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): { "id": next_id(), "type": "trace/debug/stop", - "domain": "automation", + "domain": domain, "item_id": "sun", "run_id": run_id, } @@ -898,29 +929,32 @@ async def test_automation_breakpoints_3(hass, hass_ws_client): response = await client.receive_json() assert response["success"] await hass.async_block_till_done() - await assert_last_action("sun", "action/5", "stopped") + await assert_last_action("sun", f"{prefix}/5", "stopped") # Clear 1st breakpoint await client.send_json( { "id": next_id(), "type": "trace/debug/breakpoint/clear", - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/1", + "node": f"{prefix}/1", } ) response = await client.receive_json() assert response["success"] - # Trigger "sun" automation - hass.bus.async_fire("test_event") + # Trigger "sun" automation / run "sun" script + if domain == "automation": + hass.bus.async_fire("test_event") + else: + await hass.services.async_call("script", "sun") response = await client.receive_json() - run_id = await assert_last_action("sun", "action/5", "running") + run_id = await assert_last_action("sun", f"{prefix}/5", "running") assert response["event"] == { - "domain": "automation", + "domain": domain, "item_id": "sun", - "node": "action/5", + "node": f"{prefix}/5", "run_id": run_id, }