Skip to content

Commit 8e7a5d3

Browse files
authored
Merge pull request #104 from achxkloel/feat/build_collect_html_output
feat: add html output for build collect command (RDT-1588)
2 parents 4a4cf72 + 4c478a8 commit 8e7a5d3

File tree

7 files changed

+1414
-176
lines changed

7 files changed

+1414
-176
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ repos:
2828
- id: mypy
2929
args: ['--warn-unused-ignores']
3030
additional_dependencies:
31+
- beautifulsoup4
3132
- click
3233
- idf-build-apps~=2.12
3334
- jinja2

docs/en/guides/cli_group_cmds/cli_build.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Options:
9191

9292
- ``--paths PATHS`` - Paths to search for applications. If not provided, current directory is used
9393
- ``--output OUTPUT`` - Output destination. If not provided, stdout is used
94+
- ``--format [json|html]`` - Output format. Default is json
9495
- ``--include-only-enabled`` - Include only enabled applications
9596

9697
Output format:
@@ -159,5 +160,8 @@ Examples:
159160
# Collect applications and output to a file
160161
idf-ci build collect --output /path/to/output.json
161162
163+
# Collect applications and output in HTML format
164+
idf-ci build collect --format html --output /path/to/output.html
165+
162166
# Collect only enabled applications
163167
idf-ci build collect --include-only-enabled

