diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index ad7134cb8a..d73e9aad4e 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -151,10 +151,20 @@ def run_compile(config: ConfigType, verbose: bool) -> int: get_factory_firmware_path(), build_dir / "firmware.ota.bin", ) + ninja_mtime = ninja_file.stat().st_mtime for artifact in build_dir_artifacts: if not artifact.is_file(): _LOGGER.error("Build produced no %s", artifact) return 1 + if artifact.stat().st_mtime < ninja_mtime: + # A leftover from an older manifest must not pass as this + # build's output (a defective manifest with no default targets + # reports "no work to do" while building nothing) + _LOGGER.error( + "%s is older than build.ninja; run 'esphome clean-all' and retry", + artifact, + ) + return 1 if not _print_size_summary(build_dir, paths): # The cause was already warned; name the consequence so a build @@ -214,7 +224,9 @@ def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | N ld_path = get_flash_ld_path(build_dir, paths) try: ld_text = ld_path.read_text(encoding="utf-8") - except OSError as err: + except (OSError, UnicodeDecodeError) as err: + # UnicodeDecodeError: a truncated/corrupt script must degrade to + # the same warning, never abort an already-linked build _LOGGER.warning("Cannot read linker script for the Flash summary: %s", err) return None app_size = segment_length(ld_text, "irom0_0_seg") diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 52e48925f2..b5852abc46 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -664,10 +664,13 @@ def _decode_pc(config: ConfigType, addr: str) -> None: try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - # Warn, not debug: a failing addr2line must be visible + # Warn, not debug: a failing addr2line must be visible. The warning + # is rate-limited across a dump, so mark every undecoded address + # inline or the rest read as merely unmappable _warn_decode_problem( "addr2line-failed", "Could not decode crash address %s (%s)", addr, err ) + _LOGGER.warning("Not decoded %s (addr2line failed)", addr) _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/script/test_build_components.py b/script/test_build_components.py index 26c49cf60f..06a2ea09f5 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -1106,6 +1106,11 @@ def test_components( # If no components found, build a reference configuration for baseline comparison # Create a synthetic "empty" component test that will build just the base config + if fail_on_no_tests and not all_tests: + # The synthetic baseline would report success having built nothing + # the caller asked for (e.g. a wildcard that matched no component) + print(f"No components found matching: {component_patterns}") + return 1 if not all_tests: print(f"No components found matching: {component_patterns}") print( diff --git a/tests/script/test_test_build_components.py b/tests/script/test_test_build_components.py index a293ebcb16..3f19935e0b 100644 --- a/tests/script/test_test_build_components.py +++ b/tests/script/test_test_build_components.py @@ -285,6 +285,18 @@ def test_components_blank_list_fails_with_flag( assert "blank component list" in capsys.readouterr().out +def test_components_wildcard_no_match_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A wildcard matching nothing must not degrade to the synthetic + baseline build and exit green under the flag.""" + rc = tbc.test_components( + ["zz_no_such*"], "esp8266-ard", "compile", False, fail_on_no_tests=True + ) + assert rc == 1 + assert "No components found matching" in capsys.readouterr().out + + def test_components_empty_match_tolerated_without_flag() -> None: """The esp32-ard smoke leg deliberately builds only the subset with a matching fixture; without the flag an empty match stays green.""" diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index 82f950e7fa..a5c4df6c14 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -39,9 +39,11 @@ def _setup_core(tmp_path: Path) -> None: CORE.build_path = tmp_path CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} # run_compile verifies the produced artifacts; give every test a build - # that "produced" them (tests for the guard delete them again) + # that "produced" them (tests for the guard delete them again). The + # manifest comes first: artifacts must not be older than build.ninja. build_dir = CORE.relative_pioenvs_path("test8266") build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "build.ninja").write_text("# manifest") for artifact in ( "firmware.elf", "firmware.bin", @@ -51,6 +53,18 @@ def _setup_core(tmp_path: Path) -> None: (build_dir / artifact).write_bytes(b"") +def _touch_artifacts(build_dir: Path) -> None: + """Re-date the fixture artifacts after a test rewrites build.ninja, so + the freshness guard sees them as this manifest's outputs.""" + for artifact in ( + "firmware.elf", + "firmware.bin", + "firmware.factory.bin", + "firmware.ota.bin", + ): + (build_dir / artifact).touch() + + def _paths(tmp_path: Path) -> framework.InstalledPaths: return framework.InstalledPaths( framework=tmp_path / "framework", @@ -150,6 +164,7 @@ def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None: build_dir = toolchain.get_build_dir() build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "build.ninja").write_text("") + _touch_artifacts(build_dir) compdb = build_dir / "compile_commands.json" compdb.write_text("[]") os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2) @@ -422,6 +437,7 @@ def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None: build_dir.mkdir(parents=True, exist_ok=True) # write_project (stubbed below) always leaves a build.ninja behind (build_dir / "build.ninja").write_text("# manifest") + _touch_artifacts(build_dir) def run(regenerate_expected: bool) -> None: with ( @@ -571,6 +587,44 @@ def test_run_compile_skipped_size_summary_names_consequence( assert "Firmware size summary unavailable for this build" in caplog.text +def test_run_compile_stale_artifact_fails( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An artifact older than build.ninja is a leftover, not this build's + output; a defective manifest with no default targets must not pass on + it.""" + build_dir = toolchain.get_build_dir() + os.utime(build_dir / "firmware.bin", (0, 0)) + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=False), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "_print_size_summary", return_value=True), + patch.object(toolchain, "get_idedata", return_value={}), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1 + assert "older than build.ninja" in caplog.text + + +def test_parse_app_size_non_utf8_ld_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A corrupt (non-UTF-8) linker script degrades to the same warning as + an unreadable one, never a traceback after a successful link.""" + paths = _paths(tmp_path) + ld = tmp_path / "corrupt.ld" + ld.write_bytes(b"\xff\xfe not utf8") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, paths) is None + assert "Cannot read linker script" in caplog.text + + def test_get_idedata_accepts_preresolved_ccache() -> None: """run_compile threads its resolved ccache through; the probe must not run again."""