Skip to content

Commit cf02df0

Browse files
committed
Merge branch 'ci/fixes'
2 parents edd79b3 + 0639635 commit cf02df0

File tree

11 files changed

+535
-153
lines changed

11 files changed

+535
-153
lines changed

.github/scripts/runtime_table_generator.py

Lines changed: 293 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import sys
33
import os
4+
import re
45
from datetime import datetime
56

67
SUCCESS_SYMBOL = ":white_check_mark:"
@@ -12,16 +13,206 @@
1213
data = json.load(f)
1314
tests = sorted(data["stats"]["suite_details"], key=lambda x: x["name"])
1415

16+
def _safe_env_list(name: str):
17+
"""Parse list-like env variables that look like ['esp32','esp32s2'].
18+
Accepts empty/None and returns a Python list of strings.
19+
"""
20+
raw = os.environ.get(name, "").strip()
21+
if not raw:
22+
return []
23+
# Normalize to JSON by replacing single quotes with double quotes
24+
try:
25+
normalized = raw.replace("'", '"')
26+
return json.loads(normalized)
27+
except Exception:
28+
# Fallback: split on commas and strip brackets
29+
raw = raw.strip("[]")
30+
return [p.strip().strip("'\"") for p in raw.split(",") if p.strip()]
31+
32+
33+
def _scan_artifacts_for_tests():
34+
"""Discover built tests from downloaded artifacts.
35+
Returns mapping test_name -> set(targets) for which a build*.tmp dir exists containing ci.yml (or even without).
36+
"""
37+
results = {}
38+
root = os.path.join(os.getcwd(), "runtime-test-results", "build_artifacts")
39+
if not os.path.isdir(root):
40+
return results
41+
for dirpath, dirnames, filenames in os.walk(root):
42+
# Match .../<target>/<test_name>/buildX.tmp
43+
if not re.search(r"build\d*\.tmp$", dirpath):
44+
continue
45+
parts = dirpath.split(os.sep)
46+
if len(parts) < 3:
47+
continue
48+
test_name = parts[-2]
49+
target = parts[-3]
50+
results.setdefault(test_name, set()).add(target)
51+
return results
52+
53+
54+
def _read_local_ci_yaml_if_available(test_name: str) -> str:
55+
"""Try to find a ci.yml stored beside compiled binaries downloaded by results workflow.
56+
Expected paths: runtime-test-results/build_artifacts/**/build*.tmp/ci.yml with sketch folder named test_name.
57+
"""
58+
# Walk known root
59+
root = os.path.join(os.getcwd(), "runtime-test-results", "build_artifacts")
60+
if not os.path.isdir(root):
61+
return ""
62+
# Traverse limited depth for performance
63+
for dirpath, dirnames, filenames in os.walk(root):
64+
# Quick filter: folder path must contain the test folder name and a build*.tmp
65+
if test_name not in dirpath:
66+
continue
67+
if not re.search(r"build\d*\.tmp", dirpath):
68+
continue
69+
if "ci.yml" in filenames:
70+
try:
71+
with open(os.path.join(dirpath, "ci.yml"), "r") as f:
72+
return f.read()
73+
except Exception:
74+
continue
75+
return ""
76+
77+
78+
def _parse_ci_yml_minimal(content: str):
79+
"""Very small YAML reader for the fields we need: targets, platforms, requires, requires_any.
80+
Returns dict: {targets: {t: bool}, platforms: {p: bool}, requires: [str], requires_any: [str]}
81+
"""
82+
parsed = {"targets": {}, "platforms": {}, "requires": [], "requires_any": []}
83+
if not content:
84+
return parsed
85+
86+
lines = content.splitlines()
87+
current = None
88+
for line in lines:
89+
# Remove comments and trailing spaces
90+
line = line.split("#", 1)[0].rstrip()
91+
if not line.strip():
92+
continue
93+
# Top-level keys
94+
if not line.startswith(" "):
95+
if line.startswith("targets:"):
96+
current = "targets"
97+
continue
98+
if line.startswith("platforms:"):
99+
current = "platforms"
100+
continue
101+
if line.startswith("requires:"):
102+
current = "requires"
103+
continue
104+
if line.startswith("requires_any:"):
105+
current = "requires_any"
106+
continue
107+
current = None
108+
continue
109+
110+
# Nested under a current section (2+ spaces)
111+
if current in ("targets", "platforms"):
112+
m = re.match(r"\s*([A-Za-z0-9_\-]+)\s*:\s*(\w+)", line)
113+
if m:
114+
key = m.group(1)
115+
val = m.group(2).lower()
116+
parsed[current][key] = (val != "false" and val != "null")
117+
elif current in ("requires", "requires_any"):
118+
m = re.match(r"\s*-[\s]*(.+)$", line)
119+
if m:
120+
parsed[current].append(m.group(1).strip())
121+
122+
return parsed
123+
124+
125+
def _read_local_sdkconfig_for_target(target: str) -> str:
126+
"""Find sdkconfig for target inside downloaded build artifacts.
127+
Searches any build*.tmp directory under .arduino/tests/<target>/<test>/build*.tmp
128+
Returns first found content.
129+
"""
130+
root = os.path.join(os.getcwd(), "runtime-test-results", "build_artifacts")
131+
if not os.path.isdir(root):
132+
return ""
133+
for dirpath, dirnames, filenames in os.walk(root):
134+
if target not in dirpath:
135+
continue
136+
if not re.search(r"build\d*\.tmp$", dirpath):
137+
continue
138+
if "sdkconfig" in filenames:
139+
try:
140+
with open(os.path.join(dirpath, "sdkconfig"), "r") as f:
141+
return f.read()
142+
except Exception:
143+
continue
144+
return ""
145+
146+
147+
def _meets_requirements(ci_cfg: dict, sdkconfig_text: str) -> bool:
148+
# Missing files -> assume requirements met (match CI behavior to build and fail later if needed)
149+
if not sdkconfig_text:
150+
return True
151+
# AND list
152+
for req in ci_cfg.get("requires", []):
153+
req = req.strip()
154+
if not req:
155+
continue
156+
if not any(line.startswith(req) for line in sdkconfig_text.splitlines()):
157+
return False
158+
# OR list
159+
req_any = ci_cfg.get("requires_any", [])
160+
if req_any:
161+
if not any(any(line.startswith(r.strip()) for line in sdkconfig_text.splitlines()) for r in req_any):
162+
return False
163+
return True
164+
165+
166+
def _parse_fqbn_counts(content: str):
167+
"""Parse ci.yml 'fqbn' section and count entries per target.
168+
Returns dict target -> int
169+
"""
170+
counts = {}
171+
if not content:
172+
return counts
173+
lines = content.splitlines()
174+
in_fqbn = False
175+
current_target = None
176+
for line in lines:
177+
raw = line.split("#", 1)[0].rstrip()
178+
if not raw:
179+
continue
180+
if not raw.startswith(" "):
181+
# top-level key
182+
if raw.startswith("fqbn:"):
183+
in_fqbn = True
184+
current_target = None
185+
continue
186+
else:
187+
if in_fqbn:
188+
# leaving fqbn section
189+
break
190+
continue
191+
if not in_fqbn:
192+
continue
193+
# two-space indent target: line
194+
m_target = re.match(r"\s{2}([A-Za-z0-9_\-]+)\s*:\s*", raw)
195+
if m_target:
196+
current_target = m_target.group(1)
197+
counts.setdefault(current_target, 0)
198+
continue
199+
# four-space list item under current target
200+
if current_target:
201+
if re.match(r"\s{4}-\s", raw):
202+
counts[current_target] = counts.get(current_target, 0) + 1
203+
return counts
204+
205+
15206
# Get commit SHA from command line argument or environment variable
16207
commit_sha = None
17208
if len(sys.argv) < 2 or len(sys.argv) > 3:
18209
print(f"Usage: python {sys.argv[0]} <test_results.json> [commit_sha]", file=sys.stderr)
19210
sys.exit(1)
20-
elif len(sys.argv) == 3: # Commit SHA is provided as argument
211+
elif len(sys.argv) == 3: # Commit SHA is provided as argument
21212
commit_sha = sys.argv[2]
22-
elif "GITHUB_SHA" in os.environ: # Commit SHA is provided as environment variable
213+
elif "GITHUB_SHA" in os.environ: # Commit SHA is provided as environment variable
23214
commit_sha = os.environ["GITHUB_SHA"]
24-
else: # Commit SHA is not provided
215+
else: # Commit SHA is not provided
25216
print("Commit SHA is not provided. Please provide it as an argument or set the GITHUB_SHA environment variable.", file=sys.stderr)
26217
sys.exit(1)
27218

