333 lines
14 KiB
Python
333 lines
14 KiB
Python
"""
|
|
Regression tests for the library include-root layout (VELXIO_PER_LIB_ROOTS).
|
|
|
|
Every case here is a real incident. The merged component used to copy all
|
|
libraries into one interleaved tree and export EVERY directory that held a
|
|
file, so a library's private internals sat on the global -I and hijacked
|
|
`#include`s issued by other libraries, by the arduino-esp32 core and by
|
|
ESP-IDF itself:
|
|
|
|
* FastLED ships a host-test stub `src/platforms/stub/Arduino.h`. With that
|
|
directory exported, U8g2's `#include <Arduino.h>` resolved to the stub —
|
|
'yield' was not declared, and fl::string polluted String overload
|
|
resolution inside the core's WString.h.
|
|
* FastLED ships `src/fl/stl/asio/http/http_parser.h`. Exported, it shadowed
|
|
the IDF http_parser component header that esp_http_server.h includes, and
|
|
every httpd_method_t use failed to compile.
|
|
* LovyanGFX ships `src/lgfx/internal/limits.h`. Exported, FreeRTOS's
|
|
portmacro.h picked it up inside an extern "C" block.
|
|
|
|
The fix is arduino-cli's model: one subtree per library, exactly one exported
|
|
include root per library (`<lib>/src`, or `<lib>` for the flat legacy layout),
|
|
`utility/` private. These tests assert the property — no directory other than a
|
|
library's own include root is ever exported — rather than any single denylist
|
|
entry, so a new library shipping a new generically-named internal cannot
|
|
reintroduce the class.
|
|
|
|
Run from the repo root:
|
|
python -m pytest test/backend/unit/test_espidf_include_roots.py -v
|
|
"""
|
|
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend'))
|
|
|
|
from app.services import espidf_compiler as espidf_mod
|
|
from app.services.espidf_compiler import ESPIDFCompiler
|
|
|
|
|
|
def make_compiler() -> ESPIDFCompiler:
|
|
comp = ESPIDFCompiler.__new__(ESPIDFCompiler)
|
|
comp.idf_path = ''
|
|
comp.arduino_path = ''
|
|
comp.has_arduino = False
|
|
comp.idf5_path = ''
|
|
comp.arduino5_path = ''
|
|
comp.has_arduino5 = False
|
|
return comp
|
|
|
|
|
|
def write(path: Path, text: str = '// x') -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding='utf-8')
|
|
|
|
|
|
class IncludeRootsBase(unittest.TestCase):
|
|
"""Builds a libraries dir that mirrors the real offenders."""
|
|
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp())
|
|
self.arduino_libs = self.tmp / 'libraries'
|
|
self.esp32_libs = self.tmp / 'esp32_libs'
|
|
self.user_libs = self.tmp / 'user_libs'
|
|
for d in (self.arduino_libs, self.esp32_libs, self.user_libs):
|
|
d.mkdir(parents=True)
|
|
self.comp = make_compiler()
|
|
|
|
# FastLED: recursive layout, ships an Arduino.h stub and an
|
|
# http_parser.h deep inside src/, plus a unity build that reaches them
|
|
# through file-relative includes.
|
|
fl = self.arduino_libs / 'fastled@3.10.5-017d142ff4ff'
|
|
write(fl / 'src' / 'FastLED.h', '#include "platforms/stub/Arduino.h"')
|
|
write(fl / 'src' / 'FastLED.cpp')
|
|
write(fl / 'src' / 'platforms' / 'stub' / 'Arduino.h', '// host-test stub')
|
|
write(fl / 'src' / 'fl' / 'stl' / 'asio' / 'http' / 'http_parser.h')
|
|
write(fl / 'src' / 'fl' / 'stl' / 'string.h')
|
|
write(fl / 'library.properties', 'name=FastLED\narchitectures=*\n')
|
|
|
|
# U8g2: recursive layout, includes <Arduino.h> expecting the CORE one.
|
|
u8 = self.arduino_libs / 'U8g2'
|
|
write(u8 / 'src' / 'U8g2lib.h', '#include <Arduino.h>')
|
|
write(u8 / 'src' / 'U8g2lib.cpp', '#include <Arduino.h>')
|
|
# A benign internal subdirectory: not on any denylist, so it is the
|
|
# clean way to show what each layout exports.
|
|
write(u8 / 'src' / 'fonts' / 'Font5x7.h')
|
|
write(u8 / 'library.properties', 'name=U8g2\narchitectures=*\n')
|
|
|
|
# LovyanGFX-style internal limits.h.
|
|
lg = self.arduino_libs / 'M5GFX'
|
|
write(lg / 'src' / 'M5GFX.h')
|
|
write(lg / 'src' / 'M5GFX.cpp')
|
|
write(lg / 'src' / 'lgfx' / 'internal' / 'limits.h')
|
|
write(lg / 'library.properties', 'name=M5GFX\narchitectures=esp32\n')
|
|
|
|
# Legacy flat layout with a utility/ dir (Arduino keeps it private).
|
|
legacy = self.arduino_libs / 'LegacyLib'
|
|
write(legacy / 'LegacyLib.h')
|
|
write(legacy / 'LegacyLib.cpp')
|
|
write(legacy / 'utility' / 'twi.h')
|
|
write(legacy / 'utility' / 'twi.c')
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.tmp)
|
|
|
|
def resolve(self, headers):
|
|
return self.comp._resolve_library_components(
|
|
headers, self.arduino_libs, self.esp32_libs,
|
|
'arduino-esp32', self.user_libs,
|
|
)
|
|
|
|
def cmake_text(self) -> str:
|
|
return (self.user_libs / 'user_libs_all' / 'CMakeLists.txt').read_text()
|
|
|
|
def exported_dirs(self) -> list[str]:
|
|
"""Directories on the component's PUBLIC include path."""
|
|
m = re.search(r'INCLUDE_DIRS ([^\n]*)', self.cmake_text())
|
|
self.assertIsNotNone(m, 'component CMakeLists has no INCLUDE_DIRS')
|
|
return re.findall(r'"([^"]+)"', m.group(1))
|
|
|
|
def private_dirs(self) -> list[str]:
|
|
m = re.search(r'PRIV_INCLUDE_DIRS ([^\n]*)', self.cmake_text())
|
|
return re.findall(r'"([^"]+)"', m.group(1)) if m else []
|
|
|
|
def merged_files(self) -> list[str]:
|
|
root = self.user_libs / 'user_libs_all'
|
|
return sorted(
|
|
str(p.relative_to(root)).replace('\\', '/')
|
|
for p in root.rglob('*') if p.is_file()
|
|
)
|
|
|
|
|
|
class TestPerLibraryIncludeRoots(IncludeRootsBase):
|
|
"""Default mode: one subtree and one exported include root per library."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self._saved = espidf_mod._PER_LIB_ROOTS
|
|
espidf_mod._PER_LIB_ROOTS = True
|
|
|
|
def tearDown(self):
|
|
espidf_mod._PER_LIB_ROOTS = self._saved
|
|
super().tearDown()
|
|
|
|
def test_only_library_roots_are_exported(self):
|
|
"""THE property: nothing but '.' and one root per library is on -I."""
|
|
self.resolve(['FastLED.h', 'U8g2lib.h', 'M5GFX.h'])
|
|
dirs = self.exported_dirs()
|
|
self.assertEqual(dirs[0], '.')
|
|
roots = dirs[1:]
|
|
self.assertEqual(len(roots), 3, f'expected one root per library, got {roots}')
|
|
for root in roots:
|
|
self.assertRegex(
|
|
root, r'^[^/]+/src$',
|
|
f'{root} is not a library include root — only <lib>/src may be exported',
|
|
)
|
|
|
|
def test_fastled_stub_arduino_h_is_not_exported(self):
|
|
"""The 2026-08-19 incident: U8g2's <Arduino.h> must reach the core."""
|
|
self.resolve(['FastLED.h', 'U8g2lib.h'])
|
|
for d in self.exported_dirs():
|
|
self.assertFalse(
|
|
d.endswith('platforms/stub'),
|
|
f'{d} exported — FastLED\'s Arduino.h stub shadows the core again',
|
|
)
|
|
|
|
def test_fastled_http_parser_dir_is_not_exported(self):
|
|
"""esp_http_server.h's <http_parser.h> must reach the IDF component."""
|
|
self.resolve(['FastLED.h'])
|
|
for d in self.exported_dirs():
|
|
self.assertFalse(
|
|
d.endswith('fl/stl/asio/http'),
|
|
f'{d} exported — FastLED\'s http_parser.h shadows the IDF header again',
|
|
)
|
|
|
|
def test_lovyangfx_internal_limits_dir_is_not_exported(self):
|
|
"""FreeRTOS portmacro.h's <limits.h> must reach the toolchain."""
|
|
self.resolve(['M5GFX.h'])
|
|
for d in self.exported_dirs():
|
|
self.assertFalse(
|
|
d.endswith('lgfx/internal'),
|
|
f'{d} exported — LovyanGFX\'s limits.h shadows the toolchain again',
|
|
)
|
|
|
|
def test_benign_internal_subdirectory_is_not_exported_either(self):
|
|
"""Not a denylist: NO subdirectory is exported, harmless or not."""
|
|
self.resolve(['U8g2lib.h'])
|
|
self.assertFalse(
|
|
any(d.endswith('/fonts') for d in self.exported_dirs()),
|
|
'a library internal subdir reached -I — the class is back',
|
|
)
|
|
self.assertTrue(
|
|
any(f.endswith('src/fonts/Font5x7.h') for f in self.merged_files()),
|
|
'the header must still be copied for file-relative includes',
|
|
)
|
|
|
|
def test_internals_are_still_copied(self):
|
|
"""Exclusion is from -I only: file-relative includes must keep working."""
|
|
files = self.merged_files()
|
|
self.resolve(['FastLED.h'])
|
|
files = self.merged_files()
|
|
self.assertTrue(
|
|
any(f.endswith('src/platforms/stub/Arduino.h') for f in files),
|
|
'the stub was dropped — FastLED\'s own relative include breaks',
|
|
)
|
|
self.assertTrue(
|
|
any(f.endswith('src/fl/stl/asio/http/http_parser.h') for f in files),
|
|
)
|
|
|
|
def test_each_library_keeps_its_own_subtree(self):
|
|
"""No interleaving: a library's files live under its own directory."""
|
|
self.resolve(['FastLED.h', 'U8g2lib.h'])
|
|
files = self.merged_files()
|
|
fastled = [f for f in files if f.endswith('FastLED.h')]
|
|
u8g2 = [f for f in files if f.endswith('U8g2lib.h')]
|
|
self.assertTrue(fastled and u8g2)
|
|
self.assertNotEqual(
|
|
fastled[0].split('/')[0], u8g2[0].split('/')[0],
|
|
'libraries share a subtree — same-path files can collapse silently',
|
|
)
|
|
|
|
def test_same_relative_path_in_two_libraries_survives(self):
|
|
"""Legacy dedup dropped one copy; per-library subtrees keep both."""
|
|
a = self.arduino_libs / 'LibA'
|
|
write(a / 'src' / 'LibA.h')
|
|
write(a / 'src' / 'utility' / 'helper.h', '// A')
|
|
b = self.arduino_libs / 'LibB'
|
|
write(b / 'src' / 'LibB.h')
|
|
write(b / 'src' / 'utility' / 'helper.h', '// B')
|
|
self.resolve(['LibA.h', 'LibB.h'])
|
|
helpers = [f for f in self.merged_files() if f.endswith('utility/helper.h')]
|
|
self.assertEqual(len(helpers), 2, f'one copy was silently dropped: {helpers}')
|
|
|
|
def test_legacy_utility_dir_is_private_not_public(self):
|
|
"""arduino-cli scopes utility/ to the owning library, never globally."""
|
|
self.resolve(['LegacyLib.h'])
|
|
self.assertFalse(
|
|
any(d.endswith('/utility') for d in self.exported_dirs()),
|
|
'utility/ is on the public include path — a generic utility/debug.h '
|
|
'would shadow every other library\'s',
|
|
)
|
|
self.assertTrue(
|
|
any(d.endswith('/utility') for d in self.private_dirs()),
|
|
'utility/ must stay reachable privately for the library\'s own sources',
|
|
)
|
|
|
|
def test_flat_library_exports_its_root(self):
|
|
self.resolve(['LegacyLib.h'])
|
|
roots = [d for d in self.exported_dirs() if d != '.']
|
|
self.assertTrue(
|
|
any(not r.endswith('/src') for r in roots),
|
|
f'flat-layout library must export its own root, got {roots}',
|
|
)
|
|
|
|
def test_sources_are_registered_with_library_prefixed_paths(self):
|
|
self.resolve(['FastLED.h'])
|
|
srcs = re.search(r'SRCS ([^\n]*)', self.cmake_text()).group(1)
|
|
self.assertIn('/src/FastLED.cpp', srcs)
|
|
|
|
def test_versioned_cache_dirname_is_path_safe(self):
|
|
"""Cache entries are `name@version-sha`; the subdir must stay valid."""
|
|
self.resolve(['FastLED.h'])
|
|
roots = [d for d in self.exported_dirs() if d != '.']
|
|
self.assertTrue(roots)
|
|
for r in roots:
|
|
self.assertNotRegex(r, r'[<>:"|?*\\\\]', f'unsafe path component: {r}')
|
|
|
|
def test_transitive_dependency_still_resolves(self):
|
|
"""Per-library scanning must not lose transitive pulls."""
|
|
dep = self.arduino_libs / 'DepLib'
|
|
write(dep / 'src' / 'DepLib.h')
|
|
write(dep / 'src' / 'DepLib.cpp')
|
|
top = self.arduino_libs / 'TopLib'
|
|
write(top / 'src' / 'TopLib.h', '#include <DepLib.h>')
|
|
write(top / 'src' / 'TopLib.cpp')
|
|
self.resolve(['TopLib.h'])
|
|
files = self.merged_files()
|
|
self.assertTrue(any(f.endswith('DepLib.h') for f in files),
|
|
'transitive dependency was not merged')
|
|
|
|
def test_still_one_component(self):
|
|
names, header_to_comp = self.resolve(['FastLED.h'])
|
|
self.assertEqual(names, ['user_libs_all'])
|
|
self.assertEqual(header_to_comp['FastLED.h'], 'user_libs_all')
|
|
|
|
|
|
class TestLegacyLayoutStillWorks(IncludeRootsBase):
|
|
"""VELXIO_PER_LIB_ROOTS=0 must reproduce the old behaviour exactly."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self._saved = espidf_mod._PER_LIB_ROOTS
|
|
espidf_mod._PER_LIB_ROOTS = False
|
|
|
|
def tearDown(self):
|
|
espidf_mod._PER_LIB_ROOTS = self._saved
|
|
super().tearDown()
|
|
|
|
def test_files_are_interleaved_at_component_root(self):
|
|
self.resolve(['FastLED.h', 'U8g2lib.h'])
|
|
files = self.merged_files()
|
|
self.assertIn('src/FastLED.h', files)
|
|
self.assertIn('src/U8g2lib.h', files)
|
|
|
|
def test_subdirectories_are_exported(self):
|
|
"""The legacy mechanism: every dir holding a file is exported, and only
|
|
the hand-maintained denylist keeps the dangerous ones out. A benign
|
|
internal subdir therefore DOES reach -I here — which is exactly the
|
|
design the per-library layout replaces."""
|
|
self.resolve(['U8g2lib.h'])
|
|
self.assertTrue(
|
|
any(d.endswith('fonts') for d in self.exported_dirs()),
|
|
'legacy mode should still export internal subdirectories',
|
|
)
|
|
|
|
def test_denylisted_dirs_are_kept_off_the_include_path(self):
|
|
"""The old safety net still works when the flag is off."""
|
|
self.resolve(['FastLED.h'])
|
|
for d in self.exported_dirs():
|
|
self.assertFalse(d.endswith('platforms/stub'), d)
|
|
self.assertFalse(d.endswith('fl/stl/asio/http'), d)
|
|
|
|
def test_no_private_include_dirs(self):
|
|
self.resolve(['LegacyLib.h'])
|
|
self.assertEqual(self.private_dirs(), [])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|