mirror of
https://github.com/esphome/esphome.git
synced 2026-09-27 23:10:21 +00:00
316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""Tests for esphome.espidf.runner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from esphome.espidf import runner
|
|
|
|
# A flushing runner delivers the first line in well under a second; this is
|
|
# only ever waited out when the shim has gone back to buffering, so keep it
|
|
# just long enough to cover interpreter startup on a loaded CI machine.
|
|
FIRST_LINE_TIMEOUT = 10.0
|
|
|
|
|
|
def _prepare_main(
|
|
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
|
|
) -> tuple[io.BytesIO, io.TextIOWrapper]:
|
|
"""Point ``runner.main()`` at *probe* with a buffered fake stdout.
|
|
|
|
``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and
|
|
``os.get_terminal_size``; every one of those is monkeypatched so it is
|
|
put back afterwards. The fake stdout is block buffered like a pipe, so
|
|
the caller can tell whether the shim flushed. The wrapper comes back with
|
|
the buffer because dropping it would close the buffer underneath us.
|
|
"""
|
|
buf = io.BytesIO()
|
|
stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False)
|
|
|
|
monkeypatch.setattr(sys, "path", list(sys.path))
|
|
monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args])
|
|
monkeypatch.setattr(sys, "stdout", stream)
|
|
monkeypatch.setattr(sys, "stderr", stream)
|
|
monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size)
|
|
|
|
return buf, stream
|
|
|
|
|
|
def _run_main(
|
|
monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str
|
|
) -> tuple[io.BytesIO, io.TextIOWrapper]:
|
|
"""Run ``runner.main()`` against *probe* and expect a clean exit."""
|
|
buf, stream = _prepare_main(monkeypatch, probe, *args)
|
|
assert runner.main() == 0
|
|
return buf, stream
|
|
|
|
|
|
def test_main_filters_noise_and_flushes_each_write(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""Useful lines reach the stream right away; noisy ones are dropped."""
|
|
buf, _stream = _run_main(
|
|
monkeypatch, fixture_path / "espidf" / "filtering_probe.py"
|
|
)
|
|
|
|
# Read before any flush of our own: the shim has to have flushed.
|
|
output = buf.getvalue().decode("utf-8")
|
|
|
|
assert "Compiling main.cpp\n" in output
|
|
assert "[2/9] Building C object\n" in output
|
|
# Matched by FILTER_IDF_LINES, so they never leave the runner.
|
|
assert "Project build complete." not in output
|
|
assert "-- Component paths:" not in output
|
|
# Held back until the end because no terminator arrived.
|
|
assert output.endswith("still going\n")
|
|
|
|
|
|
def test_main_keeps_output_after_a_form_feed(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""A form feed is text, not a line break, so nothing after it is lost."""
|
|
buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py")
|
|
|
|
assert buf.getvalue().decode("utf-8") == (
|
|
"Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n"
|
|
)
|
|
|
|
|
|
def test_main_drains_a_partial_line_when_the_build_dies(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""A build that stops mid line must still show that line.
|
|
|
|
This is the whole point of draining: the message explaining why the
|
|
build failed is exactly the one most likely to arrive without a
|
|
trailing newline.
|
|
"""
|
|
buf, _stream = _prepare_main(
|
|
monkeypatch, fixture_path / "espidf" / "crashing_probe.py"
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
runner.main()
|
|
|
|
assert excinfo.value.code == 2
|
|
assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n"
|
|
|
|
|
|
def test_main_reports_rather_than_raises_when_draining_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
fixture_path: Path,
|
|
capfd: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""A stream that closed under us must not crash the runner's cleanup.
|
|
|
|
The drain runs from a ``finally``, so an exception there would replace
|
|
whatever exit code the build was carrying back.
|
|
"""
|
|
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
|
|
|
|
assert runner.main() == 0
|
|
reported = capfd.readouterr().err
|
|
assert "Could not write out remaining output" in reported
|
|
# The held line has to come along; the stream it was meant for is gone.
|
|
assert "partial before close" in reported
|
|
|
|
|
|
def test_main_survives_a_drain_failure_with_nowhere_to_report_it(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""With no real stderr to report to, cleanup still must not raise.
|
|
|
|
``sys.__stderr__`` is None on some interpreters, and ``print(file=None)``
|
|
falls back to ``sys.stdout``, which here is the shim wrapping the stream
|
|
that just failed.
|
|
"""
|
|
monkeypatch.setattr(sys, "__stderr__", None)
|
|
_prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py")
|
|
|
|
assert runner.main() == 0
|
|
|
|
|
|
def test_main_still_filters_a_drained_partial_line(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""Releasing a held line does not smuggle noise past the filter."""
|
|
buf, _stream = _run_main(
|
|
monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py"
|
|
)
|
|
|
|
assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n"
|
|
|
|
|
|
def test_main_keeps_everything_in_verbose_mode(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""``-v`` turns the filter off so the noisy lines survive."""
|
|
buf, _stream = _run_main(
|
|
monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v"
|
|
)
|
|
|
|
output = buf.getvalue().decode("utf-8")
|
|
|
|
assert "Project build complete.\n" in output
|
|
assert "-- Component paths: /a /b /c\n" in output
|
|
# With no filter there is no line buffering, so the partial line goes
|
|
# straight through as well.
|
|
assert output.endswith("still going")
|
|
|
|
|
|
def test_runner_streams_output_before_the_build_finishes(
|
|
fixture_path: Path, probe_env: dict[str, str]
|
|
) -> None:
|
|
"""The runner must flush, or a dashboard build looks frozen.
|
|
|
|
``toolchain.py`` spawns the runner as a plain script with no ``-u``, and
|
|
hands it a pipe when esphome itself is running under the dashboard. A
|
|
pipe is block buffered, so without a flush in the shim's ``write()`` the
|
|
output sits in the child until 8 KiB piles up or the build ends.
|
|
"""
|
|
runner_py = Path(runner.__file__)
|
|
probe = fixture_path / "espidf" / "streaming_probe.py"
|
|
|
|
with subprocess.Popen(
|
|
[sys.executable, str(runner_py), str(probe)],
|
|
stdout=subprocess.PIPE,
|
|
# Keep stderr: if the runner dies on startup, its traceback is the
|
|
# only clue about why no line showed up.
|
|
stderr=subprocess.PIPE,
|
|
env=probe_env,
|
|
text=True,
|
|
) as proc:
|
|
assert proc.stdout is not None
|
|
assert proc.stderr is not None
|
|
first_line: list[str] = []
|
|
reader = threading.Thread(
|
|
target=lambda: first_line.append(proc.stdout.readline()), daemon=True
|
|
)
|
|
try:
|
|
reader.start()
|
|
reader.join(FIRST_LINE_TIMEOUT)
|
|
still_running = proc.poll() is None
|
|
|
|
# The probe sleeps for a minute after writing, so reaching us at
|
|
# all means the line was flushed rather than released at exit.
|
|
assert first_line == ["Compiling main.cpp\n"], (
|
|
f"runner stderr: {'' if still_running else proc.stderr.read()}"
|
|
)
|
|
assert still_running
|
|
finally:
|
|
proc.kill()
|
|
proc.wait()
|
|
# Join before leaving the block, so the reader is done rather than
|
|
# racing ``Popen`` closing the pipe under it.
|
|
reader.join(1.0)
|
|
|
|
|
|
class _FakeKernel32:
|
|
"""Stand-in for the Windows kernel32 console code page calls."""
|
|
|
|
def __init__(self, input_cp: int, output_cp: int) -> None:
|
|
self.input_cp = input_cp
|
|
self.output_cp = output_cp
|
|
self.calls: list[tuple[str, int]] = []
|
|
|
|
def GetConsoleCP(self) -> int: # noqa: N802
|
|
return self.input_cp
|
|
|
|
def GetConsoleOutputCP(self) -> int: # noqa: N802
|
|
return self.output_cp
|
|
|
|
def SetConsoleCP(self, codepage: int) -> int: # noqa: N802
|
|
self.calls.append(("SetConsoleCP", codepage))
|
|
self.input_cp = codepage
|
|
return 1
|
|
|
|
def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802
|
|
self.calls.append(("SetConsoleOutputCP", codepage))
|
|
self.output_cp = codepage
|
|
return 1
|
|
|
|
|
|
def test_main_runs_the_build_with_a_utf8_console(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""An attached console is switched to UTF-8 and then put back."""
|
|
kernel32 = _FakeKernel32(850, 850)
|
|
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
|
|
|
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
|
|
|
assert kernel32.calls == [
|
|
("SetConsoleCP", runner.UTF8_CODEPAGE),
|
|
("SetConsoleOutputCP", runner.UTF8_CODEPAGE),
|
|
("SetConsoleCP", 850),
|
|
("SetConsoleOutputCP", 850),
|
|
]
|
|
|
|
|
|
def test_main_restores_the_console_when_the_build_dies(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""A failing build must not leave the user's console on UTF-8."""
|
|
kernel32 = _FakeKernel32(437, 437)
|
|
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
|
_prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py")
|
|
|
|
with pytest.raises(SystemExit):
|
|
runner.main()
|
|
|
|
assert (kernel32.input_cp, kernel32.output_cp) == (437, 437)
|
|
|
|
|
|
def test_main_restores_the_console_when_the_switch_fails_part_way(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""A failed output page switch must not strand the changed input page."""
|
|
kernel32 = _FakeKernel32(850, 850)
|
|
|
|
def _refuse(codepage: int) -> int:
|
|
kernel32.calls.append(("SetConsoleOutputCP", codepage))
|
|
return 0
|
|
|
|
kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign]
|
|
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
|
|
|
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
|
|
|
assert kernel32.input_cp == 850
|
|
assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)]
|
|
|
|
|
|
def test_main_leaves_the_console_alone_when_there_is_none(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""Without a console the code page calls return 0 and nothing is set."""
|
|
kernel32 = _FakeKernel32(0, 0)
|
|
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
|
|
|
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
|
|
|
assert kernel32.calls == []
|
|
|
|
|
|
def test_main_leaves_a_console_already_on_utf8_alone(
|
|
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
|
|
) -> None:
|
|
"""An overlapping build must not save UTF-8 as the page to restore."""
|
|
kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE)
|
|
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
|
|
|
|
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
|
|
|
|
assert kernel32.calls == []
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows")
|
|
def test_get_kernel32_is_none_off_windows() -> None:
|
|
assert runner._get_kernel32() is None
|