@@ -45,12 +236,23 @@
45236
proc_test_data = {}
46237
target_list = []
47238

239+
# Build executed tests map and collect targets
240+
executed_tests_index = {} # {(platform, target, test_name): {tests, failures, errors}}
241+
executed_run_counts = {} # {(platform, target, test_name): int}
242+
48243
for test in tests:
49244
if test["name"].startswith("performance_"):
50245
continue
51246

52-
_, platform, target, test_name = test["name"].split("_", 3)
53-
test_name = test_name[:-1]
247+
try:
248+
test_type, platform, target, rest = test["name"].split("_", 3)
249+
except ValueError:
250+
# Unexpected name, skip
251+
continue
252+
253+
# Remove an optional trailing numeric index (multi-FQBN builds)
254+
m = re.match(r"(.+?)(\d+)?$", rest)
255+
test_name = m.group(1) if m else rest
54256

55257
if target not in target_list:
56258
target_list.append(target)
@@ -72,9 +274,70 @@
72274
proc_test_data[platform][test_name][target]["failures"] += test["failures"]
73275
proc_test_data[platform][test_name][target]["errors"] += test["errors"]
74276

277+
executed_tests_index[(platform, target, test_name)] = proc_test_data[platform][test_name][target]
278+
executed_run_counts[(platform, target, test_name)] = executed_run_counts.get((platform, target, test_name), 0) + 1
279+
75280
target_list = sorted(target_list)
76281

