diff --git a/backend/app/services/arduino_cli.py b/backend/app/services/arduino_cli.py index 0655bdec..1701f167 100644 --- a/backend/app/services/arduino_cli.py +++ b/backend/app/services/arduino_cli.py @@ -23,6 +23,85 @@ def _looks_like_missing_header(stderr: str | None) -> bool: return bool(stderr and _MISSING_HEADER_RE.search(stderr)) +# ── arduino-cli error humanizer ────────────────────────────────────────────── +# arduino-cli reports failures in two shapes: plain text on stderr, or (when +# invoked with --format json) a JSON object {"error": "...", "warnings": [...]} +# on stderr. We used to hand that raw blob straight to the caller, so a fresh +# desktop install with no library index yet — and no way to reach +# downloads.arduino.cc through the user's firewall / proxy — surfaced to the +# in-app agent as "the library server returns a JSON error" (2026-08-17, +# a Maker cancelled 16 minutes after subscribing). Turn the known shapes into +# one actionable sentence and keep the raw text under a separate key. + +_INDEX_DOWNLOAD_MARKERS = ( + "error downloading index", + "library_index.tar.bz2", + "library_index.json", + "package_index.json", + "error updating library index", + "error initializing instance", +) +_NETWORK_MARKERS = ( + "downloads.arduino.cc", + "dial tcp", + "dial udp", + "network is unreachable", + "no such host", + "connection refused", + "tls handshake", + "i/o timeout", + "context deadline exceeded", + "proxy", +) + + +def humanize_cli_error(raw: str | None, *, action: str = "run arduino-cli") -> str: + """One readable sentence for an arduino-cli failure. + + `raw` is whatever arduino-cli wrote (stderr, or stdout+stderr). `action` + names what we were doing ("search libraries", "install Adafruit GFX") so + the message reads naturally in the UI and in the agent's tool result. + """ + text = (raw or "").strip() + # JSON-shaped error (--format json): {"error": "...", "warnings": [...]} + if text.startswith("{"): + try: + import json as _json + obj = _json.loads(text) + if isinstance(obj, dict): + parts = [str(obj.get("error") or "")] + warns = obj.get("warnings") + if isinstance(warns, list): + parts.extend(str(w) for w in warns) + text = "\n".join(p for p in parts if p).strip() + except ValueError: + pass + low = text.lower() + + if any(m in low for m in _INDEX_DOWNLOAD_MARKERS) or ( + "index" in low and any(m in low for m in _NETWORK_MARKERS) + ): + return ( + f"Could not {action}: the Arduino library index is not available yet " + "and arduino-cli could not download it from downloads.arduino.cc. " + "Check your internet connection, firewall or proxy and try again." + ) + if "can't download library" in low or ( + "download" in low and any(m in low for m in _NETWORK_MARKERS) + ): + return ( + f"Could not {action}: the download from downloads.arduino.cc failed. " + "Check your internet connection, firewall or proxy and try again." + ) + if "not found" in low or "no library found" in low: + first = next((ln.strip() for ln in text.splitlines() if ln.strip()), text) + return f"Could not {action}: {first[:300]}" + if not text: + return f"Could not {action}: arduino-cli returned no output." + first = next((ln.strip() for ln in text.splitlines() if ln.strip()), text) + return f"Could not {action}: {first[:300]}" + + class ArduinoCLIService: # Board manager URLs for cores that aren't built-in CORE_URLS: dict[str, str] = { @@ -658,8 +737,12 @@ class ArduinoCLIService: if result.returncode != 0: print(f"Error searching libraries: {stderr}") - return {"success": False, "error": stderr} - + return { + "success": False, + "error": humanize_cli_error(stderr or stdout, action="search libraries"), + "raw_error": stderr, + } + import json try: results = json.loads(stdout) @@ -686,7 +769,13 @@ class ArduinoCLIService: return {"success": True, "libraries": libraries} except json.JSONDecodeError: - return {"success": False, "error": "Invalid output format from arduino-cli"} + # rc was 0 but stdout is not JSON: arduino-cli printed a + # progress/warning line ahead of (or instead of) the payload. + return { + "success": False, + "error": humanize_cli_error(stderr or stdout, action="search libraries"), + "raw_error": (stderr or stdout)[:2000], + } except Exception as e: print(f"Exception searching libraries: {e}") @@ -763,7 +852,15 @@ class ArduinoCLIService: "requested_version": version, } print(f"Failed to install {library_name}: {result.stderr}") - return {"success": False, "error": result.stderr, "stdout": result.stdout} + return { + "success": False, + "error": humanize_cli_error( + (result.stderr or "") + "\n" + (result.stdout or ""), + action=f"install {library_name}", + ), + "raw_error": result.stderr, + "stdout": result.stdout, + } except Exception as e: print(f"Exception installing library: {e}") diff --git a/test/backend/unit/test_arduino_cli_error_messages.py b/test/backend/unit/test_arduino_cli_error_messages.py new file mode 100644 index 00000000..dbf1e765 --- /dev/null +++ b/test/backend/unit/test_arduino_cli_error_messages.py @@ -0,0 +1,91 @@ +"""arduino-cli failures reach the UI / agent as one actionable sentence. + +Regression for 2026-08-17: a fresh desktop install has no library index +yet; when arduino-cli could not download it (firewall / proxy) `lib search` +returned a JSON blob on stderr and we forwarded it verbatim, so the in-app +agent told the user "the library server returns a JSON error" ten times in +a row. The strings below are the real arduino-cli 1.0.4 output captured +offline (`unshare -n`) against an empty data dir. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO / "backend")) + +from app.services.arduino_cli import humanize_cli_error # noqa: E402 + +SEARCH_JSON_STDERR = ( + '{\n "error": "Error updating library index: Error downloading index ' + "'https://downloads.arduino.cc/libraries/library_index.tar.bz2': Get " + '\\"https://downloads.arduino.cc/libraries/library_index.tar.bz2\\": dial tcp: ' + 'lookup downloads.arduino.cc on 127.0.0.53:53: dial udp 127.0.0.53:53: connect: ' + 'network is unreachable",\n "warnings": [\n "Error initializing instance: ' + "Error downloading index 'https://downloads.arduino.cc/libraries/library_index.tar.bz2'\"\n ]\n}" +) + +INSTALL_NO_INDEX_STDERR = ( + "Error initializing instance: Error downloading index " + "'https://downloads.arduino.cc/libraries/library_index.tar.bz2': Get " + '"https://downloads.arduino.cc/libraries/library_index.tar.bz2": dial tcp: lookup ' + "downloads.arduino.cc on 127.0.0.53:53: dial udp 127.0.0.53:53: connect: network is unreachable\n" + "Error initializing instance: Loading index file: reading library_index.json: open " + "/x/data/library_index.json: no such file or directory\n" +) + +INSTALL_NO_NET_WITH_INDEX = ( + "Downloading Adafruit GFX Library@1.12.6...\n" + 'Adafruit GFX Library@1.12.6 Get "https://downloads.arduino.cc/libraries/github.com/adafruit/' + 'Adafruit_GFX_Library-1.12.6.zip?query=install": dial tcp: lookup downloads.arduino.cc on ' + "127.0.0.53:53: dial udp 127.0.0.53:53: connect: network is unreachable\n" + "Error installing Adafruit GFX Library: Can't download library: Get " + '"https://downloads.arduino.cc/libraries/github.com/adafruit/Adafruit_GFX_Library-1.12.6.zip' + '?query=install": dial tcp: lookup downloads.arduino.cc on 127.0.0.53:53: dial udp ' + "127.0.0.53:53: connect: network is unreachable\n" +) + + +def test_search_json_error_becomes_index_download_sentence(): + msg = humanize_cli_error(SEARCH_JSON_STDERR, action="search libraries") + assert msg.startswith("Could not search libraries:") + assert "library index" in msg + assert "downloads.arduino.cc" in msg + assert "firewall or proxy" in msg + # No JSON leaks through to the user / agent. + assert "{" not in msg and "warnings" not in msg + + +def test_install_without_index_is_the_same_story(): + msg = humanize_cli_error(INSTALL_NO_INDEX_STDERR, action="install Adafruit GFX Library") + assert msg.startswith("Could not install Adafruit GFX Library:") + assert "library index" in msg + assert "downloads.arduino.cc" in msg + + +def test_install_with_index_but_no_network_names_the_download(): + msg = humanize_cli_error(INSTALL_NO_NET_WITH_INDEX, action="install Adafruit GFX Library") + assert msg.startswith("Could not install Adafruit GFX Library:") + assert "download from downloads.arduino.cc failed" in msg + assert "firewall or proxy" in msg + + +def test_unknown_library_passes_the_cli_line_through(): + msg = humanize_cli_error("Error installing NoSuchLib: Library 'NoSuchLib' not found", action="install NoSuchLib") + assert msg == "Could not install NoSuchLib: Error installing NoSuchLib: Library 'NoSuchLib' not found" + + +def test_empty_and_unknown_shapes_still_return_a_sentence(): + assert humanize_cli_error("", action="search libraries") == ( + "Could not search libraries: arduino-cli returned no output." + ) + assert humanize_cli_error(None, action="search libraries").startswith("Could not search libraries:") + long = "x" * 1000 + msg = humanize_cli_error(long, action="search libraries") + assert msg.startswith("Could not search libraries: ") and len(msg) < 400 + + +def test_multiline_unknown_error_uses_first_line_only(): + msg = humanize_cli_error("\n\nfirst line here\nsecond line\n", action="search libraries") + assert msg == "Could not search libraries: first line here"