From 3fe71b57af4a1735dda8fd7f495fe67aa83e9ed5 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 10 Mar 2026 01:25:49 -0300 Subject: [PATCH] feat: enhance library installation to support Wokwi-hosted libraries and update tests accordingly --- backend/app/services/arduino_cli.py | 116 +++++++++++++++++- .../src/__tests__/install-libraries.test.ts | 36 +++--- .../simulator/InstallLibrariesModal.css | 11 ++ .../simulator/InstallLibrariesModal.tsx | 18 ++- frontend/src/utils/wokwiZip.ts | 8 +- 5 files changed, 164 insertions(+), 25 deletions(-) diff --git a/backend/app/services/arduino_cli.py b/backend/app/services/arduino_cli.py index 84c2d52..c6ea41c 100644 --- a/backend/app/services/arduino_cli.py +++ b/backend/app/services/arduino_cli.py @@ -439,8 +439,13 @@ class ArduinoCLIService: async def install_library(self, library_name: str) -> dict: """ - Install an Arduino library + Install an Arduino library. + Handles standard library names as well as Wokwi-hosted entries in + the form "LibName@wokwi:projectHash". """ + if '@wokwi:' in library_name: + return await self._install_wokwi_library(library_name) + try: print(f"Installing library: {library_name}") @@ -463,6 +468,115 @@ class ArduinoCLIService: print(f"Exception installing library: {e}") return {"success": False, "error": str(e)} + async def _install_wokwi_library(self, library_spec: str) -> dict: + """ + Download and install a Wokwi-hosted library. + + Wokwi stores custom libraries as projects. The spec format is: + LibName@wokwi:projectHash + and the project ZIP is available at: + https://wokwi.com/api/projects/{projectHash}/zip + + The ZIP is extracted into the Arduino user libraries directory so that + arduino-cli can find the headers during compilation. + """ + import json as _json + import urllib.request + import urllib.error + import zipfile + import os + import shutil + + parts = library_spec.split('@wokwi:', 1) + lib_name = parts[0].strip() + project_hash = parts[1].strip() + print(f"Installing Wokwi library: {lib_name} (project: {project_hash})") + + # ── Locate the Arduino user libraries directory ──────────────────────── + try: + def _get_config(): + return subprocess.run( + [self.cli_path, "config", "dump", "--format", "json"], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + cfg_result = await asyncio.to_thread(_get_config) + cfg = _json.loads(cfg_result.stdout) + config_dict = cfg.get("config", cfg) + dirs = config_dict.get("directories", {}) + user_dir = dirs.get("user", "") or dirs.get("sketchbook", "") + if not user_dir: + return {"success": False, "error": "Could not determine Arduino user directory from config"} + lib_dir = Path(user_dir) / "libraries" / lib_name + except Exception as e: + return {"success": False, "error": f"Failed to read arduino-cli config: {e}"} + + # ── Download project ZIP ─────────────────────────────────────────────── + url = f"https://wokwi.com/api/projects/{project_hash}/zip" + tmp_path = None + try: + with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: + tmp_path = tmp.name + + def _download(): + req = urllib.request.Request( + url, + headers={"User-Agent": "velxio-arduino-emulator/1.0"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp, \ + open(tmp_path, 'wb') as out: + out.write(resp.read()) + except urllib.error.HTTPError as http_err: + raise RuntimeError( + f"Could not download Wokwi library '{lib_name}' " + f"(HTTP {http_err.code}). " + f"Wokwi-hosted libraries require the Wokwi platform and " + f"cannot be installed automatically in a local environment." + ) from http_err + + await asyncio.to_thread(_download) + + # ── Extract into the libraries directory ─────────────────────────── + if lib_dir.exists(): + shutil.rmtree(lib_dir) + lib_dir.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(tmp_path, 'r') as zf: + for zi in zf.infolist(): + # Skip directories and Wokwi-specific files + if zi.is_dir(): + continue + fname = zi.filename + basename = Path(fname).name + if not basename or basename == 'wokwi-project.txt': + continue + # Flatten any subdirectory structure + dest = lib_dir / basename + dest.write_bytes(zf.read(fname)) + + # Create a minimal library.properties so arduino-cli recognises it + props = lib_dir / "library.properties" + if not props.exists(): + props.write_text( + f"name={lib_name}\nversion=1.0.0\nauthor=Wokwi\n" + f"sentence=Wokwi-hosted library\nparagraph=\ncategory=Other\n" + f"url=https://wokwi.com/projects/{project_hash}\n" + f"architectures=*\n" + ) + + print(f"Installed Wokwi library {lib_name} to {lib_dir}") + return {"success": True, "stdout": f"Installed {lib_name} from Wokwi project {project_hash}"} + + except Exception as e: + print(f"Error installing Wokwi library {lib_name}: {e}") + return {"success": False, "error": str(e)} + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass + async def list_installed_libraries(self) -> dict: """ List all installed Arduino libraries diff --git a/frontend/src/__tests__/install-libraries.test.ts b/frontend/src/__tests__/install-libraries.test.ts index 537af33..10b7c5a 100644 --- a/frontend/src/__tests__/install-libraries.test.ts +++ b/frontend/src/__tests__/install-libraries.test.ts @@ -98,11 +98,11 @@ describe('parseLibrariesTxt — unit', () => { expect(result).toEqual(['LibA', 'LibB', 'LibC']); }); - it('excludes @wokwi: hash entries', () => { + it('includes @wokwi: hash entries (backend handles them)', () => { const result = parseLibrariesTxt( - 'GoodLib\nBadLib@wokwi:abc123deadbeef\nAnotherGood\n', + 'GoodLib\nWokwiLib@wokwi:abc123deadbeef\nAnotherGood\n', ); - expect(result).toEqual(['GoodLib', 'AnotherGood']); + expect(result).toEqual(['GoodLib', 'WokwiLib@wokwi:abc123deadbeef', 'AnotherGood']); }); it('handles inline whitespace (leading/trailing spaces on a line)', () => { @@ -115,18 +115,18 @@ describe('parseLibrariesTxt — unit', () => { expect(result).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']); }); - it('parses calculator-breakout-icon libraries.txt → only standard libs, no @wokwi:', () => { + it('parses calculator-breakout-icon libraries.txt → standard libs AND @wokwi: entries', () => { const result = parseLibrariesTxt(CALCULATOR_LIBRARIES_TXT); expect(result).toContain('Adafruit GFX Library'); expect(result).toContain('Adafruit FT6206 Library'); expect(result).toContain('Adafruit ILI9341'); expect(result).toContain('SD'); expect(result).toContain('Adafruit SSD1351 library'); - // none of the @wokwi: entries - for (const entry of result) { - expect(entry).not.toContain('@wokwi:'); - } - expect(result).toHaveLength(5); + // Wokwi-hosted entries must also be present + expect(result).toContain('LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d'); + expect(result).toContain('LC_baseTools@wokwi:95340986110645c1b45e55597a7caf4d023d4b4a'); + // 5 standard + 10 @wokwi: entries visible in CALCULATOR_LIBRARIES_TXT snippet + expect(result.length).toBeGreaterThan(5); }); it('parses ServoOverdone libraries.txt → [Servo]', () => { @@ -153,13 +153,13 @@ describe('importFromWokwiZip — libraries field', () => { expect(result.libraries).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']); }); - it('excludes @wokwi: entries from the returned array', async () => { + it('includes @wokwi: entries in the returned array (backend installs them)', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}', - 'libraries.txt': 'GoodLib\nBadLib@wokwi:deadbeef12345\n', + 'libraries.txt': 'GoodLib\nWokwiLib@wokwi:deadbeef12345\n', }); const result = await importFromWokwiZip(file); - expect(result.libraries).toEqual(['GoodLib']); + expect(result.libraries).toEqual(['GoodLib', 'WokwiLib@wokwi:deadbeef12345']); }); it('returns empty array when libraries.txt is only comments', async () => { @@ -180,16 +180,18 @@ describe('importFromWokwiZip — libraries field', () => { expect(result.libraries).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']); }); - it('calculator ZIP — 5 standard libs, zero @wokwi: entries', async () => { + it('calculator ZIP — includes both standard libs AND @wokwi: entries', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}', 'libraries.txt': CALCULATOR_LIBRARIES_TXT, }); const result = await importFromWokwiZip(file); - expect(result.libraries).toHaveLength(5); - for (const lib of result.libraries) { - expect(lib).not.toContain('@wokwi:'); - } + // 5 standard Arduino Library Manager libs + expect(result.libraries).toContain('Adafruit GFX Library'); + expect(result.libraries).toContain('SD'); + // Wokwi-hosted libs must also be present + expect(result.libraries).toContain('LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d'); + expect(result.libraries.length).toBeGreaterThan(5); }); it('libraries field does not interfere with files[], components[], wires[]', async () => { diff --git a/frontend/src/components/simulator/InstallLibrariesModal.css b/frontend/src/components/simulator/InstallLibrariesModal.css index f5670c9..d30d62c 100644 --- a/frontend/src/components/simulator/InstallLibrariesModal.css +++ b/frontend/src/components/simulator/InstallLibrariesModal.css @@ -179,6 +179,17 @@ cursor: help; } +.ilib-badge--wokwi { + color: #7dd3fc; + background: #0c2340; + border: 1px solid #1e4070; + margin-left: 6px; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.03em; + cursor: default; +} + /* Spinner */ .ilib-spinner { flex-shrink: 0; diff --git a/frontend/src/components/simulator/InstallLibrariesModal.tsx b/frontend/src/components/simulator/InstallLibrariesModal.tsx index 958fa54..5518113 100644 --- a/frontend/src/components/simulator/InstallLibrariesModal.tsx +++ b/frontend/src/components/simulator/InstallLibrariesModal.tsx @@ -126,9 +126,20 @@ export const InstallLibrariesModal: React.FC = ({ {/* Library list */}
- {items.map((item) => ( + {items.map((item) => { + // For Wokwi-hosted libraries ("LibName@wokwi:hash"), show only the LibName + const displayName = item.name.includes('@wokwi:') + ? item.name.split('@wokwi:')[0] + : item.name; + const isWokwiLib = item.name.includes('@wokwi:'); + return (
- {item.name} + + {displayName} + {isWokwiLib && ( + wokwi + )} + {item.status === 'pending' && pending} {item.status === 'installing' && ( @@ -156,7 +167,8 @@ export const InstallLibrariesModal: React.FC = ({ )}
- ))} + ); + })}
{/* Footer */} diff --git a/frontend/src/utils/wokwiZip.ts b/frontend/src/utils/wokwiZip.ts index 78f70c5..422a304 100644 --- a/frontend/src/utils/wokwiZip.ts +++ b/frontend/src/utils/wokwiZip.ts @@ -48,7 +48,7 @@ export interface ImportResult { components: VelxioComponent[]; wires: Wire[]; files: Array<{ name: string; content: string }>; - /** Standard Arduino library names parsed from libraries.txt (Wokwi-only @wokwi: entries are excluded). */ + /** Library names parsed from libraries.txt. Includes both standard Arduino Library Manager names and Wokwi-hosted entries in the form "LibName@wokwi:hash". */ libraries: string[]; } @@ -137,14 +137,14 @@ function metadataIdToWokwiType(metadataId: string): string { /** * Parse the contents of a Wokwi libraries.txt file. * - Strips blank lines and # comments - * - Excludes Wokwi-hosted entries in the form name@wokwi:hash - * (they have no arduino-cli installable equivalent) + * - Includes Wokwi-hosted entries in the form name@wokwi:hash + * so the backend can download and install them from wokwi.com */ export function parseLibrariesTxt(content: string): string[] { const libs: string[] = []; for (const raw of content.split('\n')) { const line = raw.trim(); - if (!line || line.startsWith('#') || line.includes('@wokwi:')) continue; + if (!line || line.startsWith('#')) continue; libs.push(line); } return libs;