77-
for platform in proc_test_data:
282+
# Determine expected tests from repo, ci.yml and sdkconfig for validation type
283+
repo = os.environ.get("GITHUB_REPOSITORY", "")
284+
hw_enabled = os.environ.get("HW_TESTS_ENABLED", "false").lower() == "true"
285+
wokwi_enabled = os.environ.get("WOKWI_TESTS_ENABLED", "false").lower() == "true"
286+
qemu_enabled = os.environ.get("QEMU_TESTS_ENABLED", "false").lower() == "true"
287+
288+
hw_targets = _safe_env_list("HW_TARGETS") if hw_enabled else []
289+
wokwi_targets = _safe_env_list("WOKWI_TARGETS") if wokwi_enabled else []
290+
qemu_targets = _safe_env_list("QEMU_TARGETS") if qemu_enabled else []
291+
292+
# Only consider validation tests in this report
293+
expected_by_platform = {"hardware": {}, "wokwi": {}, "qemu": {}}
294+
expected_counts_by_key = {} # {(platform, target, test_name): int}
295+
try:
296+
built_tests = _scan_artifacts_for_tests()
297+
# Prefetch sdkconfigs per target
298+
sdk_by_target = {}
299+
all_needed_targets = sorted(set(hw_targets + wokwi_targets + qemu_targets))
300+
for t in all_needed_targets:
301+
sdk_by_target[t] = _read_local_sdkconfig_for_target(t)
302+
303+
for test_name, test_targets in built_tests.items():
304+
# Load ci.yml if exists
305+
ci_content = _read_local_ci_yaml_if_available(test_name)
306+
ci = _parse_ci_yml_minimal(ci_content)
307+
fqbn_counts = _parse_fqbn_counts(ci_content)
308+
309+
# Evaluate for each platform
310+
for platform, targets in (("hardware", hw_targets), ("wokwi", wokwi_targets), ("qemu", qemu_targets)):
311+
if not targets:
312+
continue
313+
# platforms.<platform> == false -> skip
314+
if platform in ci.get("platforms", {}) and ci["platforms"].get(platform) is False:
315+
continue
316+
# Only consider targets that were actually built (we don't fetch repo)
317+
for target in [t for t in targets if t in test_targets]:
318+
# targets.<target> == false -> skip
319+
if target in ci.get("targets", {}) and ci["targets"].get(target) is False:
320+
continue
321+
# requirements
322+
if not _meets_requirements(ci, sdk_by_target.get(target, "")):
323+
continue
324+
expected_by_platform.setdefault(platform, {}).setdefault(test_name, set()).add(target)
325+
# Determine expected run count by number of FQBN variants (default 1)
326+
expected_runs = fqbn_counts.get(target, 0)
327+
if expected_runs <= 0:
328+
expected_runs = 1
329+
expected_counts_by_key[(platform, target, test_name)] = expected_runs
330+
except Exception as e:
331+
print(f"WARN: Failed to compute expected tests: {e}", file=sys.stderr)
332+
333+
# Ensure target_list also includes expected targets even if nothing executed
334+
for t in hw_targets + wokwi_targets + qemu_targets:
335+
if t not in target_list:
336+
target_list.append(t)
337+
target_list = sorted(set(target_list))
338+
339+
platforms_to_render = set(proc_test_data.keys()) | {p for p, mp in expected_by_platform.items() if any(mp.values())}
340+
for platform in platforms_to_render:
78341
print("")
79342
print(f"#### {platform.capitalize()}")
80343
print("")
@@ -90,19 +353,37 @@
90353
print("")
91354
print("-" + "|:-:" * len(target_list))
92355

