feat(libraries): manifest auto-migration + name-affinity ranking + install-declares

Three-part fix for the library-contamination class of ESP32 compile
failures (a user's project kept pulling unrelated libraries because 79%
of projects compile with an empty manifest, forcing scan-all resolution
over ~1000 shared libraries):

1. Name-affinity ranking in _find_library_for_header: the provider whose
   name matches the header stem wins (exact normalized match > esp32
   arch declaration > contains-stem, alphabetical only as tie-break).
   Previously first-alphabetical won, so any stray library shipping a
   generic header (Servo.h has 6 providers) could hijack the build.

2. Manifest auto-migration: scan-all compiles now report the libraries
   the build really used (manifest_incomplete +
   manifest_suggested_libraries on the compile result); the editor folds
   single-candidate suggestions into the board's declared manifest after
   a green build, so the NEXT compile runs scoped. One green build = one
   migrated project; empty-manifest scan-all behaviour is untouched.

3. Install-declares: the Wokwi import library modal records successful
   installs in the active board's manifest, and .vlx export/import now
   round-trips board.libraries.

Tests: test_espidf_library_ranking.py (9) + library-manifest.test.ts (19).
This commit is contained in:
David Montero 2026-08-04 06:03:32 +02:00
parent 0c65de16ad
commit cd67362312
8 changed files with 480 additions and 7 deletions

View File