idf_ci/build_collect/scripts.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import json
5+
import logging
6+
import typing as t
7+
from pathlib import Path
8+
9+
from idf_build_apps import find_apps
10+
from idf_build_apps.app import App
11+
from idf_build_apps.args import FindArguments
12+
from idf_build_apps.constants import ALL_TARGETS, BuildStatus
13+
from jinja2 import Environment, FileSystemLoader
14+
15+
from idf_ci import get_pytest_cases
16+
from idf_ci.idf_pytest import PytestCase
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def collect_apps(paths: t.List[str], include_only_enabled_apps: bool) -> t.Dict[str, t.Any]:
22+
"""Collect all applications and corresponding test cases."""
23+
apps = find_apps(
24+
find_arguments=FindArguments(
25+
paths=paths,
26+
include_all_apps=not include_only_enabled_apps,
27+
recursive=True,
28+
enable_preview_targets=True,
29+
),
30+
)
31+
32+
test_cases = get_pytest_cases(
33+
paths=paths,
34+
marker_expr='', # pass empty marker to collect all test cases
35+
additional_args=['--ignore-no-tests-collected-error'],
36+
)
37+
38+
# Gather apps by path
39+
apps_by_path: t.Dict[str, t.List[App]] = {}
40+
apps_by_abs_path: t.Dict[str, t.List[App]] = {}
41+
for app in apps:
42+
apps_by_path.setdefault(app.app_dir, []).append(app)
43+
apps_by_abs_path.setdefault(Path(app.app_dir).absolute().as_posix(), []).append(app)
44+
45+
# Create a dict with test cases for quick lookup
46+
# Structure: path -> target -> sdkconfig -> list[PytestCase]
47+
test_cases_index: t.Dict[str, t.Dict[str, t.Dict[str, t.List[PytestCase]]]] = {}
48+
for case in test_cases:
49+
case_path = Path(case.path).parent.as_posix()
50+
51+
# Handle multiple targets
52+
targets = case.targets
53+
for target in targets:
54+
test_cases_index.setdefault(case_path, {}).setdefault(target, {})
55+
56+
for pytest_app in case.apps:
57+
test_cases_index[case_path][target].setdefault(pytest_app.config, [])
58+
test_cases_index[case_path][target][pytest_app.config].append(case)
59+
60+
result: t.Dict[str, t.Any] = {
61+
'summary': {
62+
'total_projects': len(apps_by_path),
63+
'total_apps': len(apps),
64+
'total_test_cases': len(test_cases),
65+
'total_test_cases_used': 0,
66+
'total_test_cases_disabled': 0,
67+
'total_test_cases_requiring_nonexistent_app': 0,
68+
},
69+
'projects': {},
70+
}
71+
72+
for index, app_path in enumerate(sorted(apps_by_path)):
73+
logger.debug(
74+
f'Processing path {index + 1}/{len(apps_by_path)} with {len(apps_by_path[app_path])} apps: {app_path}'
75+
)
76+
77+
result['projects'][app_path] = {'apps': [], 'test_cases_requiring_nonexistent_app': []}
78+
project: t.Dict[str, t.Any] = result['projects'][app_path]
79+
project_test_cases: t.Set[str] = set()
80+
used_test_cases: t.Set[str] = set()
81+
disabled_test_cases: t.Set[str] = set()
82+
app_abs_path = Path(app_path).absolute().as_posix()
83+
84+
for app in apps_by_path[app_path]:
85+
# Find test cases for current app by path, target and sdkconfig
86+
app_test_cases: t.Dict[str, PytestCase] = {}
87+
88+
if app_abs_path in test_cases_index:
89+
# Gather all test cases
90+
for target_key in test_cases_index[app_abs_path]:
91+
for sdkconfig_key in test_cases_index[app_abs_path][target_key]:
92+
test_cases = test_cases_index[app_abs_path][target_key][sdkconfig_key]
93+
project_test_cases.update([case.caseid for case in test_cases])
94+
95+
# Find matching test cases
96+
if app.target in test_cases_index[app_abs_path]:
97+
if app.config_name in test_cases_index[app_abs_path][app.target]:
98+
test_cases = test_cases_index[app_abs_path][app.target][app.config_name]
99+
100+
for case in test_cases:
101+
app_test_cases[case.caseid] = case
102+
103+
# Get enabled test targets from manifest if exists
104+
enabled_test_targets = ALL_TARGETS
105+
if app.MANIFEST is not None:
106+
enabled_test_targets = app.MANIFEST.enable_test_targets(app_path, config_name=app.config_name)
107+
108+
# Test cases info
109+
test_cases_info: t.List[t.Dict[str, t.Any]] = []
110+
for case in app_test_cases.values():
111+
test_case: t.Dict[str, t.Any] = {
112+
'name': case.name,
113+
'caseid': case.caseid,
114+
}
115+
116+
skipped_targets = case.skipped_targets()
117+
is_disabled_by_manifest = app.target not in enabled_test_targets
118+
is_disabled_by_marker = app.target in skipped_targets
119+
120+
if is_disabled_by_manifest or is_disabled_by_marker:
121+
test_case['disabled'] = True
122+
test_case['disabled_by_manifest'] = is_disabled_by_manifest
123+
test_case['disabled_by_marker'] = is_disabled_by_marker
124+
125+
if is_disabled_by_marker:
126+
test_case['skip_reason'] = skipped_targets[app.target]
127+
128+
if is_disabled_by_manifest:
129+
test_case['test_comment'] = app.test_comment or ''
130+
131+
disabled_test_cases.add(case.caseid)
132+
else:
133+
used_test_cases.add(case.caseid)
134+
135+
test_cases_info.append(test_case)
136+
137+
project['apps'].append(
138+
{
139+
'target': app.target,
140+
'sdkconfig': app.config_name,
141+
'build_status': app.build_status.value,
142+
'build_comment': app.build_comment or '',
143+
'test_comment': app.test_comment or '',
144+
'test_cases': test_cases_info,
145+
}
146+
)
147+
148+
unused_test_cases: t.Set[str] = project_test_cases.copy()
149+
unused_test_cases.difference_update(used_test_cases)
150+
unused_test_cases.difference_update(disabled_test_cases)
151+
152+
for unused_test_case in unused_test_cases:
153+
project['test_cases_requiring_nonexistent_app'].append(unused_test_case)
154+
155+
result['summary']['total_test_cases_used'] += len(used_test_cases)
156+
result['summary']['total_test_cases_disabled'] += len(disabled_test_cases)
157+
result['summary']['total_test_cases_requiring_nonexistent_app'] += len(unused_test_cases)
158+
159+
return result
160+
161+
162+
def format_as_json(data: t.Dict[str, t.Any]) -> str:
163+
"""Format collected data as JSON."""
164+
return json.dumps(data)
165+
166+
167+
def format_as_html(data: t.Dict[str, t.Any]) -> str:
168+
"""Format collected data as HTML."""
169+
rows = []
170+
projects = data.get('projects', {})
171+
all_target_list = set()
172+
173+
for project_path, project_info in projects.items():
174+
apps = project_info.get('apps', [])
175+
176+
total_tests = 0
177+
total_enabled_tests = 0
178+
target_list = set()
179+
config_list = set()
180+
config_target_app: t.Dict[str, t.Dict[str, t.Any]] = {}
181+
details = []
182+
183+
for app in apps:
184+
# Collect sdkconfigs
185+
config_list.add(app.get('sdkconfig'))
186+
187+
# Collect targets
188+
target_list.add(app.get('target'))
189+
190+
# config -> target -> app
191+
config_target_app.setdefault(app.get('sdkconfig'), {})[app.get('target')] = app
192+
193+
all_target_list.update(target_list)
194+
config_list_sorted = sorted(config_list)
195+
target_list_sorted = sorted(target_list)
196+
197+
for config in config_list_sorted:
198+
detail_item = {'sdkconfig': config, 'coverage': 0, 'targets': []}
199+
200+
targets_tested = 0
201+
targets_total = 0
202+
203+
for target in target_list_sorted:
204+
# Status:
205+
# U - Unknown
206+
# B - Should be built
207+
# D - Disabled
208+
# T - Tests enabled
209+
# S - Tests skipped
210+
target_info = {
211+
'name': target,
212+
'status': 'U',
213+
'status_label': '', # B - Should be built, D - Disabled
214+
'has_err': False,
215+
'is_disabled': False,
216+
'disable_reason': '',
217+
'tests': 0,
218+
'enabled_tests': 0,
219+
}
220+
221+
app = config_target_app.get(config, {}).get(target)
222+
223+
if app:
224+
targets_total += 1
225+
226+
status = app.get('build_status')
227+
test_cases = app.get('test_cases', [])
228+
disabled_test_cases = [case for case in test_cases if case.get('disabled')]
229+
230+
target_info['tests'] = len(test_cases)
231+
target_info['enabled_tests'] = target_info['tests'] - len(disabled_test_cases)
232+
233+
total_tests += target_info['tests']
234+
total_enabled_tests += target_info['enabled_tests']
235+
236+
if status == BuildStatus.DISABLED:
237+
target_info['status'] = target_info['status_label'] = 'D'
238+
target_info['is_disabled'] = True
239+
target_info['disable_reason'] = app.get('build_comment', '')
240+
elif status == BuildStatus.SHOULD_BE_BUILT:
241+
target_info['status'] = target_info['status_label'] = 'B'
242+
243+
if target_info['enabled_tests'] > 0:
244+
target_info['status'] += 'T'
245+
targets_tested += 1
246+
247+
if len(disabled_test_cases) > 0:
248+
target_info['status'] += 'S'
249+
250+
# Mismatched test cases
251+
disabled_by_manifest_only = [
252+
case
253+
for case in test_cases
254+
if case.get('disabled_by_manifest') and not case.get('disabled_by_marker')
255+
]
256+
disabled_by_marker_only = [
257+
case
258+
for case in test_cases
259+
if case.get('disabled_by_marker') and not case.get('disabled_by_manifest')
260+
]
261+
262+
# Set error if there are any mismatches
263+
if disabled_by_manifest_only or disabled_by_marker_only:
264+
target_info['has_err'] = True
265+
target_info['disabled_by_manifest_only'] = disabled_by_manifest_only
266+
target_info['disabled_by_marker_only'] = disabled_by_marker_only
267+
268+
detail_item['targets'].append(target_info)
269+
270+
if targets_total > 0:
271+
detail_item['coverage'] = (targets_tested / targets_total) * 100
272+
273+
details.append(detail_item)
274+
275+
rows.append(
276+
{
277+
'project_path': project_path,
278+
'apps': len(apps),
279+
'tests': total_tests,
280+
'enabled_tests': total_enabled_tests,
281+
'tests_unknown_sdkconfig': project_info.get('test_cases_requiring_nonexistent_app', []),
282+
'target_list': target_list_sorted,
283+
'details': details,
284+
}
285+
)
286+
287+
rows = sorted(rows, key=lambda x: x['project_path'])
288+
loader = FileSystemLoader(Path(__file__).parent)
289+
env = Environment(loader=loader)
290+
template = env.get_template('template.html')
291+
output = template.render(
292+
{
293+
'targets': sorted(all_target_list),
294+
'rows': rows,
295+
}
296+
)
297+
298+
return output

0 commit comments

Comments
 (0)