velxio/test/backend/unit/test_espidf_include_scanner.py

140 lines
5.5 KiB
Python
Raw Normal View History

"""Preprocessor-aware include scanning for the ESP-IDF library resolver.
Reported 2026-08-03: every ESP32 build in a user's project failed with
errors from libraries he never installed (SoftwareSerial.h,
ArduinoBearSSL.h, Adafruit_GFX.h, AsyncElegantOTA.h) coming out of
user_libs_all.
Chain: ESPAsyncWebServer guards `#include <Hash.h>` behind
`#if defined(ESP8266) || defined(TARGET_RP2040)`. The old scanner was a
bare regex with no preprocessor state, so it reported Hash.h anyway; the
resolver then searched ~1000 installed libraries, found stemi-hexapod's
src/Hash.h, merged that hexapod-robot library whole, and compiled all of
its sources.
Design rule under test: a branch is skipped ONLY when every identifier in
its condition is a platform macro known to be undefined on ESP32.
Anything unknown stays LIVE — hiding a genuinely needed header would be a
far worse failure than the old over-reporting.
No toolchain required: pure scanning logic.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend'))
from app.services.espidf_compiler import ESPIDFCompiler
class TestPreprocessorAwareIncludeScan(unittest.TestCase):
def setUp(self) -> None:
self.scan = ESPIDFCompiler()._detect_external_includes
def test_real_espasyncwebserver_shape(self):
"""The exact reported break."""
got = self.scan(
'#if defined(ESP8266) || defined(TARGET_RP2040)\n'
'#include <Hash.h>\n'
'#include <ESPAsyncTCP.h>\n'
'#else\n'
'#include <AsyncTCP.h>\n'
'#endif\n'
'#include <ArduinoJson.h>\n'
)
self.assertNotIn('Hash.h', got)
self.assertNotIn('ESPAsyncTCP.h', got)
self.assertIn('AsyncTCP.h', got)
self.assertIn('ArduinoJson.h', got)
def test_unknown_macro_stays_live(self):
"""The safety property: we must never hide a header we cannot rule
out. A project-specific macro is not a platform macro."""
self.assertIn('MyLib.h', self.scan(
'#if USE_CUSTOM_THING\n#include <MyLib.h>\n#endif'))
self.assertIn('Other.h', self.scan(
'#if LIB_VERSION > 2\n#include <Other.h>\n#endif'))
def test_esp32_branch_lives_and_foreign_branch_dies(self):
got = self.scan(
'#ifdef ESP32\n#include <WiFi.h>\n#endif\n'
'#ifdef __AVR__\n#include <SoftwareSerial.h>\n#endif')
self.assertIn('WiFi.h', got)
self.assertNotIn('SoftwareSerial.h', got)
def test_else_of_a_dead_branch_is_live(self):
self.assertEqual(
self.scan('#ifdef ESP8266\n#include <Dead.h>\n'
'#else\n#include <Alive.h>\n#endif'),
['Alive.h'])
def test_elif_selects_the_esp32_arm(self):
self.assertEqual(
self.scan('#if defined(ESP8266)\n#include <A.h>\n'
'#elif defined(ESP32)\n#include <B.h>\n'
'#else\n#include <C.h>\n#endif'),
['B.h'])
def test_else_of_an_unknown_branch_is_live(self):
"""The GxEPD2 shape (found by the gallery compile smoke, 2026-08-15):
#if ENABLE_GxEPD2_GFX // defaults to 0 — unknown to us
#include "GxEPD2_GFX.h"
#elif defined(_GFX_H_)
#define GxEPD2_GFX_BASE_CLASS GFX
#else
#include <Adafruit_GFX.h> // what the compiler really takes
#endif
An UNKNOWN #if must not mark the chain as taken: pruning this #else
dropped Adafruit_GFX from user_libs_all and broke every GxEPD2
e-paper example with 'fatal error: Adafruit_GFX.h: No such file'."""
got = self.scan(
'#if ENABLE_GxEPD2_GFX\n#include "GxEPD2_GFX.h"\n'
'#elif defined(_GFX_H_)\n#define GxEPD2_GFX_BASE_CLASS GFX\n'
'#else\n#include <Adafruit_GFX.h>\n#endif')
self.assertIn('Adafruit_GFX.h', got)
# The unknown arm itself also stays live — over-reporting is the
# accepted cost, hiding a real dependency is not.
self.assertIn('GxEPD2_GFX.h', got)
def test_else_of_a_provably_true_branch_stays_dead(self):
"""The counterweight: when the #if IS provably true on ESP32, its
#else really is dead and must stay pruned."""
self.assertEqual(
self.scan('#if defined(ESP32)\n#include <A.h>\n'
'#else\n#include <B.h>\n#endif'),
['A.h'])
def test_nested_dead_block(self):
self.assertEqual(
self.scan('#ifdef ESP8266\n#ifdef FOO\n#include <Deep.h>\n'
'#endif\n#endif\n#include <Top.h>'),
['Top.h'])
def test_comments_are_not_includes(self):
self.assertEqual(
self.scan('// #include <Commented.h>\n'
'/* #include <Block.h> */\n'
'#include <Real.h>'),
['Real.h'])
def test_plain_sketch_unchanged(self):
got = self.scan('#include <ESPAsyncWebServer.h>\n'
'#include <ESP32Servo.h>\n'
'#include <WiFi.h>')
self.assertIn('ESPAsyncWebServer.h', got)
self.assertIn('ESP32Servo.h', got)
def test_idf_internal_and_pathed_headers_still_skipped(self):
got = self.scan('#include <freertos/FreeRTOS.h>\n'
'#include <esp_wifi.h>\n'
'#include <DHT.h>')
self.assertEqual(got, ['DHT.h'])
if __name__ == '__main__':
unittest.main()