@ -894,6 +894,7 @@ class ESPIDFCompiler:
arduino_comp_name: str,
user_libs_dir: Path,
allowed_libraries: set[str] | None = None,
merged_libs: dict[str, str] | None = None,
) -> tuple[list[str], dict[str, str]]:
"""
BFS over ext_headers (and transitive includes) to discover all external
@ -1039,6 +1040,14 @@ class ESPIDFCompiler:
logger.info(f'[espidf] Merging "{lib_dir_name}" into user_libs_all for <{header}>')
found_any = True
header_to_comp[header] = 'user_libs_all'
if merged_libs is not None:
# Report the DISPLAY name (library.properties name=, the
# one the Library Manager shows) so the frontend can
# auto-declare it in the project manifest. Cache folder
# names carry a @version-hash suffix — strip it.
_lib_root = src_root.parent if src_root.name == 'src' else src_root
_props_name = self._parse_library_properties(_lib_root).get('name', '')
merged_libs[header] = _props_name or lib_dir_name.split('@', 1)[0]
# Preserve directory structure while merging libraries.
# Skip non-buildable directories like examples, tests, docs.
@ -1249,16 +1258,56 @@ class ESPIDFCompiler:
def _find_library_for_header(self, header: str, libs_dir: Path) -> Path | None:
"""
Search libs_dir for a library that provides `header`.
Returns the source root of the library (root or src/ subdirectory).
Search libs_dir for a library that provides `header`, preferring the
best-named candidate instead of the first alphabetical one.
First-alphabetical was how <Servo.h> (6 providers in the live cache)
could resolve to a random lib that merely ships a same-named file,
and how generic headers landed on unrelated libraries. Scoring:
+100 normalized library name == normalized header stem
("Servo.h" -> the lib actually named Servo/ESP32Servo...)
+40 library.properties architectures lists esp32 explicitly
(purpose-built beats a wildcard/absent declaration)
+20 library name CONTAINS the header stem, minus a length
penalty so "servo" outranks "simpleservoesp32"
Alphabetical remains the final tie-break so resolution stays
deterministic. Libraries with score 0 still resolve (legacy
behaviour) — this only changes WHICH provider wins when several
exist.
"""
stem = self._norm_lib_name(Path(header).stem)
best: tuple[float, str, Path] | None = None
for lib_dir in sorted(libs_dir.iterdir()):
if not lib_dir.is_dir():
continue
for src_root in [lib_dir, lib_dir / 'src']:
if (src_root / header).exists():
return src_root
return None
src_root = None
for cand in (lib_dir, lib_dir / 'src'):
if (cand / header).exists():
src_root = cand
break
if src_root is None:
continue
# Folder names in the shared cache look like "esp32servo@3.2.1-<hash>"
# — strip the version suffix before comparing.
folder = lib_dir.name.split('@', 1)[0]
props = self._parse_library_properties(lib_dir)
names = {self._norm_lib_name(folder)}
if props.get('name'):
names.add(self._norm_lib_name(props['name']))
score = 0.0
if stem and stem in names:
score += 100
arch = {a.strip().lower() for a in props.get('architectures', '').split(',') if a.strip()}
if self._ESP32_LIB_ARCH in arch:
score += 40
if stem and score < 100 and any(stem in n for n in names if n):
shortest = min((len(n) for n in names if stem in n), default=0)
score += max(5.0, 20.0 - (shortest - len(stem)) * 0.5)
if best is None or score > best[0]:
best = (score, lib_dir.name, src_root)
return best[2] if best else None
def _create_idf_component(
self,
@ -2263,6 +2312,12 @@ class ESPIDFCompiler:
)
ext_headers = list(ext_headers_set)
component_names: list[str] = []
# header -> display name of the library the resolver merged for
# it. Fed back to the client on scan-all success so the project
# manifest can be auto-completed (79% of projects compile with an
# empty manifest today; this migrates them one green build at a
# time instead of ever breaking them).
merged_libs_report: dict[str, str] = {}
# arduino-esp32 component name (directory basename of ARDUINO_ESP32_PATH)
arduino_comp_name = Path(self.arduino_path).name if self.arduino_path else 'arduino-esp32'
@ -2282,6 +2337,7 @@ class ESPIDFCompiler:
ext_headers, arduino_libs, esp32_libs,
arduino_comp_name, user_libs_dir,
allowed_libraries=allowed_libraries,
merged_libs=merged_libs_report,
)
# Patch main/CMakeLists.txt — REQUIRES and INCLUDE_DIRS for user_libs_all.
@ -2522,7 +2578,7 @@ class ESPIDFCompiler:
binary_b64 = base64.b64encode(merged_path.read_bytes()).decode('ascii')
logger.info(f'[espidf] Compilation successful — {len(binary_b64) // 1024} KB (base64), has_wifi={has_wifi}')
return {
result_ok: dict = {
'success': True,
'hex_content': None,
'binary_content': binary_b64,
@ -2531,6 +2587,16 @@ class ESPIDFCompiler:
'stdout': all_stdout,
'stderr': all_stderr,
}
# Scan-all build (no manifest): tell the client exactly which
# libraries the build really used, shaped like the existing
# manifest_suggested_libraries ({header: [candidates]}) so one
# consumer handles both this and the incomplete-manifest retry.
if allowed_libraries is None and merged_libs_report:
result_ok['manifest_incomplete'] = True
result_ok['manifest_suggested_libraries'] = {
h: [name] for h, name in merged_libs_report.items()
}
return result_ok
# Singleton instance

View File

@ -0,0 +1,140 @@
/**
* Library-manifest helpers (utils/libraryManifest.ts).
*
* These pure functions drive the 2026-08 manifest migration:
* - mergeSuggestedLibraries folds the backend's post-build report
* (`manifest_suggested_libraries`) into a board's declared manifest —
* conservatively: single-candidate entries only, normalized dedup,
* null when nothing changes (so callers skip the store write).
* - add/removeLibraryToManifest back the install-declares flow (agent
* install tool, Wokwi InstallLibrariesModal).
*/
import { describe, it, expect } from 'vitest';
import {
normalizeLibName,
bareLibName,
mergeSuggestedLibraries,
addLibraryToManifest,
removeLibraryFromManifest,
} from '../utils/libraryManifest';
describe('normalizeLibName', () => {
it('lowercases and strips non-alphanumerics', () => {
expect(normalizeLibName('Adafruit GFX Library')).toBe('adafruitgfxlibrary');
expect(normalizeLibName('Adafruit_GFX_Library')).toBe('adafruitgfxlibrary');
expect(normalizeLibName('ESP Async WebServer')).toBe('espasyncwebserver');
});
it('handles empty / falsy input', () => {
expect(normalizeLibName('')).toBe('');
});
});
describe('bareLibName', () => {
it('strips a trailing @version', () => {
expect(bareLibName('DHT sensor library@1.4.6')).toBe('DHT sensor library');
expect(bareLibName('ESP32Servo@3.2.1')).toBe('ESP32Servo');
});
it('leaves version-less specs untouched', () => {
expect(bareLibName('ESP32Servo')).toBe('ESP32Servo');
});
it('does not treat a leading @ as a version separator', () => {
expect(bareLibName('@scoped-thing')).toBe('@scoped-thing');
});
});
describe('mergeSuggestedLibraries', () => {
it('adds single-candidate suggestions to an empty manifest', () => {
const merged = mergeSuggestedLibraries(undefined, {
'ESPAsyncWebServer.h': ['ESP Async WebServer'],
'ESP32Servo.h': ['ESP32Servo'],
});
expect(merged).toEqual(['ESP Async WebServer', 'ESP32Servo']);
});
it('skips ambiguous (multi-candidate) suggestions', () => {
const merged = mergeSuggestedLibraries([], {
'Servo.h': ['Servo', 'ESP32Servo'], // ambiguous — never guess
'AsyncTCP.h': ['AsyncTCP'],
});
expect(merged).toEqual(['AsyncTCP']);
});
it('dedups against the current manifest with normalized comparison', () => {
const merged = mergeSuggestedLibraries(['esp async webserver'], {
'ESPAsyncWebServer.h': ['ESP Async WebServer'],
'AsyncTCP.h': ['AsyncTCP'],
});
expect(merged).toEqual(['esp async webserver', 'AsyncTCP']);
});
it('dedups repeated suggestions across headers', () => {
const merged = mergeSuggestedLibraries([], {
'FooA.h': ['FooLib'],
'FooB.h': ['Foo_Lib'], // same library, different spelling
});
expect(merged).toEqual(['FooLib']);
});
it('returns null when there is nothing new (no store churn)', () => {
expect(mergeSuggestedLibraries(['AsyncTCP'], { 'AsyncTCP.h': ['AsyncTCP'] })).toBeNull();
expect(mergeSuggestedLibraries(['A'], null)).toBeNull();
expect(mergeSuggestedLibraries(['A'], undefined)).toBeNull();
expect(mergeSuggestedLibraries(['A'], {})).toBeNull();
});
it('ignores empty or malformed candidate entries', () => {
const merged = mergeSuggestedLibraries([], {
'A.h': [''],
'B.h': [],
'C.h': ['RealLib'],
});
expect(merged).toEqual(['RealLib']);
});
it('preserves the existing manifest order and appends', () => {
const merged = mergeSuggestedLibraries(['First', 'Second'], { 'X.h': ['Third'] });
expect(merged).toEqual(['First', 'Second', 'Third']);
});
});
describe('addLibraryToManifest', () => {
it('adds a bare name from a versioned spec', () => {
expect(addLibraryToManifest(undefined, 'ESP32Servo@3.2.1')).toEqual(['ESP32Servo']);
});
it('returns null when already declared (normalized)', () => {
expect(addLibraryToManifest(['esp32servo'], 'ESP32Servo@3.2.1')).toBeNull();
expect(addLibraryToManifest(['ESP Async WebServer'], 'esp_async_webserver')).toBeNull();
});
it('appends to an existing manifest', () => {
expect(addLibraryToManifest(['A'], 'B')).toEqual(['A', 'B']);
});
it('rejects empty specs', () => {
expect(addLibraryToManifest(['A'], '')).toBeNull();
expect(addLibraryToManifest(['A'], ' ')).toBeNull();
});
});
describe('removeLibraryFromManifest', () => {
it('removes by normalized name regardless of version suffix', () => {
expect(removeLibraryFromManifest(['ESP32Servo', 'AsyncTCP'], 'esp32servo@3.2.1')).toEqual([
'AsyncTCP',
]);
});
it('returns null when the library is not declared', () => {
expect(removeLibraryFromManifest(['A'], 'B')).toBeNull();
expect(removeLibraryFromManifest([], 'B')).toBeNull();
expect(removeLibraryFromManifest(undefined, 'B')).toBeNull();
});
it('can empty the manifest (explicit [] is a valid manifest)', () => {
expect(removeLibraryFromManifest(['OnlyOne'], 'OnlyOne')).toEqual([]);
});
});

View File

@ -22,6 +22,7 @@ import { reportRunEvent } from '../../services/metricsService';
import { useProjectStore } from '../../store/useProjectStore';
import { LibraryManagerModal } from '../simulator/LibraryManagerModal';
import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal';
import { mergeSuggestedLibraries } from '../../utils/libraryManifest';
import { parseCompileResult } from '../../utils/compilationLogger';
import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger';
import { exportToWokwiZip } from '../../utils/wokwiZip';
@ -608,6 +609,18 @@ export const EditorToolbar = ({
if (result.has_wifi !== undefined) {
updateBoard(activeBoardId, { hasWifi: result.has_wifi });
}
// P2.4 auto-migration: a green build reports the libraries it
// really used; fold single-candidate ones into this board's
// declared manifest so the NEXT compile runs scoped instead of
// scan-all (the mode where unrelated libraries could leak in).
const mergedLibs = mergeSuggestedLibraries(
activeBoard?.libraries,
result.manifest_suggested_libraries,
);
if (mergedLibs) {
updateBoard(activeBoardId, { libraries: mergedLibs });
console.log('[manifest] auto-declared from build:', mergedLibs);
}
}
setMessage({ type: 'success', text: 'Compiled successfully' });
markCompiled();

View File

@ -1,6 +1,8 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { installLibrary } from '../../services/libraryService';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { addLibraryToManifest } from '../../utils/libraryManifest';
import './InstallLibrariesModal.css';
interface InstallLibrariesModalProps {
@ -98,6 +100,17 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
const result = await installLibrary(item.spec);
if (result.success) {
setItemStatus(item.spec, 'done');
// Installing must also DECLARE: record the library in the active
// board's manifest so the compile scope knows about it. Without
// this, Wokwi-imported projects compiled via scan-all forever
// (2026-08 library-contamination investigation).
const sim = useSimulatorStore.getState();
const boardId = sim.activeBoardId;
const board = sim.boards.find((b) => b.id === boardId);
if (boardId && board) {
const next = addLibraryToManifest(board.libraries, item.spec);
if (next) sim.updateBoard(boardId, { libraries: next });
}
} else {
setItemStatus(item.spec, 'error', result.error || 'Install failed');
}

View File

@ -39,6 +39,11 @@ export interface CompileResult {
stderr: string;
error?: string;
core_install_log?: string;
/** P2.4 — the manifest is missing libraries the build really used. */
manifest_incomplete?: boolean;
/** { header: [candidate library display names] } — single-candidate
* entries are safe to auto-declare (see utils/libraryManifest.ts). */
manifest_suggested_libraries?: Record<string, string[]> | null;
}
interface CompileStartResponse {

View File

@ -0,0 +1,82 @@
/**
* Library-manifest helpers — pure functions, no store imports.
*
* Why this exists (2026-08 library-contamination investigation): 79% of
* projects compile with an EMPTY manifest, which forces the backend into
* scan-all resolution over ~1000 shared libraries — the mode where an
* unrelated library can be dragged into the build. Rather than ever
* breaking those projects, the backend now reports the libraries a
* successful scan-all build actually used (`manifest_suggested_libraries`,
* shape { header: [candidateNames] }), and the frontend folds them into the
* board's declared manifest here. One green build = one migrated project.
*/
/** Library-name normalization, mirroring the backend's `_norm_lib_name`:
* lowercase, alphanumerics only, so "Adafruit GFX Library",
* "Adafruit_GFX_Library" and "adafruitgfxlibrary" all compare equal. */
export function normalizeLibName(name: string): string {
return (name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
/** Strip a trailing "@version" from a spec ("DHT sensor library@1.4.6"). */
export function bareLibName(spec: string): string {
const at = spec.lastIndexOf('@');
return at > 0 ? spec.slice(0, at) : spec;
}
/**
* Merge the backend's suggested libraries into the current manifest.
*
* Conservative by design:
* - only entries with EXACTLY ONE candidate are taken (an ambiguous
* suggestion must never guess its way into the manifest);
* - names already present (normalized comparison) are skipped;
* - returns `null` when there is nothing new, so callers can cheaply
* detect "no change" and avoid a store write / autosave churn.
*/
export function mergeSuggestedLibraries(
current: string[] | undefined,
suggested: Record<string, string[]> | null | undefined,
): string[] | null {
if (!suggested) return null;
const have = new Set((current ?? []).map(normalizeLibName));
const additions: string[] = [];
for (const candidates of Object.values(suggested)) {
if (!Array.isArray(candidates) || candidates.length !== 1) continue;
const name = String(candidates[0] ?? '').trim();
if (!name) continue;
const norm = normalizeLibName(name);
if (!norm || have.has(norm)) continue;
have.add(norm);
additions.push(name);
}
if (!additions.length) return null;
return [...(current ?? []), ...additions];
}
/**
* Add one just-installed library to a manifest (install-declares flow).
* Returns `null` when it is already declared.
*/
export function addLibraryToManifest(
current: string[] | undefined,
spec: string,
): string[] | null {
const name = bareLibName(spec).trim();
if (!name) return null;
const norm = normalizeLibName(name);
const have = new Set((current ?? []).map(normalizeLibName));
if (have.has(norm)) return null;
return [...(current ?? []), name];
}
/** Remove a library from a manifest (uninstall flow). Null = no change. */
export function removeLibraryFromManifest(
current: string[] | undefined,
spec: string,
): string[] | null {
if (!current?.length) return null;
const norm = normalizeLibName(bareLibName(spec));
const next = current.filter((n) => normalizeLibName(n) !== norm);
return next.length === current.length ? null : next;
}

View File

@ -53,6 +53,8 @@ export interface VlxPayload {
activeFileGroupId: string;
languageMode?: string;
serialBaudRate?: number;
/** Declared library manifest (compile scope). Absent in old files. */
libraries?: string[];
}>;
fileGroups: Record<string, Array<{ name: string; content: string }>>;
components: Component[];
@ -70,6 +72,10 @@ function serialisableBoard(b: BoardInstance) {
activeFileGroupId: b.activeFileGroupId,
languageMode: b.languageMode,
serialBaudRate: b.serialBaudRate,
// The declared manifest must survive the .vlx round-trip: dropping it
// silently reverted re-imported projects to scan-all resolution
// (2026-08 library-contamination investigation).
libraries: b.libraries,
};
}

View File

@ -0,0 +1,148 @@
"""Name-affinity ranking + merged-library reporting in the ESP-IDF resolver.
Two behaviours under test, both from the 2026-08 library-contamination
investigation:
1. `_find_library_for_header` used to return the FIRST alphabetical
provider. <Servo.h> has 6 providers in the live cache, so a random
stray could win over the library actually named after the header.
Now the best-named candidate wins (exact normalized name > esp32-arch
declaration > contains-stem), alphabetical only as tie-break.
2. `_resolve_library_components` reports which library it merged for each
header (display name from library.properties, version suffix stripped)
via the optional `merged_libs` out-param. That report becomes
`manifest_suggested_libraries` on scan-all compiles, which the frontend
uses to auto-complete the project manifest — the migration path for the
~79% of projects that still compile with an empty manifest.
No toolchain required: pure resolution logic.
"""
import sys
import tempfile
import shutil
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
def _mk(p: Path, content: str = "x") -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
class TestNameAffinityRanking(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp())
self.libs = self.tmp / "libraries"
self.c = ESPIDFCompiler()
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def test_exact_name_beats_alphabetical(self):
# Sorts FIRST alphabetically but is unrelated to the header.
_mk(self.libs / "aaaa-stray-robot" / "src" / "Servo.h")
_mk(self.libs / "aaaa-stray-robot" / "library.properties",
"name=AAAA Stray Robot\n")
# The library actually named after the header.
_mk(self.libs / "servo@1.2.0-cafebabe" / "src" / "Servo.h")
_mk(self.libs / "servo@1.2.0-cafebabe" / "library.properties",
"name=Servo\narchitectures=*\n")
got = self.c._find_library_for_header("Servo.h", self.libs)
self.assertIsNotNone(got)
self.assertIn("servo@1.2.0", str(got))
def test_esp32_arch_beats_wildcard_when_no_name_match(self):
_mk(self.libs / "alpha-generic" / "src" / "Foo.h")
_mk(self.libs / "alpha-generic" / "library.properties",
"name=Alpha Generic\narchitectures=*\n")
_mk(self.libs / "zeta-esp32-port" / "src" / "Foo.h")
_mk(self.libs / "zeta-esp32-port" / "library.properties",
"name=Zeta ESP32 Port\narchitectures=esp32\n")
got = self.c._find_library_for_header("Foo.h", self.libs)
self.assertIn("zeta-esp32-port", str(got))
def test_contains_stem_prefers_shorter_name(self):
_mk(self.libs / "simpleservoesp32" / "src" / "Bar.h")
_mk(self.libs / "simpleservoesp32" / "library.properties",
"name=SimpleBarKitDeluxeEdition\n")
_mk(self.libs / "zzz-bar-lib" / "src" / "Bar.h")
_mk(self.libs / "zzz-bar-lib" / "library.properties", "name=BarLib\n")
got = self.c._find_library_for_header("Bar.h", self.libs)
self.assertIn("zzz-bar-lib", str(got))
def test_single_provider_still_resolves_with_zero_score(self):
# Legacy behaviour preserved: an unrelated name is still a match
# when it is the only provider.
_mk(self.libs / "whatever" / "Baz.h")
got = self.c._find_library_for_header("Baz.h", self.libs)
self.assertIn("whatever", str(got))
def test_no_provider_returns_none(self):
_mk(self.libs / "something" / "Other.h")
self.assertIsNone(self.c._find_library_for_header("Missing.h", self.libs))
class TestMergedLibraryReport(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp())
self.libs = self.tmp / "libraries"
self.out = self.tmp / "project" / "user_libs"
self.out.mkdir(parents=True)
self.c = ESPIDFCompiler()
self.c.arduino_path = ""
self.c._core_headers_cache = None
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def _resolve(self, headers, merged):
return self.c._resolve_library_components(
headers, arduino_libs=self.libs, esp32_libs=None,
arduino_comp_name="arduino-esp32", user_libs_dir=self.out,
merged_libs=merged,
)
def test_reports_display_name_not_cache_folder(self):
_mk(self.libs / "espasyncwebserver@3.11.2-e82094d0" / "src" / "ESPAsyncWebServer.h")
_mk(self.libs / "espasyncwebserver@3.11.2-e82094d0" / "library.properties",
"name=ESP Async WebServer\narchitectures=*\n")
merged: dict[str, str] = {}
self._resolve(["ESPAsyncWebServer.h"], merged)
self.assertEqual(merged, {"ESPAsyncWebServer.h": "ESP Async WebServer"})
def test_falls_back_to_folder_name_without_version_suffix(self):
_mk(self.libs / "esp32servo@3.2.1-577147ab" / "src" / "ESP32Servo.h")
# library.properties present but nameless
_mk(self.libs / "esp32servo@3.2.1-577147ab" / "library.properties", "author=x\n")
merged: dict[str, str] = {}
self._resolve(["ESP32Servo.h"], merged)
self.assertEqual(merged, {"ESP32Servo.h": "esp32servo"})
def test_transitive_pulls_are_reported_too(self):
_mk(self.libs / "toplib" / "src" / "Top.h", "#include <Dep.h>\n")
_mk(self.libs / "toplib" / "library.properties", "name=TopLib\n")
_mk(self.libs / "deplib" / "src" / "Dep.h")
_mk(self.libs / "deplib" / "library.properties", "name=DepLib\n")
merged: dict[str, str] = {}
self._resolve(["Top.h"], merged)
self.assertEqual(merged.get("Top.h"), "TopLib")
self.assertEqual(merged.get("Dep.h"), "DepLib")
def test_none_out_param_keeps_legacy_shape(self):
_mk(self.libs / "somelib" / "src" / "A.h")
names, hdr2comp = self.c._resolve_library_components(
["A.h"], arduino_libs=self.libs, esp32_libs=None,
arduino_comp_name="arduino-esp32", user_libs_dir=self.out,
)
self.assertEqual(names, ["user_libs_all"])
self.assertEqual(hdr2comp.get("A.h"), "user_libs_all")
if __name__ == "__main__":
unittest.main()