93-
for test_name, targets in proc_test_data[platform].items():
356+
# Determine all test names to render for this platform: executed ∪ expected
357+
platform_executed = proc_test_data.get(platform, {})
358+
platform_expected = expected_by_platform.get(platform, {})
359+
all_test_names = sorted(set(platform_executed.keys()) | set(platform_expected.keys()))
360+
361+
for test_name in all_test_names:
94362
print(f"{test_name}", end="")
95363
for target in target_list:
96-
if target in targets:
97-
test_data = targets[target]
98-
if test_data["errors"] > 0:
364+
# Executed?
365+
executed_cell = platform_executed.get(test_name, {}).get(target)
366+
exp_runs = expected_counts_by_key.get((platform, target, test_name), 0)
367+
exec_runs = executed_run_counts.get((platform, target, test_name), 0)
368+
if executed_cell:
369+
# If fewer runs executed than expected, mark as error
370+
if exp_runs > 0 and exec_runs < exp_runs:
371+
print(f"|Error {ERROR_SYMBOL}", end="")
372+
continue
373+
if executed_cell["errors"] > 0:
99374
print(f"|Error {ERROR_SYMBOL}", end="")
100375
else:
101-
print(f"|{test_data['total']-test_data['failures']}/{test_data['total']}", end="")
102-
if test_data["failures"] > 0:
376+
print(f"|{executed_cell['total']-executed_cell['failures']}/{executed_cell['total']}", end="")
377+
if executed_cell["failures"] > 0:
103378
print(f" {FAILURE_SYMBOL}", end="")
104379
else:
105380
print(f" {SUCCESS_SYMBOL}", end="")
381+
continue
382+
383+
# Not executed. Was it expected?
384+
expected_targets = platform_expected.get(test_name, set())
385+
if target in expected_targets:
386+
print(f"|Error {ERROR_SYMBOL}", end="")
106387
else:
107388
print("|-", end="")
108389
print("")

.github/scripts/sketch_utils.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,11 @@ function build_sketch { # build_sketch <ide_path> <user_path> <path-to-ino> [ext
294294
exit "$exit_status"
295295
fi
296296

297+
# Copy ci.yml alongside compiled binaries for later consumption by reporting tools
298+
if [ -f "$sketchdir/ci.yml" ]; then
299+
cp -f "$sketchdir/ci.yml" "$build_dir/ci.yml" 2>/dev/null || true
300+
fi
301+
297302
if [ -n "$log_compilation" ]; then
298303
#Extract the program storage space and dynamic memory usage in bytes and percentage in separate variables from the output, just the value without the string
299304
flash_bytes=$(grep -oE 'Sketch uses ([0-9]+) bytes' "$output_file" | awk '{print $3}')
@@ -337,6 +342,10 @@ function build_sketch { # build_sketch <ide_path> <user_path> <path-to-ino> [ext
337342
echo "ERROR: Compilation failed with error code $exit_status"
338343
exit $exit_status
339344
fi
345+
# Copy ci.yml alongside compiled binaries for later consumption by reporting tools
346+
if [ -f "$sketchdir/ci.yml" ]; then
347+
cp -f "$sketchdir/ci.yml" "$build_dir/ci.yml" 2>/dev/null || true
348+
fi
340349
# $ide_path/arduino-builder -compile -logger=human -core-api-version=10810 \
341350
# -fqbn=\"$currfqbn\" \
342351
# -warnings="all" \

0 commit comments

Comments
 (0)