mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[ci] Restore memory impact detail for ESP-IDF builds (#17587)
This commit is contained in:
@@ -20,6 +20,7 @@ from . import (
|
||||
RAM_SECTIONS,
|
||||
MemoryAnalyzer,
|
||||
)
|
||||
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import ComponentMemory
|
||||
@@ -759,45 +760,25 @@ def main():
|
||||
print(f"Error: {build_path} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find firmware.elf
|
||||
elf_file = None
|
||||
for elf_candidate in [
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / build_path.name / "firmware.elf",
|
||||
]:
|
||||
if elf_candidate.exists():
|
||||
elf_file = str(elf_candidate)
|
||||
break
|
||||
|
||||
if not elf_file:
|
||||
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
|
||||
elf_path = find_elf_path(build_path)
|
||||
if not elf_path:
|
||||
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find idedata.json - check current directory first, then home
|
||||
device_name = build_path.name
|
||||
idedata_candidates = [
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
]
|
||||
elf_file = str(elf_path)
|
||||
|
||||
idedata = None
|
||||
for idedata_path in idedata_candidates:
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
if idedata_path := find_idedata_path(build_path):
|
||||
try:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
|
||||
|
||||
if not idedata:
|
||||
print(
|
||||
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
|
||||
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
|
||||
|
||||
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
|
||||
analyzer.analyze()
|
||||
|
||||
@@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
|
||||
]
|
||||
|
||||
|
||||
def find_elf_path(build_path: Path) -> Path | None:
|
||||
"""Locate the firmware ELF inside an ESPHome build directory.
|
||||
|
||||
The layout depends on the toolchain that produced the build, so try each
|
||||
known one in turn.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the ELF file, or None if no known layout matches
|
||||
"""
|
||||
name = build_path.name
|
||||
for candidate in (
|
||||
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
|
||||
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
|
||||
build_path / "build" / "firmware.elf",
|
||||
# PlatformIO
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / name / "firmware.elf",
|
||||
# LibreTiny uses raw_firmware.elf
|
||||
build_path / "raw_firmware.elf",
|
||||
build_path / ".pioenvs" / name / "raw_firmware.elf",
|
||||
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def idedata_candidates(build_path: Path) -> list[Path]:
|
||||
"""Return the idedata locations searched for a build directory, in order.
|
||||
|
||||
Exposed so a caller reporting "not found" can name the paths it tried
|
||||
without keeping its own copy of the list.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
The candidate idedata JSON paths, most specific first
|
||||
"""
|
||||
name = build_path.name
|
||||
return [
|
||||
# In .pioenvs for test builds
|
||||
build_path / ".pioenvs" / name / "idedata.json",
|
||||
# Both toolchains cache it in the data dir, which holds this build dir:
|
||||
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
|
||||
build_path.parent.parent / "idedata" / f"{name}.json",
|
||||
# Regular builds, invoked from the config dir or from anywhere
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{name}.json",
|
||||
]
|
||||
|
||||
|
||||
def find_idedata_path(build_path: Path) -> Path | None:
|
||||
"""Locate the idedata JSON belonging to an ESPHome build directory.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the idedata JSON, or None if it was not found
|
||||
"""
|
||||
for candidate in idedata_candidates(build_path):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _find_in_platformio_packages(tool_name: str) -> str | None:
|
||||
"""Search for a tool in PlatformIO package directories.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ toolchain has no such command, but its CMake build emits
|
||||
turns that file into the same fields consumers (IDE integration, clang-tidy)
|
||||
expect:
|
||||
|
||||
{cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
||||
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -197,6 +197,28 @@ def _get_toolchain_includes(cxx_path: str) -> list[str]:
|
||||
return includes
|
||||
|
||||
|
||||
def _cc_path_from_cxx(cxx_path: str) -> str:
|
||||
"""Derive the C compiler path from the C++ compiler path.
|
||||
|
||||
compile_commands.json only names the C++ compiler, but consumers reach the
|
||||
rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of
|
||||
``cc_path``, so they need the ``gcc``-suffixed name.
|
||||
"""
|
||||
stem, suffix = (
|
||||
(cxx_path[: -len(".exe")], ".exe")
|
||||
if cxx_path.endswith(".exe")
|
||||
else (cxx_path, "")
|
||||
)
|
||||
# Rewrite the program name only when it is g++ itself, or a toolchain
|
||||
# prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc.
|
||||
# Requiring a separator before the "g++" keeps names that merely end in
|
||||
# those three characters intact: "clang++" must not become "clangcc".
|
||||
head = stem[: -len("g++")]
|
||||
if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))):
|
||||
stem = f"{head}gcc"
|
||||
return f"{stem}{suffix}"
|
||||
|
||||
|
||||
def idedata_from_build(compile_commands: Path) -> dict:
|
||||
"""Parse compile_commands.json into the idedata fields consumers expect.
|
||||
|
||||
@@ -218,6 +240,7 @@ def idedata_from_build(compile_commands: Path) -> dict:
|
||||
build_includes.setdefault(inc, None)
|
||||
|
||||
return {
|
||||
"cc_path": _cc_path_from_cxx(cxx_path),
|
||||
"cxx_path": cxx_path,
|
||||
"cxx_flags": cxx_flags,
|
||||
"defines": defines,
|
||||
|
||||
@@ -467,9 +467,16 @@ def get_idedata() -> dict | None:
|
||||
cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||
if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
cached = json.loads(cache.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# Caches written before cc_path was emitted stay newer than
|
||||
# compile_commands.json forever, so rebuild them on the field rather
|
||||
# than on the timestamp. Check the type too: a corrupted cache can
|
||||
# still be valid JSON, and "in" would match a substring of a string.
|
||||
if isinstance(cached, dict) and "cc_path" in cached:
|
||||
return cached
|
||||
|
||||
data = idedata_from_build(compile_commands)
|
||||
data["prog_path"] = str(get_elf_path())
|
||||
|
||||
@@ -33,6 +33,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from esphome.analyze_memory import MemoryAnalyzer
|
||||
from esphome.analyze_memory.toolchain import (
|
||||
find_elf_path,
|
||||
find_idedata_path,
|
||||
idedata_candidates,
|
||||
)
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
from script.ci_helpers import write_github_output
|
||||
|
||||
@@ -130,53 +135,31 @@ def run_detailed_analysis(build_dir: str) -> dict | None:
|
||||
print(f"Build directory not found: {build_dir}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Find firmware.elf (or raw_firmware.elf for LibreTiny)
|
||||
elf_path = None
|
||||
for elf_candidate in [
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / build_path.name / "firmware.elf",
|
||||
# LibreTiny uses raw_firmware.elf
|
||||
build_path / "raw_firmware.elf",
|
||||
build_path / ".pioenvs" / build_path.name / "raw_firmware.elf",
|
||||
]:
|
||||
if elf_candidate.exists():
|
||||
elf_path = str(elf_candidate)
|
||||
break
|
||||
|
||||
elf_path = find_elf_path(build_path)
|
||||
if not elf_path:
|
||||
print(
|
||||
f"firmware.elf/raw_firmware.elf not found in {build_dir}", file=sys.stderr
|
||||
)
|
||||
print(f"No firmware ELF found in {build_dir}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Find idedata.json - check multiple locations
|
||||
device_name = build_path.name
|
||||
idedata_candidates = [
|
||||
# In .pioenvs for test builds
|
||||
build_path / ".pioenvs" / device_name / "idedata.json",
|
||||
# In .esphome/idedata for regular builds
|
||||
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
# Check parent directories for .esphome/idedata (for test_build_components)
|
||||
build_path.parent.parent.parent / "idedata" / f"{device_name}.json",
|
||||
]
|
||||
|
||||
idedata = None
|
||||
for idedata_path in idedata_candidates:
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
if idedata_path := find_idedata_path(build_path):
|
||||
try:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(
|
||||
f"Warning: Failed to load idedata from {idedata_path}: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
# Without idedata the analyzer falls back to whatever binutils are on
|
||||
# PATH, which are the wrong architecture for a cross build, so say where
|
||||
# we looked rather than let the results quietly get worse.
|
||||
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
|
||||
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
|
||||
|
||||
analyzer = MemoryAnalyzer(elf_path, idedata=idedata)
|
||||
analyzer = MemoryAnalyzer(str(elf_path), idedata=idedata)
|
||||
components = analyzer.analyze()
|
||||
|
||||
# Convert to JSON-serializable format
|
||||
@@ -320,6 +303,19 @@ def main() -> int:
|
||||
else:
|
||||
print(f"{ram_bytes},{flash_bytes}")
|
||||
|
||||
# The build produced usable totals, so a missing detailed analysis means the
|
||||
# build layout moved out from under this script rather than a broken build.
|
||||
# Fail loudly: the comment would otherwise silently drop the component
|
||||
# breakdown and the symbol tables, which is easy to miss for a long time.
|
||||
if detailed_analysis is None:
|
||||
print(
|
||||
"::error::Detailed memory analysis unavailable even though the build "
|
||||
f"succeeded (build directory: {build_dir or 'not detected'}). The PR "
|
||||
"comment would be missing its component breakdown and symbol changes.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -2993,3 +2993,53 @@ def test_main_force_all_off_uses_detection(
|
||||
assert output["component_test_count"] == 0
|
||||
mock_determine_integration_tests.assert_called_once()
|
||||
mock_should_run_clang_tidy.assert_called_once()
|
||||
|
||||
|
||||
# Every platform the memory impact analysis can select must produce an ELF that
|
||||
# find_elf_path knows how to locate. The analysis fails the job when it cannot
|
||||
# find one, so a platform with an unknown layout would turn a clean build red.
|
||||
_MEMORY_IMPACT_ELF_LAYOUTS = {
|
||||
# Native ESP-IDF toolchain (the esp32 default): <build>/build/firmware.elf
|
||||
"esp32-c6-idf": "build/firmware.elf",
|
||||
"esp32-idf": "build/firmware.elf",
|
||||
"esp32-c3-idf": "build/firmware.elf",
|
||||
"esp32-s2-idf": "build/firmware.elf",
|
||||
"esp32-s3-idf": "build/firmware.elf",
|
||||
# PlatformIO: <build>/.pioenvs/<name>/firmware.elf
|
||||
"esp8266-ard": ".pioenvs/{name}/firmware.elf",
|
||||
"rp2040-ard": ".pioenvs/{name}/firmware.elf",
|
||||
"rp2350-ard": ".pioenvs/{name}/firmware.elf",
|
||||
# LibreTiny: <build>/.pioenvs/<name>/raw_firmware.elf
|
||||
"bk72xx-ard": ".pioenvs/{name}/raw_firmware.elf",
|
||||
"rtl87xx-ard": ".pioenvs/{name}/raw_firmware.elf",
|
||||
"ln882x-ard": ".pioenvs/{name}/raw_firmware.elf",
|
||||
# Zephyr: <build>/.pioenvs/<name>/zephyr/[zephyr/]zephyr.elf
|
||||
"nrf52-adafruit": ".pioenvs/{name}/zephyr/zephyr/zephyr.elf",
|
||||
}
|
||||
|
||||
|
||||
def test_memory_impact_platforms_have_known_elf_layout() -> None:
|
||||
"""Every selectable memory impact platform has a documented ELF layout.
|
||||
|
||||
Adding a platform to the preference list without teaching find_elf_path
|
||||
where its ELF lands would fail the memory impact job on a clean build.
|
||||
"""
|
||||
selectable = {
|
||||
platform.value for platform in determine_jobs.MEMORY_IMPACT_PLATFORM_PREFERENCE
|
||||
}
|
||||
selectable.add(determine_jobs.MEMORY_IMPACT_FALLBACK_PLATFORM.value)
|
||||
|
||||
assert selectable == set(_MEMORY_IMPACT_ELF_LAYOUTS)
|
||||
|
||||
|
||||
def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None:
|
||||
"""find_elf_path locates the ELF each memory impact platform produces."""
|
||||
from esphome.analyze_memory.toolchain import find_elf_path
|
||||
|
||||
for platform, layout in _MEMORY_IMPACT_ELF_LAYOUTS.items():
|
||||
build_path = tmp_path / platform / ".esphome" / "build" / "mydevice"
|
||||
elf = build_path / layout.format(name=build_path.name)
|
||||
elf.parent.mkdir(parents=True)
|
||||
elf.write_text("")
|
||||
|
||||
assert find_elf_path(build_path) == elf, f"{platform} ELF not found"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for locating build artifacts across the supported toolchain layouts."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.analyze_memory.toolchain import (
|
||||
find_elf_path,
|
||||
find_idedata_path,
|
||||
idedata_candidates,
|
||||
)
|
||||
from esphome.espidf.idedata import _cc_path_from_cxx
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
|
||||
|
||||
def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path:
|
||||
"""Create <tmp_path>/.esphome/build/<name>, mirroring a real data dir."""
|
||||
build_path = tmp_path / ".esphome" / "build" / name
|
||||
build_path.mkdir(parents=True)
|
||||
return build_path
|
||||
|
||||
|
||||
def _touch(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("")
|
||||
return path
|
||||
|
||||
|
||||
def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None:
|
||||
"""The native ESP-IDF toolchain writes the ELF under build/."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / "build" / "firmware.elf")
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
def test_find_elf_path_platformio(tmp_path: Path) -> None:
|
||||
"""The PlatformIO toolchain writes the ELF under .pioenvs/<name>/."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf")
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
def test_find_elf_path_libretiny(tmp_path: Path) -> None:
|
||||
"""The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf")
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative_elf",
|
||||
[
|
||||
# SDK < 2.9.2
|
||||
"zephyr/zephyr.elf",
|
||||
# SDK >= 2.9.2 nests the artifacts one level deeper
|
||||
"zephyr/zephyr/zephyr.elf",
|
||||
],
|
||||
)
|
||||
def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None:
|
||||
"""Zephyr (nRF52) keeps the ELF under .pioenvs/<name>/zephyr/."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf)
|
||||
|
||||
assert find_elf_path(build_path) == elf
|
||||
|
||||
|
||||
def test_find_elf_path_missing(tmp_path: Path) -> None:
|
||||
"""An unknown layout resolves to None rather than a bogus path."""
|
||||
assert find_elf_path(_make_build_dir(tmp_path)) is None
|
||||
|
||||
|
||||
def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None:
|
||||
"""The idedata cache sits in the data dir that holds the build dir."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json")
|
||||
|
||||
assert find_idedata_path(build_path) == idedata
|
||||
|
||||
|
||||
def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None:
|
||||
"""Test builds may keep idedata alongside the PlatformIO env."""
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json")
|
||||
|
||||
assert find_idedata_path(build_path) == idedata
|
||||
|
||||
|
||||
def test_find_idedata_path_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A missing idedata resolves to None."""
|
||||
# Keep the cwd/home fallbacks from finding an unrelated file on this machine
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
||||
|
||||
assert find_idedata_path(_make_build_dir(tmp_path)) is None
|
||||
|
||||
|
||||
def test_idedata_candidates_are_what_find_probes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Every advertised candidate is one find_idedata_path actually accepts.
|
||||
|
||||
The candidates are reported to the user when idedata is missing, so a list
|
||||
that drifts from the lookup would send someone hunting in the wrong place.
|
||||
"""
|
||||
# Two candidates are relative to the cwd and to home; keep the test from
|
||||
# writing into the real ones.
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
||||
|
||||
build_path = _make_build_dir(tmp_path)
|
||||
candidates = idedata_candidates(build_path)
|
||||
|
||||
assert candidates, "no candidates advertised"
|
||||
for candidate in candidates:
|
||||
_touch(candidate)
|
||||
assert find_idedata_path(build_path) == candidate
|
||||
candidate.unlink()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cxx_path", "expected"),
|
||||
[
|
||||
("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"),
|
||||
("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"),
|
||||
(
|
||||
r"C:\tools\bin\xtensa-esp32-elf-g++.exe",
|
||||
r"C:\tools\bin\xtensa-esp32-elf-gcc.exe",
|
||||
),
|
||||
# Nothing to rewrite; leave the path alone
|
||||
("/tools/bin/clang++", "/tools/bin/clang++"),
|
||||
],
|
||||
)
|
||||
def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
|
||||
"""cc_path is derived from the C++ compiler that compile_commands.json names."""
|
||||
assert _cc_path_from_cxx(cxx_path) == expected
|
||||
|
||||
|
||||
def test_native_idedata_resolves_toolchain_tools() -> None:
|
||||
"""The binutils paths are derived from the native ESP-IDF cc_path.
|
||||
|
||||
Without cc_path, IDEData.objdump_path raises KeyError and the memory
|
||||
analysis silently degrades to no component or symbol detail.
|
||||
"""
|
||||
idedata = IDEData(
|
||||
{
|
||||
"cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"),
|
||||
"cxx_path": "/tools/bin/xtensa-esp32-elf-g++",
|
||||
}
|
||||
)
|
||||
|
||||
assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump"
|
||||
assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for script/ci_memory_impact_extract.py."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Add script directory to path so we can import the module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script"))
|
||||
|
||||
from ci_memory_impact_extract import main # noqa: E402
|
||||
|
||||
_COMPILE_OUTPUT = (
|
||||
"RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n"
|
||||
"Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
|
||||
|
||||
def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int:
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output))
|
||||
monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv])
|
||||
return main()
|
||||
|
||||
|
||||
def test_missing_detailed_analysis_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A build with no usable ELF fails instead of posting a comment without detail."""
|
||||
build_dir = tmp_path / ".esphome" / "build" / "mydevice"
|
||||
build_dir.mkdir(parents=True)
|
||||
out_json = tmp_path / "analysis.json"
|
||||
|
||||
rc = _run(
|
||||
monkeypatch,
|
||||
_COMPILE_OUTPUT,
|
||||
["--build-dir", str(build_dir), "--output-json", str(out_json)],
|
||||
)
|
||||
|
||||
assert rc == 1
|
||||
# The totals are still written so the failure can be diagnosed from the artifact
|
||||
assert out_json.is_file()
|
||||
|
||||
|
||||
def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Compile output without a build path cannot be analyzed, so it fails."""
|
||||
assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1
|
||||
|
||||
|
||||
def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Output with no memory totals at all is still a failure."""
|
||||
assert _run(monkeypatch, "nothing useful here\n", []) == 1
|
||||
@@ -7,6 +7,8 @@ import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
|
||||
from esphome.core import CORE
|
||||
from esphome.espidf import toolchain
|
||||
@@ -100,7 +102,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "cached"}')
|
||||
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
@@ -108,7 +110,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_not_called()
|
||||
assert result == {"cxx_path": "cached"}
|
||||
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
|
||||
"""A cache predating cc_path is rebuilt even though it is newer.
|
||||
|
||||
Such a cache stays newer than the compile DB forever, so consumers that
|
||||
derive the binutils paths from cc_path would keep failing on it.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result["cc_path"] == "gcc"
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
|
||||
@@ -131,6 +157,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
|
||||
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
|
||||
def test_get_idedata_regenerates_on_non_dict_cache(
|
||||
setup_core: Path, cached: str
|
||||
) -> None:
|
||||
"""A newer cache holding valid JSON that is not an object is regenerated.
|
||||
|
||||
A bare string would otherwise pass the cc_path check by substring and be
|
||||
handed to consumers expecting a dict.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(cached)
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
"""An unparseable (but newer) cache falls back to regeneration."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
|
||||
Reference in New Issue
Block a user