Skip to content

Commit e6f781f

Browse files
authored
Merge pull request #8 from FastLED/copilot/test-esp32c2-fix-build-badge
2 parents 31321d2 + 84684c7 commit e6f781f

3 files changed

Lines changed: 188 additions & 10 deletions

File tree

src/fbuild/packages/framework_esp32.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
Framework Download Process:
77
1. Download Arduino-ESP32 core (framework-arduinoespressif32)
88
2. Download ESP-IDF precompiled libraries (framework-arduinoespressif32-libs)
9-
3. Extract both archives (.tar.xz format)
9+
3. Extract both archives (.tar.xz, .tar.gz, or .zip format)
1010
4. Provide access to cores/, variants/, libraries/, and tools/
1111
1212
Framework Structure (after extraction):
@@ -48,6 +48,7 @@
4848

4949
import _thread
5050
import json
51+
import zipfile
5152
from pathlib import Path
5253
from typing import Any, Dict, List, Optional
5354

@@ -227,12 +228,18 @@ def _download_and_extract_to_temp(name: str, url: str, desc: str) -> tuple[str,
227228
file_size = archive_path.stat().st_size
228229
if file_size < 1024:
229230
raise ValueError(f"Archive too small ({file_size} bytes), likely a failed download")
230-
# Check magic bytes: XZ files start with 0xFD377A585A00
231+
with open(archive_path, "rb") as f:
232+
magic = f.read(6)
233+
# Check magic bytes based on archive format
231234
if archive_name.endswith((".tar.xz", ".txz")):
232-
with open(archive_path, "rb") as f:
233-
magic = f.read(6)
234235
if magic != b"\xfd7zXZ\x00":
235236
raise ValueError(f"Not a valid XZ file (magic bytes: {magic[:6]!r})")
237+
elif archive_name.endswith((".tar.gz", ".tgz")):
238+
if magic[:2] != b"\x1f\x8b":
239+
raise ValueError(f"Not a valid gzip file (magic bytes: {magic[:2]!r})")
240+
elif archive_name.endswith(".zip"):
241+
if magic[:4] != b"PK\x03\x04":
242+
raise ValueError(f"Not a valid ZIP file (magic bytes: {magic[:4]!r})")
236243
except (ValueError, OSError) as e:
237244
if attempt < max_attempts - 1:
238245
if self.show_progress:
@@ -250,8 +257,12 @@ def _download_and_extract_to_temp(name: str, url: str, desc: str) -> tuple[str,
250257
try:
251258
if self.show_progress:
252259
print(f"Extracting {desc}...")
253-
with tarfile.open(archive_path, "r:xz") as tar:
254-
tar.extractall(temp_dir)
260+
if archive_name.endswith(".zip"):
261+
with zipfile.ZipFile(archive_path, "r") as zf:
262+
zf.extractall(temp_dir)
263+
else:
264+
with tarfile.open(archive_path, "r:*") as tar:
265+
tar.extractall(temp_dir)
255266
except KeyboardInterrupt:
256267
raise
257268
except Exception as e:

tests/esp32c2/esp32c2.ino

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
// GPIO 8 is commonly used as LED on ESP32-C2 DevKit
2+
#define LED_PIN 8
3+
#define BAUD_RATE 115200
4+
15
void setup() {
2-
Serial.begin(115200);
3-
pinMode(LED_BUILTIN, OUTPUT);
6+
Serial.begin(BAUD_RATE);
7+
pinMode(LED_PIN, OUTPUT);
48

59
delay(1000); // wait for host to connect
610
Serial.println("TEST PASSED");
711
}
812

913
void loop() {
10-
digitalWrite(LED_BUILTIN, HIGH);
14+
digitalWrite(LED_PIN, HIGH);
1115
delay(500);
12-
digitalWrite(LED_BUILTIN, LOW);
16+
digitalWrite(LED_PIN, LOW);
1317
delay(500);
1418
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Test that framework extraction handles multiple archive formats.
2+
3+
The bug: _download_and_extract_to_temp() in framework_esp32.py hardcoded
4+
`tarfile.open(archive_path, "r:xz")` which fails when the framework or
5+
skeleton library archives are in .tar.gz or .zip format.
6+
7+
CI error: "tarfile.ReadError: not an lzma file"
8+
"""
9+
10+
import io
11+
import os
12+
import tarfile
13+
import zipfile
14+
from pathlib import Path
15+
from unittest.mock import MagicMock
16+
17+
import pytest
18+
19+
from fbuild.packages.cache import Cache
20+
from fbuild.packages.framework_esp32 import FrameworkESP32
21+
22+
23+
def _create_tar_xz_archive(archive_path: Path, content_dir_name: str = "framework") -> None:
24+
"""Create a valid .tar.xz archive with a single top-level directory."""
25+
with tarfile.open(archive_path, "w:xz") as tar:
26+
# Create a directory entry
27+
dirinfo = tarfile.TarInfo(name=content_dir_name)
28+
dirinfo.type = tarfile.DIRTYPE
29+
dirinfo.mode = 0o755
30+
tar.addfile(dirinfo)
31+
32+
# Create a file with random data (incompressible) to exceed 1024-byte validation
33+
fileinfo = tarfile.TarInfo(name=f"{content_dir_name}/package.json")
34+
data = os.urandom(4096)
35+
fileinfo.size = len(data)
36+
tar.addfile(fileinfo, io.BytesIO(data))
37+
38+
39+
def _create_tar_gz_archive(archive_path: Path, content_dir_name: str = "framework") -> None:
40+
"""Create a valid .tar.gz archive with a single top-level directory."""
41+
with tarfile.open(archive_path, "w:gz") as tar:
42+
dirinfo = tarfile.TarInfo(name=content_dir_name)
43+
dirinfo.type = tarfile.DIRTYPE
44+
dirinfo.mode = 0o755
45+
tar.addfile(dirinfo)
46+
47+
fileinfo = tarfile.TarInfo(name=f"{content_dir_name}/package.json")
48+
data = os.urandom(4096)
49+
fileinfo.size = len(data)
50+
tar.addfile(fileinfo, io.BytesIO(data))
51+
52+
53+
def _create_zip_archive(archive_path: Path, content_dir_name: str = "framework") -> None:
54+
"""Create a valid .zip archive with a single top-level directory."""
55+
with zipfile.ZipFile(archive_path, "w") as zf:
56+
zf.writestr(f"{content_dir_name}/package.json", os.urandom(4096).hex())
57+
58+
59+
class TestArchiveMagicByteValidation:
60+
"""Test magic byte validation for different archive formats."""
61+
62+
def test_xz_magic_bytes_valid(self, tmp_path: Path) -> None:
63+
"""Valid .tar.xz file passes magic byte check."""
64+
archive_path = tmp_path / "framework.tar.xz"
65+
_create_tar_xz_archive(archive_path)
66+
67+
with open(archive_path, "rb") as f:
68+
magic = f.read(6)
69+
assert magic == b"\xfd7zXZ\x00"
70+
71+
def test_gz_magic_bytes_valid(self, tmp_path: Path) -> None:
72+
"""Valid .tar.gz file has correct gzip magic bytes."""
73+
archive_path = tmp_path / "framework.tar.gz"
74+
_create_tar_gz_archive(archive_path)
75+
76+
with open(archive_path, "rb") as f:
77+
magic = f.read(2)
78+
assert magic == b"\x1f\x8b"
79+
80+
def test_zip_magic_bytes_valid(self, tmp_path: Path) -> None:
81+
"""Valid .zip file has correct PK magic bytes."""
82+
archive_path = tmp_path / "framework.zip"
83+
_create_zip_archive(archive_path)
84+
85+
with open(archive_path, "rb") as f:
86+
magic = f.read(4)
87+
assert magic == b"PK\x03\x04"
88+
89+
90+
class TestFrameworkExtractionFormats:
91+
"""Test that _download_and_extract_to_temp handles all supported formats."""
92+
93+
def _run_extraction(self, tmp_path: Path, archive_filename: str, create_fn) -> Path:
94+
"""Helper: create archive, mock download, run extraction, return extracted dir.
95+
96+
The _download_framework_components_parallel method uses cache_dir = install_dir.parent,
97+
so we place the install_dir inside a parent directory and pre-place the archive there.
98+
"""
99+
parent_dir = tmp_path / "parent"
100+
parent_dir.mkdir()
101+
install_dir = parent_dir / "install"
102+
install_dir.mkdir()
103+
104+
# Place the archive in parent_dir (where cache_dir points)
105+
archive_path = parent_dir / archive_filename
106+
create_fn(archive_path)
107+
108+
# Build a minimal FrameworkESP32 with a dummy URL ending in the archive filename
109+
mock_cache = MagicMock(spec=Cache)
110+
mock_cache.platforms_dir = tmp_path / "platforms"
111+
mock_cache.platforms_dir.mkdir()
112+
mock_cache.get_platform_path.return_value = install_dir
113+
114+
framework = FrameworkESP32(
115+
cache=mock_cache,
116+
framework_url=f"https://example.com/releases/download/1.0.0/{archive_filename}",
117+
libs_url="",
118+
show_progress=False,
119+
)
120+
121+
# Directly call the extraction logic by invoking the parallel download method
122+
# The archive already exists in cache_dir (install_dir.parent), so download is skipped
123+
framework._download_framework_components_parallel(install_dir)
124+
125+
return install_dir
126+
127+
def test_tar_xz_extraction(self, tmp_path: Path) -> None:
128+
"""Framework in .tar.xz format extracts correctly."""
129+
install_dir = self._run_extraction(tmp_path, "esp32-core-1.0.0.tar.xz", _create_tar_xz_archive)
130+
assert (install_dir / "package.json").exists()
131+
132+
def test_tar_gz_extraction(self, tmp_path: Path) -> None:
133+
"""Framework in .tar.gz format extracts correctly."""
134+
install_dir = self._run_extraction(tmp_path, "esp32-core-1.0.0.tar.gz", _create_tar_gz_archive)
135+
assert (install_dir / "package.json").exists()
136+
137+
def test_zip_extraction(self, tmp_path: Path) -> None:
138+
"""Framework in .zip format extracts correctly."""
139+
install_dir = self._run_extraction(tmp_path, "esp32-core-1.0.0.zip", _create_zip_archive)
140+
assert (install_dir / "package.json").exists()
141+
142+
def test_tar_gz_not_treated_as_xz(self, tmp_path: Path) -> None:
143+
"""A .tar.gz file must not fail with 'not an lzma file' error."""
144+
# This is the exact bug that was happening: .tar.gz or .zip was opened with "r:xz"
145+
cache_dir = tmp_path / "cache"
146+
cache_dir.mkdir()
147+
148+
archive_path = cache_dir / "framework.tar.gz"
149+
_create_tar_gz_archive(archive_path)
150+
151+
temp_dir = cache_dir / "_temp_extract_test"
152+
temp_dir.mkdir()
153+
154+
# Opening a .tar.gz with "r:*" (auto-detect) should work
155+
with tarfile.open(archive_path, "r:*") as tar:
156+
tar.extractall(temp_dir)
157+
158+
assert len(list(temp_dir.iterdir())) > 0
159+
160+
# Opening with "r:xz" should fail - this is what the old code did
161+
with pytest.raises(Exception):
162+
with tarfile.open(archive_path, "r:xz") as tar:
163+
tar.extractall(temp_dir)

0 commit comments

Comments
 (0)