hector-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hector/__init__.py +4 -0
- hector/__main__.py +7 -0
- hector/commands/__init__.py +35 -0
- hector/commands/base.py +111 -0
- hector/commands/export.py +26 -0
- hector/commands/init.py +15 -0
- hector/commands/run.py +27 -0
- hector/commands/test.py +52 -0
- hector/commands/validate.py +15 -0
- hector/connections.py +130 -0
- hector/core.py +259 -0
- hector/dependencies.py +41 -0
- hector/docker.py +191 -0
- hector/generator.py +292 -0
- hector/hubs.py +196 -0
- hector/mappings.py +167 -0
- hector/modules.py +201 -0
- hector/peripherals.py +168 -0
- hector/pipeline.py +693 -0
- hector/reporters.py +111 -0
- hector/runners.py +380 -0
- hector/scaffold.py +75 -0
- hector/validator.py +414 -0
- hector_cli-0.1.0.dist-info/METADATA +1401 -0
- hector_cli-0.1.0.dist-info/RECORD +29 -0
- hector_cli-0.1.0.dist-info/WHEEL +5 -0
- hector_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hector_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
- hector_cli-0.1.0.dist-info/top_level.txt +1 -0
hector/reporters.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Pluggable test-result reporters.
|
|
4
|
+
|
|
5
|
+
Register a custom reporter:
|
|
6
|
+
from hector.reporters import REPORTERS
|
|
7
|
+
|
|
8
|
+
@REPORTERS.register("csv")
|
|
9
|
+
def csv_reporter(results, output_dir):
|
|
10
|
+
import csv, os
|
|
11
|
+
with open(os.path.join(output_dir, "results.csv"), "w") as f:
|
|
12
|
+
w = csv.DictWriter(f, fieldnames=["name","type","passed","duration"])
|
|
13
|
+
w.writeheader()
|
|
14
|
+
for r in results:
|
|
15
|
+
w.writerow({"name": r.name, "type": r.type,
|
|
16
|
+
"passed": r.passed, "duration": r.duration})
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import xml.etree.ElementTree as ET
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from .core import Registry
|
|
25
|
+
|
|
26
|
+
REPORTERS = Registry("reporter")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class StepResult:
|
|
31
|
+
"""Outcome of a single test step, passed to every reporter after the run."""
|
|
32
|
+
name: str
|
|
33
|
+
type: str # "robot", "shell", "build", …
|
|
34
|
+
passed: bool
|
|
35
|
+
duration: float = 0.0
|
|
36
|
+
commands: list = field(default_factory=list) # script lines / commands run
|
|
37
|
+
stdout: str = "" # captured stdout (bash) or trimmed log
|
|
38
|
+
stderr: str = "" # captured stderr
|
|
39
|
+
artifacts: dict = field(default_factory=dict) # {label: abs_path}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emit(results, output_dir, reporter_names):
|
|
43
|
+
"""Call each named reporter with the full result list."""
|
|
44
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
45
|
+
for name in reporter_names:
|
|
46
|
+
fn = REPORTERS.get(name)
|
|
47
|
+
fn(results, output_dir)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@REPORTERS.register("junit")
|
|
51
|
+
def junit_reporter(results, output_dir):
|
|
52
|
+
"""JUnit XML — compatible with GitLab CI, GitHub Actions, and Jenkins."""
|
|
53
|
+
failures = sum(1 for r in results if not r.passed)
|
|
54
|
+
suite = ET.Element(
|
|
55
|
+
"testsuite",
|
|
56
|
+
name="hector",
|
|
57
|
+
tests=str(len(results)),
|
|
58
|
+
failures=str(failures),
|
|
59
|
+
time=f"{sum(r.duration for r in results):.3f}",
|
|
60
|
+
)
|
|
61
|
+
for r in results:
|
|
62
|
+
tc = ET.SubElement(
|
|
63
|
+
suite, "testcase",
|
|
64
|
+
name=r.name, classname=r.type,
|
|
65
|
+
time=f"{r.duration:.3f}",
|
|
66
|
+
)
|
|
67
|
+
out = "\n".join(filter(None, [r.stdout.strip(), r.stderr.strip()]))
|
|
68
|
+
parts = []
|
|
69
|
+
if r.commands:
|
|
70
|
+
parts.append("Commands:\n" + "\n".join(f" {c}" for c in r.commands))
|
|
71
|
+
if out:
|
|
72
|
+
parts.append("Output:\n" + out)
|
|
73
|
+
if parts:
|
|
74
|
+
ET.SubElement(tc, "system-out").text = "\n\n".join(parts)
|
|
75
|
+
if not r.passed:
|
|
76
|
+
ET.SubElement(tc, "failure", message="Test failed").text = (
|
|
77
|
+
r.stderr or r.stdout or ""
|
|
78
|
+
).strip()
|
|
79
|
+
|
|
80
|
+
tree = ET.ElementTree(suite)
|
|
81
|
+
ET.indent(tree, space=" ")
|
|
82
|
+
path = os.path.join(output_dir, "junit.xml")
|
|
83
|
+
tree.write(path, encoding="unicode", xml_declaration=True)
|
|
84
|
+
print(f"[REPORT] junit → {path}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@REPORTERS.register("json")
|
|
88
|
+
def json_reporter(results, output_dir):
|
|
89
|
+
"""Machine-readable manifest for a results dashboard / CI. One entry per test,
|
|
90
|
+
each carrying its status, timing, commands, and links to its detail artifacts
|
|
91
|
+
(report.html / log.html / robot_output.xml). Paths are relative to the cwd."""
|
|
92
|
+
entries = []
|
|
93
|
+
for r in results:
|
|
94
|
+
entries.append({
|
|
95
|
+
"name": r.name,
|
|
96
|
+
"type": r.type,
|
|
97
|
+
"status": "passed" if r.passed else "failed",
|
|
98
|
+
"duration": round(r.duration, 3),
|
|
99
|
+
"commands": r.commands,
|
|
100
|
+
"artifacts": {k: os.path.relpath(v) for k, v in r.artifacts.items()},
|
|
101
|
+
})
|
|
102
|
+
manifest = {
|
|
103
|
+
"tests": len(results),
|
|
104
|
+
"failures": sum(1 for r in results if not r.passed),
|
|
105
|
+
"passed": all(r.passed for r in results),
|
|
106
|
+
"results": entries,
|
|
107
|
+
}
|
|
108
|
+
path = os.path.join(output_dir, "manifest.json")
|
|
109
|
+
with open(path, "w") as f:
|
|
110
|
+
json.dump(manifest, f, indent=2)
|
|
111
|
+
print(f"[REPORT] json → {path}")
|
hector/runners.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
TEST_RUNNERS — per-type test execution, dispatched by run_tests().
|
|
4
|
+
|
|
5
|
+
Each runner has the signature:
|
|
6
|
+
(test: dict, steps: list, ctx: RunnerContext, index: int) -> list[StepResult]
|
|
7
|
+
|
|
8
|
+
Register a custom type:
|
|
9
|
+
@TEST_RUNNERS.register("pytest")
|
|
10
|
+
def run_pytest(test, steps, ctx, index):
|
|
11
|
+
...
|
|
12
|
+
return [StepResult(name=..., type="pytest", passed=..., stdout=...)]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import html as _html
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shlex
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from . import core as _core
|
|
23
|
+
from .core import ARTIFACTS_DIR, Registry, to_container_path
|
|
24
|
+
from .docker import resolve_shell_image, run_in_container, run_renode_test
|
|
25
|
+
from .reporters import StepResult
|
|
26
|
+
|
|
27
|
+
TEST_RUNNERS = Registry("test runner")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunnerContext:
|
|
32
|
+
resc_host_path: str # generated emulation resc
|
|
33
|
+
job_index: int # 1-based
|
|
34
|
+
image: str # antmicro/renode:<version>
|
|
35
|
+
reporters: list = field(default_factory=lambda: ["junit"])
|
|
36
|
+
results_dir: str = "results"
|
|
37
|
+
renode_test_args: list = field(default_factory=list)
|
|
38
|
+
live: bool = False
|
|
39
|
+
snapshot: str = ""
|
|
40
|
+
runtime: object = None # RuntimeOptions: docker caps/devices/root for the run
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _parse_extra_args(obj):
|
|
46
|
+
raw = obj.get("args")
|
|
47
|
+
if not raw:
|
|
48
|
+
return []
|
|
49
|
+
return raw if isinstance(raw, list) else shlex.split(str(raw))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_steps(test):
|
|
53
|
+
"""Backward compat: test with top-level script:/file: treated as a single step."""
|
|
54
|
+
if "steps" in test:
|
|
55
|
+
return test["steps"]
|
|
56
|
+
return [{"name": test.get("name", ""), "script": test.get("script", ""), "file": test.get("file")}]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _test_results_dir(base, test_index, name):
|
|
60
|
+
slug = re.sub(r"[^\w-]", "_", name)[:40]
|
|
61
|
+
return os.path.join(base, f"test_{test_index}_{slug}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _render_output_pre(output):
|
|
65
|
+
"""Escape captured output; highlight `set -x` command-trace lines (starting '+')."""
|
|
66
|
+
out_lines = []
|
|
67
|
+
for line in output.splitlines():
|
|
68
|
+
esc = _html.escape(line)
|
|
69
|
+
out_lines.append(f'<span class="cmd-trace">{esc}</span>'
|
|
70
|
+
if line.startswith("+") else esc)
|
|
71
|
+
return "<br>".join(out_lines) or "(no output)"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_shell_html(test_dir, test_name, step_records, passed, duration):
|
|
75
|
+
"""Write results/test_N_<name>/report.html for a whole shell test.
|
|
76
|
+
|
|
77
|
+
Mirrors robot's per-test layout (one report at the test directory level), with
|
|
78
|
+
one section per step. `step_records` is a list of
|
|
79
|
+
{name, passed, duration, output} dicts.
|
|
80
|
+
"""
|
|
81
|
+
os.makedirs(test_dir, exist_ok=True)
|
|
82
|
+
status_cls = "pass" if passed else "fail"
|
|
83
|
+
status_text = "PASS" if passed else "FAIL"
|
|
84
|
+
|
|
85
|
+
sections = []
|
|
86
|
+
for rec in step_records:
|
|
87
|
+
s_cls = "pass" if rec["passed"] else "fail"
|
|
88
|
+
s_txt = "PASS" if rec["passed"] else "FAIL"
|
|
89
|
+
sections.append(
|
|
90
|
+
f'<h2>{_html.escape(rec["name"])} '
|
|
91
|
+
f'<span class="badge {s_cls}">{s_txt}</span> '
|
|
92
|
+
f'<span class="dur">{rec["duration"]:.2f}s</span></h2>\n'
|
|
93
|
+
f'<pre>{_render_output_pre(rec["output"])}</pre>'
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
content = (
|
|
97
|
+
'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
|
98
|
+
'<meta charset="utf-8">\n'
|
|
99
|
+
f'<title>{_html.escape(test_name)}</title>\n'
|
|
100
|
+
'<style>\n'
|
|
101
|
+
'*{box-sizing:border-box;margin:0;padding:0}\n'
|
|
102
|
+
'body{font-family:"Consolas","Courier New",monospace;background:#1e1e1e;'
|
|
103
|
+
'color:#d4d4d4;padding:24px;font-size:13px;line-height:1.5}\n'
|
|
104
|
+
'h1{color:#9cdcfe;font-size:18px;margin-bottom:6px}\n'
|
|
105
|
+
'h2{color:#c586c0;font-size:14px;margin:20px 0 6px}\n'
|
|
106
|
+
'.meta{color:#808080;margin-bottom:24px;font-size:12px}\n'
|
|
107
|
+
'.dur{color:#808080;font-weight:400;font-size:12px}\n'
|
|
108
|
+
'.badge{font-weight:700;padding:2px 8px;border-radius:3px}\n'
|
|
109
|
+
'.pass{color:#4ec9b0}.fail{color:#f44747}\n'
|
|
110
|
+
'pre{background:#252526;border-left:3px solid #555;padding:12px;'
|
|
111
|
+
'overflow-x:auto;white-space:pre-wrap;word-break:break-all}\n'
|
|
112
|
+
'.cmd-trace{color:#dcdcaa}\n'
|
|
113
|
+
'</style>\n</head>\n<body>\n'
|
|
114
|
+
f'<h1>{_html.escape(test_name)}</h1>\n'
|
|
115
|
+
'<div class="meta">'
|
|
116
|
+
f'Status: <span class="badge {status_cls}">{status_text}</span>'
|
|
117
|
+
f' Duration: {duration:.2f}s'
|
|
118
|
+
'</div>\n'
|
|
119
|
+
+ "\n".join(sections) +
|
|
120
|
+
'\n</body>\n</html>'
|
|
121
|
+
)
|
|
122
|
+
path = os.path.join(test_dir, "report.html")
|
|
123
|
+
with open(path, "w") as f:
|
|
124
|
+
f.write(content)
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def generate_robot_file(test_name, steps, resc_host_path, job_index, test_index, snapshot=""):
|
|
129
|
+
"""Generate a .robot suite: one keyword per step, one test case that calls them in order.
|
|
130
|
+
|
|
131
|
+
One test case means Renode's per-test lifecycle fires exactly once — no inter-step teardowns.
|
|
132
|
+
run_tests.py injects Suite Setup Setup (since we define none), which establishes the
|
|
133
|
+
Remote library connection. The test case boots the emulation, then calls each keyword.
|
|
134
|
+
"""
|
|
135
|
+
test_dir = os.path.join(ARTIFACTS_DIR, "tests")
|
|
136
|
+
os.makedirs(test_dir, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
keywords = []
|
|
139
|
+
for step_idx, step in enumerate(steps):
|
|
140
|
+
kw_name = step.get("name") or f"Step {step_idx + 1}"
|
|
141
|
+
body = [f" {l.strip()}" for l in (step.get("script") or "").splitlines() if l.strip()]
|
|
142
|
+
if not body:
|
|
143
|
+
body = [" No Operation"]
|
|
144
|
+
keywords.append(kw_name + "\n" + "\n".join(body))
|
|
145
|
+
|
|
146
|
+
if snapshot:
|
|
147
|
+
boot = [f" Execute Command Load @{to_container_path(snapshot)}",
|
|
148
|
+
" Execute Command start"]
|
|
149
|
+
else:
|
|
150
|
+
boot = [f" Execute Command include @{to_container_path(resc_host_path)}"]
|
|
151
|
+
|
|
152
|
+
kw_calls = [f" {step.get('name') or f'Step {i + 1}'}" for i, step in enumerate(steps)]
|
|
153
|
+
test_case = test_name + "\n" + "\n".join(boot + kw_calls)
|
|
154
|
+
|
|
155
|
+
content = (
|
|
156
|
+
"*** Keywords ***\n"
|
|
157
|
+
+ "\n\n".join(keywords) + "\n\n"
|
|
158
|
+
+ "*** Test Cases ***\n"
|
|
159
|
+
+ test_case + "\n"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
path = os.path.join(test_dir, f"job_{job_index}_test_{test_index}.robot")
|
|
163
|
+
with open(path, "w") as f:
|
|
164
|
+
f.write(content)
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── test runners ──────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def _run_robot_test(test, steps, ctx, test_index):
|
|
171
|
+
"""Run a robot test. Returns a single StepResult for the whole test."""
|
|
172
|
+
test_name = test.get("name", f"test_{test_index}")
|
|
173
|
+
effective_snapshot = ctx.snapshot or test.get("snapshot", "")
|
|
174
|
+
if effective_snapshot and not os.path.isfile(effective_snapshot):
|
|
175
|
+
raise FileNotFoundError(f"Snapshot not found: {effective_snapshot}")
|
|
176
|
+
|
|
177
|
+
extra_args = _parse_extra_args(test) + ctx.renode_test_args
|
|
178
|
+
if ctx.live:
|
|
179
|
+
extra_args += ["--verbose"]
|
|
180
|
+
interactive = test.get("interactive", False)
|
|
181
|
+
|
|
182
|
+
print(f"\n[TEST] {test_name}")
|
|
183
|
+
if effective_snapshot:
|
|
184
|
+
print(f" [SNAP] {effective_snapshot}")
|
|
185
|
+
for step in steps:
|
|
186
|
+
print(f" {step.get('name', '?')}")
|
|
187
|
+
|
|
188
|
+
test_dir = _test_results_dir(ctx.results_dir, test_index, test_name)
|
|
189
|
+
|
|
190
|
+
if len(steps) == 1 and steps[0].get("file"):
|
|
191
|
+
if effective_snapshot:
|
|
192
|
+
print(" [WARN] snapshot ignored for file-based robot tests.")
|
|
193
|
+
robot_file = steps[0]["file"]
|
|
194
|
+
else:
|
|
195
|
+
if any(s.get("file") for s in steps):
|
|
196
|
+
raise ValueError(f"Test '{test_name}': mixing file: and script: steps is not supported.")
|
|
197
|
+
robot_file = generate_robot_file(
|
|
198
|
+
test_name, steps, ctx.resc_host_path, ctx.job_index, test_index,
|
|
199
|
+
snapshot=effective_snapshot,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
t0 = time.monotonic()
|
|
203
|
+
passed = run_renode_test(robot_file, ctx.image, test_dir,
|
|
204
|
+
resc_host_path=ctx.resc_host_path,
|
|
205
|
+
extra_args=extra_args, interactive=interactive,
|
|
206
|
+
runtime=ctx.runtime)
|
|
207
|
+
duration = time.monotonic() - t0
|
|
208
|
+
# Register renode-test's own per-test reports so the manifest can deep-link them.
|
|
209
|
+
# 'report_html' is the same key bash uses, so a dashboard finds it uniformly.
|
|
210
|
+
artifacts = {}
|
|
211
|
+
for label, fname in (("report_html", "report.html"),
|
|
212
|
+
("log_html", "log.html"),
|
|
213
|
+
("robot_xml", "robot_output.xml")):
|
|
214
|
+
fpath = os.path.join(test_dir, fname)
|
|
215
|
+
if os.path.exists(fpath):
|
|
216
|
+
artifacts[label] = fpath
|
|
217
|
+
return [StepResult(
|
|
218
|
+
name=test_name, type="robot", passed=passed, duration=duration,
|
|
219
|
+
artifacts=artifacts,
|
|
220
|
+
)]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _run_shell_test(test, steps, ctx, test_index):
|
|
224
|
+
"""Run all steps of a shell test, each in a one-off container of the resolved image
|
|
225
|
+
(or on the host under --no-docker). One StepResult per test, one report.html —
|
|
226
|
+
mirrors the robot layout (a multi-step test is a single row named after the test).
|
|
227
|
+
"""
|
|
228
|
+
test_name = test.get("name", f"test_{test_index}")
|
|
229
|
+
image = resolve_shell_image(test, ctx.image, label=test_name)
|
|
230
|
+
test_dir = _test_results_dir(ctx.results_dir, test_index, test_name)
|
|
231
|
+
where = "host" if _core.NO_DOCKER else f"image: {image}"
|
|
232
|
+
print(f"\n[TEST] {test_name} (shell, {where})")
|
|
233
|
+
|
|
234
|
+
step_records = []
|
|
235
|
+
all_commands = []
|
|
236
|
+
combined_stdout = []
|
|
237
|
+
total_duration = 0.0
|
|
238
|
+
all_passed = True
|
|
239
|
+
|
|
240
|
+
for step_idx, step in enumerate(steps):
|
|
241
|
+
step_name = step.get("name", f"step_{step_idx}")
|
|
242
|
+
script = step.get("script", "")
|
|
243
|
+
print(f" {step_name}")
|
|
244
|
+
|
|
245
|
+
t0 = time.monotonic()
|
|
246
|
+
stdout, passed = run_in_container(script, image, ctx.live)
|
|
247
|
+
duration = time.monotonic() - t0
|
|
248
|
+
|
|
249
|
+
total_duration += duration
|
|
250
|
+
all_passed = all_passed and passed
|
|
251
|
+
all_commands.extend(l for l in script.splitlines() if l.strip())
|
|
252
|
+
combined_stdout.append(f"# {step_name}\n{stdout}".rstrip())
|
|
253
|
+
step_records.append({"name": step_name, "passed": passed,
|
|
254
|
+
"duration": duration, "output": stdout})
|
|
255
|
+
|
|
256
|
+
html_path = _write_shell_html(test_dir, test_name, step_records,
|
|
257
|
+
all_passed, total_duration)
|
|
258
|
+
return [StepResult(
|
|
259
|
+
name=test_name, type="shell", passed=all_passed, duration=total_duration,
|
|
260
|
+
commands=all_commands, stdout="\n\n".join(combined_stdout), stderr="",
|
|
261
|
+
artifacts={"report_html": html_path},
|
|
262
|
+
)]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── registry entries for built-in types ──────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
@TEST_RUNNERS.register("robot")
|
|
268
|
+
def robot_runner(test, steps, ctx, index):
|
|
269
|
+
return _run_robot_test(test, steps, ctx, index)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@TEST_RUNNERS.register("shell")
|
|
273
|
+
def shell_runner(test, steps, ctx, index):
|
|
274
|
+
return _run_shell_test(test, steps, ctx, index)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# Test types renamed in this schema version → current name. Old configs still run;
|
|
278
|
+
# the validator emits a matching migration warning.
|
|
279
|
+
_RENAMED_TEST_TYPES = {"bash": "shell", "docker": "shell"}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ── pre-sim build steps ───────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
def run_build_steps(blocks, default_image, results_dir, live=False):
|
|
285
|
+
"""Run the pre-sim `build:` steps (compile firmware, generate/fetch files, ...).
|
|
286
|
+
|
|
287
|
+
Each block is a shell step run in a container of its image (or on the host under
|
|
288
|
+
--no-docker), with the project bind-mounted — so anything it writes into the project
|
|
289
|
+
is there for the sim and tests. Returns one StepResult per block (type "build") so
|
|
290
|
+
builds are reported like tests; stops at the first failing block, because the
|
|
291
|
+
simulation depends on these outputs (the caller skips the sim when one fails).
|
|
292
|
+
"""
|
|
293
|
+
if not blocks:
|
|
294
|
+
return []
|
|
295
|
+
print("\n[BUILD] preparing inputs ...")
|
|
296
|
+
results = []
|
|
297
|
+
for b_idx, block in enumerate(blocks):
|
|
298
|
+
block = block or {}
|
|
299
|
+
name = block.get("name", f"build_{b_idx}")
|
|
300
|
+
image = resolve_shell_image(block, default_image, label=name)
|
|
301
|
+
where = "host" if _core.NO_DOCKER else f"image: {image}"
|
|
302
|
+
print(f" {name} ({where})")
|
|
303
|
+
|
|
304
|
+
step_records = []
|
|
305
|
+
all_commands = []
|
|
306
|
+
combined_stdout = []
|
|
307
|
+
total_duration = 0.0
|
|
308
|
+
all_passed = True
|
|
309
|
+
|
|
310
|
+
for step_idx, step in enumerate(_normalize_steps(block)):
|
|
311
|
+
script = step.get("script", "")
|
|
312
|
+
if not script.strip():
|
|
313
|
+
continue
|
|
314
|
+
step_name = step.get("name", f"step_{step_idx}")
|
|
315
|
+
t0 = time.monotonic()
|
|
316
|
+
stdout, passed = run_in_container(script, image, live)
|
|
317
|
+
duration = time.monotonic() - t0
|
|
318
|
+
total_duration += duration
|
|
319
|
+
all_passed = all_passed and passed
|
|
320
|
+
all_commands.extend(l for l in script.splitlines() if l.strip())
|
|
321
|
+
combined_stdout.append(f"# {step_name}\n{stdout}".rstrip())
|
|
322
|
+
step_records.append({"name": step_name, "passed": passed,
|
|
323
|
+
"duration": duration, "output": stdout})
|
|
324
|
+
|
|
325
|
+
slug = re.sub(r"[^\w-]", "_", name)[:40]
|
|
326
|
+
build_dir = os.path.join(results_dir, f"build_{b_idx}_{slug}")
|
|
327
|
+
html_path = _write_shell_html(build_dir, name, step_records,
|
|
328
|
+
all_passed, total_duration)
|
|
329
|
+
results.append(StepResult(
|
|
330
|
+
name=name, type="build", passed=all_passed, duration=total_duration,
|
|
331
|
+
commands=all_commands, stdout="\n\n".join(combined_stdout), stderr="",
|
|
332
|
+
artifacts={"report_html": html_path}))
|
|
333
|
+
if not all_passed:
|
|
334
|
+
print(f"[BUILD] '{name}' failed — aborting build.")
|
|
335
|
+
break
|
|
336
|
+
return results
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ── driver ────────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
def _needs_sim(test):
|
|
342
|
+
"""Whether a test requires the Renode simulation (the generated resc). Robot tests
|
|
343
|
+
always do; others honour `requires_sim` (default True)."""
|
|
344
|
+
rtype = _RENAMED_TEST_TYPES.get(test.get("type", "robot"), test.get("type", "robot"))
|
|
345
|
+
if rtype == "robot":
|
|
346
|
+
return True
|
|
347
|
+
return bool(test.get("requires_sim", True))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def run_tests(tests, ctx, fail_fast=False):
|
|
351
|
+
"""Execute tests in order, returning their StepResults (the caller emits the reports,
|
|
352
|
+
combined with the build steps'). Sim-backed tests get their own Renode session; a test
|
|
353
|
+
that needs the sim but has no generated resc (no machines) fails with a clear note.
|
|
354
|
+
With fail_fast, stop after the first failing test."""
|
|
355
|
+
results = []
|
|
356
|
+
for i, test in enumerate(tests):
|
|
357
|
+
rtype = test.get("type", "robot")
|
|
358
|
+
if rtype in _RENAMED_TEST_TYPES:
|
|
359
|
+
new = _RENAMED_TEST_TYPES[rtype]
|
|
360
|
+
print(f"[WARN] test type '{rtype}' was renamed to '{new}' — update 'type:'.")
|
|
361
|
+
rtype = new
|
|
362
|
+
test = {**test, "type": new}
|
|
363
|
+
|
|
364
|
+
if _needs_sim(test) and not ctx.resc_host_path:
|
|
365
|
+
name = test.get("name", f"test_{i}")
|
|
366
|
+
print(f"\n[TEST] {name}\n [SKIP] needs a simulation but no machine is "
|
|
367
|
+
"defined (set 'requires_sim: false' for a sim-independent test).")
|
|
368
|
+
results.append(StepResult(
|
|
369
|
+
name=name, type=rtype, passed=False, duration=0.0,
|
|
370
|
+
stderr="requires a simulation but no machine/resc is available"))
|
|
371
|
+
else:
|
|
372
|
+
runner = TEST_RUNNERS.get(rtype)
|
|
373
|
+
results.extend(runner(test, _normalize_steps(test), ctx, i))
|
|
374
|
+
|
|
375
|
+
if fail_fast and not results[-1].passed:
|
|
376
|
+
remaining = len(tests) - (i + 1)
|
|
377
|
+
if remaining:
|
|
378
|
+
print(f"\n[FAIL-FAST] Stopping after a failing test ({remaining} skipped).")
|
|
379
|
+
break
|
|
380
|
+
return results
|
hector/scaffold.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Project scaffolding: `hector init`."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_TEMPLATE = """\
|
|
8
|
+
version: "0.1"
|
|
9
|
+
renode_version: "{renode_version}"
|
|
10
|
+
|
|
11
|
+
# arguments: scalar defaults, each overridable by an env var of the same name.
|
|
12
|
+
arguments:
|
|
13
|
+
BIN: firmware.elf
|
|
14
|
+
|
|
15
|
+
machines:
|
|
16
|
+
{board}:
|
|
17
|
+
platform:
|
|
18
|
+
- platforms/boards/{board}.repl
|
|
19
|
+
firmware: ${{BIN}}
|
|
20
|
+
|
|
21
|
+
# Uncomment to capture UART output to a file:
|
|
22
|
+
# mappings: |
|
|
23
|
+
# {board}.usart2 -> file:results/uart.log
|
|
24
|
+
|
|
25
|
+
# Uncomment to add tests (run with: hector test):
|
|
26
|
+
# tests:
|
|
27
|
+
# - name: Firmware boots
|
|
28
|
+
# type: robot
|
|
29
|
+
# script: |
|
|
30
|
+
# Create Terminal Tester sysbus.usart2
|
|
31
|
+
# Wait For Line On Uart Ready timeout=30
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_PLATFORMS_NOTE = """\
|
|
35
|
+
[INIT] Next steps:
|
|
36
|
+
1. Add your .repl platform file to platforms/boards/{board}.repl
|
|
37
|
+
2. Set BIN to your firmware path, or override at runtime: BIN=path/to/fw.elf hector run
|
|
38
|
+
3. Run the simulation: hector run
|
|
39
|
+
4. Run tests: hector test
|
|
40
|
+
5. Debug a machine: hector run --debug {board}:3333
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scaffold_init():
|
|
45
|
+
"""Interactively create a starter .hector.yaml config in the current directory."""
|
|
46
|
+
target = ".hector.yaml"
|
|
47
|
+
if os.path.exists(target):
|
|
48
|
+
print(f"[INIT] {target} already exists — not overwriting.")
|
|
49
|
+
print(f"[INIT] Delete or rename it, then run 'hector init' again.")
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
print("Hector project initialisation")
|
|
53
|
+
print(" Press Enter to accept the default shown in [brackets].\n")
|
|
54
|
+
|
|
55
|
+
board = _prompt(" Board / machine name", "stm32f4_discovery")
|
|
56
|
+
renode_version = _prompt(" Renode version ", "1.16.1")
|
|
57
|
+
|
|
58
|
+
content = _TEMPLATE.format(board=board, renode_version=renode_version)
|
|
59
|
+
with open(target, "w") as fh:
|
|
60
|
+
fh.write(content)
|
|
61
|
+
|
|
62
|
+
os.makedirs(os.path.join("platforms", "boards"), exist_ok=True)
|
|
63
|
+
os.makedirs("results", exist_ok=True)
|
|
64
|
+
|
|
65
|
+
print(f"\n[INIT] Created {target}")
|
|
66
|
+
print(_PLATFORMS_NOTE.format(board=board))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _prompt(label: str, default: str) -> str:
|
|
70
|
+
try:
|
|
71
|
+
value = input(f"{label} [{default}]: ").strip()
|
|
72
|
+
except (EOFError, KeyboardInterrupt):
|
|
73
|
+
print()
|
|
74
|
+
sys.exit(0)
|
|
75
|
+
return value or default
|