mirror of
https://github.com/home-assistant/core.git
synced 2026-09-17 05:58:41 +01:00
409 lines
12 KiB
Python
409 lines
12 KiB
Python
"""The tests for the command line notification platform."""
|
|
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from homeassistant import setup
|
|
from homeassistant.components.command_line import DOMAIN
|
|
from homeassistant.components.command_line.notify import CommandLineNotificationService
|
|
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"get_config",
|
|
[
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "exit 0",
|
|
"name": "Test2",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
)
|
|
async def test_setup_integration_yaml(
|
|
hass: HomeAssistant, load_yaml_integration: None
|
|
) -> None:
|
|
"""Test sensor setup."""
|
|
assert hass.services.has_service(NOTIFY_DOMAIN, "test2")
|
|
|
|
|
|
async def test_bad_config(hass: HomeAssistant) -> None:
|
|
"""Test set up the platform with bad/missing configuration."""
|
|
assert await setup.async_setup_component(
|
|
hass,
|
|
NOTIFY_DOMAIN,
|
|
{
|
|
NOTIFY_DOMAIN: [
|
|
{"platform": "command_line"},
|
|
]
|
|
},
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert not hass.services.has_service(NOTIFY_DOMAIN, "test")
|
|
|
|
|
|
async def test_command_line_output(hass: HomeAssistant) -> None:
|
|
"""Test the command line output."""
|
|
with tempfile.TemporaryDirectory() as tempdirname:
|
|
filename = os.path.join(tempdirname, "message.txt")
|
|
message = "one, two, testing, testing"
|
|
await setup.async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": f"cat > {filename}",
|
|
"name": "Test3",
|
|
}
|
|
}
|
|
]
|
|
},
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.services.has_service(NOTIFY_DOMAIN, "test3")
|
|
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True
|
|
)
|
|
assert message == await hass.async_add_executor_job(Path(filename).read_text)
|
|
|
|
|
|
async def test_command_line_output_single_command(
|
|
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test the command line output."""
|
|
|
|
await setup.async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "echo",
|
|
"name": "Test3",
|
|
}
|
|
}
|
|
]
|
|
},
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.services.has_service(NOTIFY_DOMAIN, "test3")
|
|
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test3", {"message": "test message"}, blocking=True
|
|
)
|
|
assert "Running command: echo" in caplog.text
|
|
assert "Running with message: test message" in caplog.text
|
|
|
|
|
|
async def test_command_template(hass: HomeAssistant) -> None:
|
|
"""Test the command line output using template as command."""
|
|
|
|
with tempfile.TemporaryDirectory() as tempdirname:
|
|
filename = os.path.join(tempdirname, "message.txt")
|
|
message = "one, two, testing, testing"
|
|
hass.states.async_set("sensor.test_state", filename)
|
|
await setup.async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "cat > {{ states.sensor.test_state.state }}",
|
|
"name": "Test3",
|
|
}
|
|
}
|
|
]
|
|
},
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.services.has_service(NOTIFY_DOMAIN, "test3")
|
|
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True
|
|
)
|
|
assert message == await hass.async_add_executor_job(Path(filename).read_text)
|
|
|
|
|
|
async def test_command_incorrect_template(
|
|
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test the command line output using template as command which isn't working."""
|
|
|
|
message = "one, two, testing, testing"
|
|
await setup.async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "cat > {{ this template doesn't parse ",
|
|
"name": "Test3",
|
|
}
|
|
}
|
|
]
|
|
},
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.services.has_service(NOTIFY_DOMAIN, "test3")
|
|
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True
|
|
)
|
|
|
|
assert (
|
|
"Error rendering command template: TemplateSyntaxError: expected token"
|
|
in caplog.text
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"get_config",
|
|
[
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "exit 1",
|
|
"name": "Test4",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
)
|
|
async def test_error_for_none_zero_exit_code(
|
|
caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None
|
|
) -> None:
|
|
"""Test if an error is logged for non zero exit codes."""
|
|
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test4", {"message": "error"}, blocking=True
|
|
)
|
|
assert "Command failed" in caplog.text
|
|
assert "return code 1" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"get_config",
|
|
[
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "sleep 5",
|
|
"command_timeout": 0,
|
|
"name": "Test5",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
)
|
|
async def test_timeout(
|
|
caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None
|
|
) -> None:
|
|
"""Test blocking is not forever."""
|
|
with pytest.raises(HomeAssistantError) as exc_info:
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test5", {"message": "error"}, blocking=True
|
|
)
|
|
assert exc_info.value.translation_key == "timeout_error"
|
|
assert exc_info.value.translation_placeholders == {"command": "sleep 5"}
|
|
assert "Timeout" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
pytest.param("x" * 100000, id="stdin_buffer_below_high_water_mark"),
|
|
pytest.param("x" * 200000, id="stdin_buffer_above_high_water_mark"),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"get_config",
|
|
[
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "sleep 5",
|
|
"command_timeout": 1,
|
|
"name": "Test7",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("load_yaml_integration")
|
|
async def test_timeout_with_unflushed_stdin(hass: HomeAssistant, message: str) -> None:
|
|
"""Test a timeout is raised when the command never drains stdin.
|
|
|
|
A message larger than the pipe buffer leaves data queued in the stdin
|
|
transport when the timeout cancels communicate(). The outer timeout keeps a
|
|
regression from hanging the test run instead of failing it.
|
|
"""
|
|
with pytest.raises(HomeAssistantError) as exc_info:
|
|
async with asyncio.timeout(3):
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test7", {"message": message}, blocking=True
|
|
)
|
|
assert exc_info.value.translation_key == "timeout_error"
|
|
assert exc_info.value.translation_placeholders == {"command": "sleep 5"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"get_config",
|
|
[
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "exit 0",
|
|
"name": "Test6",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
)
|
|
async def test_spawn_error(
|
|
caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None
|
|
) -> None:
|
|
"""Test that a failure to spawn the command is handled correctly."""
|
|
|
|
with (
|
|
patch(
|
|
"homeassistant.components.command_line.notify.asyncio.create_subprocess_shell",
|
|
side_effect=OSError("exec failed"),
|
|
),
|
|
pytest.raises(HomeAssistantError) as exc_info,
|
|
):
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True
|
|
)
|
|
assert exc_info.value.translation_key == "command_error"
|
|
assert exc_info.value.translation_placeholders == {
|
|
"command": "exit 0",
|
|
"error": "exec failed",
|
|
}
|
|
assert "Error trying to exec command" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("is_closing", "write_buffer_size", "expected_abort"),
|
|
[
|
|
pytest.param(True, 0, False, id="stdin_already_closed"),
|
|
pytest.param(True, 4096, True, id="stdin_closing_with_queued_data"),
|
|
pytest.param(False, 0, True, id="stdin_still_connected"),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"get_config",
|
|
[
|
|
{
|
|
"command_line": [
|
|
{
|
|
"notify": {
|
|
"command": "exit 0",
|
|
"name": "Test6",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("load_yaml_integration")
|
|
async def test_timeout_cleanup(
|
|
caplog: pytest.LogCaptureFixture,
|
|
hass: HomeAssistant,
|
|
is_closing: bool,
|
|
write_buffer_size: int,
|
|
expected_abort: bool,
|
|
) -> None:
|
|
"""Test the stdin pipe is only aborted while it can still block wait().
|
|
|
|
The command is assumed to have exited between the timeout and the kill, so
|
|
this also covers that kill() raises ProcessLookupError.
|
|
"""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.side_effect = TimeoutError
|
|
mock_proc.kill = MagicMock(side_effect=ProcessLookupError)
|
|
mock_proc.stdin = MagicMock()
|
|
mock_proc.stdin.is_closing.return_value = is_closing
|
|
mock_proc.stdin.transport.get_write_buffer_size.return_value = write_buffer_size
|
|
|
|
with (
|
|
patch(
|
|
"homeassistant.components.command_line.notify.asyncio.create_subprocess_shell",
|
|
return_value=mock_proc,
|
|
),
|
|
pytest.raises(HomeAssistantError) as exc_info,
|
|
):
|
|
await hass.services.async_call(
|
|
NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True
|
|
)
|
|
assert exc_info.value.translation_key == "timeout_error"
|
|
assert exc_info.value.translation_placeholders == {"command": "exit 0"}
|
|
mock_proc.kill.assert_called_once()
|
|
mock_proc.wait.assert_awaited_once()
|
|
assert mock_proc.stdin.transport.abort.called is expected_abort
|
|
assert "Timeout for command" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kill_side_effect",
|
|
[
|
|
pytest.param(None, id="process_running"),
|
|
# The command may have exited before the kill.
|
|
pytest.param(ProcessLookupError, id="process_already_gone"),
|
|
],
|
|
)
|
|
async def test_cancelled_kills_process(
|
|
hass: HomeAssistant, kill_side_effect: type[Exception] | None
|
|
) -> None:
|
|
"""Test the subprocess is killed and the cancellation is re-raised.
|
|
|
|
The event loop reaps the killed child on its own, so the cancellation path
|
|
does not await wait().
|
|
"""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.side_effect = asyncio.CancelledError
|
|
mock_proc.kill = MagicMock(side_effect=kill_side_effect)
|
|
|
|
service = CommandLineNotificationService("exit 0", 15)
|
|
service.hass = hass
|
|
|
|
with (
|
|
patch(
|
|
"homeassistant.components.command_line.notify.asyncio.create_subprocess_shell",
|
|
return_value=mock_proc,
|
|
),
|
|
pytest.raises(asyncio.CancelledError),
|
|
):
|
|
await service.async_send_message("error")
|
|
|
|
mock_proc.kill.assert_called_once()
|
|
mock_proc.wait.assert_not_awaited()
|