fleetproof 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.
- fleetproof/__init__.py +46 -0
- fleetproof/__main__.py +6 -0
- fleetproof/checker.py +280 -0
- fleetproof/checks.py +218 -0
- fleetproof/cli.py +293 -0
- fleetproof/hookgate.py +166 -0
- fleetproof/report.py +384 -0
- fleetproof/runlog.py +500 -0
- fleetproof-0.1.0.dist-info/METADATA +231 -0
- fleetproof-0.1.0.dist-info/RECORD +14 -0
- fleetproof-0.1.0.dist-info/WHEEL +5 -0
- fleetproof-0.1.0.dist-info/entry_points.txt +2 -0
- fleetproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- fleetproof-0.1.0.dist-info/top_level.txt +1 -0
fleetproof/report.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Static HTML report: what did my fleet actually do — and what did it lie about.
|
|
2
|
+
|
|
3
|
+
Reads the run log (written by one process) and the checker verdicts stored inside
|
|
4
|
+
it (written by another), and renders a single self-contained HTML file: per run,
|
|
5
|
+
what the fleet claimed vs. what the independent checker found. No CDN, no JS, no
|
|
6
|
+
external assets — one file an operator can hand to a compliance function.
|
|
7
|
+
|
|
8
|
+
The inline-CSS discipline here is carried over from the run-inspection tooling this
|
|
9
|
+
package grew out of; the body — claimed-vs-verified, not database tables — is new.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import html as html_lib
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .checks import short_spec_hash
|
|
20
|
+
from .runlog import RunRecord, list_run_records
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_check_verdicts(run: RunRecord) -> list[dict[str, Any]]:
|
|
24
|
+
"""Pull every recorded checker verdict out of a run's sub-invocations."""
|
|
25
|
+
verdicts = []
|
|
26
|
+
for sub in run.sub_invocations:
|
|
27
|
+
if sub.tool == "fleetproof" and sub.subcmd == "check":
|
|
28
|
+
payload = sub.load_output()
|
|
29
|
+
if isinstance(payload, dict) and "checks" in payload:
|
|
30
|
+
verdicts.append(payload)
|
|
31
|
+
return verdicts
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _session_baseline_hash(items: list[_Enriched]) -> str | None:
|
|
35
|
+
"""The earliest verdict's spec hash across a session's runs.
|
|
36
|
+
|
|
37
|
+
``items`` arrives oldest-first (see :func:`_session_blocks`), so the first
|
|
38
|
+
verdict carrying a hash is the session baseline every later verdict is diffed
|
|
39
|
+
against in the report — the same rule the live gate uses.
|
|
40
|
+
"""
|
|
41
|
+
for _run, verdicts, _status in items:
|
|
42
|
+
for v in verdicts:
|
|
43
|
+
sha = v.get("spec_sha256")
|
|
44
|
+
if sha:
|
|
45
|
+
return sha
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_status(run: RunRecord, verdicts: list[dict[str, Any]]) -> str:
|
|
50
|
+
"""Classify a run for the report: 'contradicted', 'verified', or 'unverified'."""
|
|
51
|
+
if not verdicts:
|
|
52
|
+
return "unverified"
|
|
53
|
+
if any(v.get("verdict") == "fail" for v in verdicts):
|
|
54
|
+
return "contradicted"
|
|
55
|
+
return "verified"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# One enriched run: (record, its checker verdicts, its status).
|
|
59
|
+
_Enriched = tuple[RunRecord, list[dict[str, Any]], str]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _session_blocks(enriched: list[_Enriched]) -> list[tuple[str, str | None, list[_Enriched]]]:
|
|
63
|
+
"""Partition enriched runs into render blocks, newest activity first.
|
|
64
|
+
|
|
65
|
+
Runs sharing a ``session_id`` collapse into one ``("session", id, [...])``
|
|
66
|
+
block (chronological inside). Runs without a session id — including every
|
|
67
|
+
record written before session grouping existed — each render as their own
|
|
68
|
+
``("run", None, [item])`` block, exactly as before. Blocks are ordered by
|
|
69
|
+
their most-recent run so the newest activity leads, matching the flat
|
|
70
|
+
newest-first ordering legacy reports had.
|
|
71
|
+
"""
|
|
72
|
+
by_sid: dict[str | None, list[_Enriched]] = {}
|
|
73
|
+
for item in enriched:
|
|
74
|
+
sid = item[0].session_id or None
|
|
75
|
+
by_sid.setdefault(sid, []).append(item)
|
|
76
|
+
|
|
77
|
+
blocks: list[tuple[str, str | None, list[_Enriched]]] = []
|
|
78
|
+
for sid, items in by_sid.items():
|
|
79
|
+
if sid is None:
|
|
80
|
+
continue
|
|
81
|
+
items_sorted = sorted(items, key=lambda it: it[0].started_at or "")
|
|
82
|
+
blocks.append(("session", sid, items_sorted))
|
|
83
|
+
for item in by_sid.get(None, []):
|
|
84
|
+
blocks.append(("run", None, [item]))
|
|
85
|
+
|
|
86
|
+
def _recency(block: tuple[str, str | None, list[_Enriched]]) -> str:
|
|
87
|
+
return max((it[0].started_at or "" for it in block[2]), default="")
|
|
88
|
+
|
|
89
|
+
blocks.sort(key=_recency, reverse=True)
|
|
90
|
+
return blocks
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_report(runs: list[RunRecord] | None = None) -> str:
|
|
94
|
+
"""Render the full HTML report for the given runs (default: all runs)."""
|
|
95
|
+
if runs is None:
|
|
96
|
+
runs = list_run_records()
|
|
97
|
+
|
|
98
|
+
enriched = []
|
|
99
|
+
for run in runs:
|
|
100
|
+
verdicts = _find_check_verdicts(run)
|
|
101
|
+
enriched.append((run, verdicts, _run_status(run, verdicts)))
|
|
102
|
+
|
|
103
|
+
n_total = len(enriched)
|
|
104
|
+
n_contradicted = sum(1 for _, _, s in enriched if s == "contradicted")
|
|
105
|
+
n_verified = sum(1 for _, _, s in enriched if s == "verified")
|
|
106
|
+
n_unverified = sum(1 for _, _, s in enriched if s == "unverified")
|
|
107
|
+
|
|
108
|
+
blocks = _session_blocks(enriched)
|
|
109
|
+
n_sessions = sum(1 for kind, _, _ in blocks if kind == "session")
|
|
110
|
+
|
|
111
|
+
generated = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
112
|
+
parts = [_HTML_HEAD.format(title=html_lib.escape("FleetProof — Run Report"))]
|
|
113
|
+
parts.append("<h1>FleetProof — Run Report</h1>")
|
|
114
|
+
session_meta = f" <b>Sessions:</b> {n_sessions}" if n_sessions else ""
|
|
115
|
+
parts.append(
|
|
116
|
+
f"<p class='meta'><b>Generated:</b> {html_lib.escape(generated)} "
|
|
117
|
+
f"<b>Runs:</b> {n_total}{session_meta}</p>"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
parts.append("<div class='cards'>")
|
|
121
|
+
if n_sessions:
|
|
122
|
+
parts.append(_stat_card("Sessions", str(n_sessions), "neutral"))
|
|
123
|
+
parts.append(_stat_card("Runs", str(n_total), "neutral"))
|
|
124
|
+
parts.append(_stat_card("Independently verified", str(n_verified), "ok"))
|
|
125
|
+
parts.append(_stat_card("Claim contradicted", str(n_contradicted), "bad"))
|
|
126
|
+
parts.append(_stat_card("Unverified", str(n_unverified), "warn"))
|
|
127
|
+
parts.append("</div>")
|
|
128
|
+
|
|
129
|
+
if n_contradicted:
|
|
130
|
+
parts.append(
|
|
131
|
+
"<p class='lead bad-text'>"
|
|
132
|
+
f"{n_contradicted} run(s) reported done, but the independent checker "
|
|
133
|
+
"found a blocking failure. Those are below, marked "
|
|
134
|
+
"<span class='badge bad'>contradicted</span>.</p>"
|
|
135
|
+
)
|
|
136
|
+
elif n_total == 0:
|
|
137
|
+
parts.append("<p class='lead'>No runs recorded yet.</p>")
|
|
138
|
+
else:
|
|
139
|
+
parts.append(
|
|
140
|
+
"<p class='lead ok-text'>No contradicted claims in this window.</p>"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
for kind, sid, items in blocks:
|
|
144
|
+
if kind == "session":
|
|
145
|
+
parts.append(_render_session(sid, items))
|
|
146
|
+
else:
|
|
147
|
+
parts.append(_render_run(*items[0]))
|
|
148
|
+
|
|
149
|
+
parts.append(_HTML_FOOT)
|
|
150
|
+
return "\n".join(parts)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _render_session(session_id: str | None, items: list[_Enriched]) -> str:
|
|
154
|
+
"""Render one session as a container: a header with time range, then its runs."""
|
|
155
|
+
starts = sorted(it[0].started_at for it in items if it[0].started_at)
|
|
156
|
+
lo = starts[0][:19] if starts else "-"
|
|
157
|
+
hi = starts[-1][:19] if starts else "-"
|
|
158
|
+
n_contra = sum(1 for it in items if it[2] == "contradicted")
|
|
159
|
+
n_verified = sum(1 for it in items if it[2] == "verified")
|
|
160
|
+
if n_contra:
|
|
161
|
+
status = "contradicted"
|
|
162
|
+
elif n_verified:
|
|
163
|
+
status = "verified"
|
|
164
|
+
else:
|
|
165
|
+
status = "unverified"
|
|
166
|
+
|
|
167
|
+
parts = [f"<section class='session {status}'>"]
|
|
168
|
+
parts.append(
|
|
169
|
+
f"<h2 class='session-h'>Session <code>{html_lib.escape(str(session_id))}</code> "
|
|
170
|
+
f"{_badge(status)}</h2>"
|
|
171
|
+
)
|
|
172
|
+
parts.append(
|
|
173
|
+
"<p class='meta'>"
|
|
174
|
+
f"<b>runs in session:</b> {len(items)} "
|
|
175
|
+
f"<b>time range:</b> {html_lib.escape(lo)} → {html_lib.escape(hi)}"
|
|
176
|
+
"</p>"
|
|
177
|
+
)
|
|
178
|
+
baseline = _session_baseline_hash(items)
|
|
179
|
+
parts.append("<div class='session-body'>")
|
|
180
|
+
for run, verdicts, run_status in items:
|
|
181
|
+
parts.append(_render_run(run, verdicts, run_status, baseline))
|
|
182
|
+
parts.append("</div>")
|
|
183
|
+
parts.append("</section>")
|
|
184
|
+
return "\n".join(parts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _stat_card(label: str, value: str, tone: str) -> str:
|
|
188
|
+
return (
|
|
189
|
+
f"<div class='card {tone}'><div class='card-val'>{html_lib.escape(value)}</div>"
|
|
190
|
+
f"<div class='card-lbl'>{html_lib.escape(label)}</div></div>"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _badge(status: str) -> str:
|
|
195
|
+
tone = {"contradicted": "bad", "verified": "ok", "unverified": "warn"}.get(status, "neutral")
|
|
196
|
+
return f"<span class='badge {tone}'>{html_lib.escape(status)}</span>"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _render_run(
|
|
200
|
+
run: RunRecord,
|
|
201
|
+
verdicts: list[dict[str, Any]],
|
|
202
|
+
status: str,
|
|
203
|
+
baseline_hash: str | None = None,
|
|
204
|
+
) -> str:
|
|
205
|
+
parts = [f"<section class='run {status}'>"]
|
|
206
|
+
parts.append(
|
|
207
|
+
f"<h2>{html_lib.escape(run.run_id)} {_badge(status)}</h2>"
|
|
208
|
+
)
|
|
209
|
+
parts.append(
|
|
210
|
+
"<p class='meta'>"
|
|
211
|
+
f"<b>root tool:</b> {html_lib.escape(run.root_tool or '-')} "
|
|
212
|
+
f"<b>started:</b> {html_lib.escape((run.started_at or '-')[:19])} "
|
|
213
|
+
f"<b>sub-invocations:</b> {len(run.sub_invocations)} "
|
|
214
|
+
f"<b>failed sub-invocations:</b> {run.failed_count}"
|
|
215
|
+
"</p>"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if not verdicts:
|
|
219
|
+
parts.append(
|
|
220
|
+
"<p class='note'>No independent checker verdict recorded for this run. "
|
|
221
|
+
"The fleet's claim here has not been verified out-of-band.</p>"
|
|
222
|
+
)
|
|
223
|
+
for v in verdicts:
|
|
224
|
+
parts.append(_render_verdict(v, baseline_hash))
|
|
225
|
+
|
|
226
|
+
if run.sub_invocations:
|
|
227
|
+
parts.append(_render_sub_table(run))
|
|
228
|
+
|
|
229
|
+
parts.append("</section>")
|
|
230
|
+
return "\n".join(parts)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _render_verdict(v: dict[str, Any], baseline_hash: str | None = None) -> str:
|
|
234
|
+
summary = v.get("summary", {})
|
|
235
|
+
verdict = v.get("verdict", "?")
|
|
236
|
+
tone = "bad" if verdict == "fail" else "ok"
|
|
237
|
+
sha = v.get("spec_sha256")
|
|
238
|
+
drifted = bool(baseline_hash and sha and sha != baseline_hash)
|
|
239
|
+
drift_badge = " <span class='badge drift'>spec drift</span>" if drifted else ""
|
|
240
|
+
parts = [
|
|
241
|
+
f"<h3>Checker verdict: <span class='badge {tone}'>{html_lib.escape(verdict)}</span> "
|
|
242
|
+
f"<span class='count'>({summary.get('passed', 0)}/{summary.get('total', 0)} passed, "
|
|
243
|
+
f"{summary.get('blocking_failed', 0)} blocking)</span> "
|
|
244
|
+
f"<span class='spec'>spec {html_lib.escape(short_spec_hash(sha))}</span>"
|
|
245
|
+
f"{drift_badge}</h3>"
|
|
246
|
+
]
|
|
247
|
+
if drifted:
|
|
248
|
+
parts.append(
|
|
249
|
+
"<p class='drift-note'>.fleetproof/checks.json was modified during this "
|
|
250
|
+
"session (spec drift): this verdict was graded against "
|
|
251
|
+
f"<code>{html_lib.escape(short_spec_hash(sha))}</code>, but the session "
|
|
252
|
+
f"baseline is <code>{html_lib.escape(short_spec_hash(baseline_hash))}</code>. "
|
|
253
|
+
"Review the diff before trusting this verdict.</p>"
|
|
254
|
+
)
|
|
255
|
+
parts.append("<table class='checks'><thead><tr>")
|
|
256
|
+
parts.append("<th>check</th><th>expected</th><th>result</th><th>detail</th><th>blocking</th>")
|
|
257
|
+
parts.append("</tr></thead><tbody>")
|
|
258
|
+
for c in v.get("checks", []):
|
|
259
|
+
passed = c.get("passed")
|
|
260
|
+
blocking = c.get("blocking")
|
|
261
|
+
row_cls = "row-pass" if passed else ("row-fail" if blocking else "row-warn")
|
|
262
|
+
result = "pass" if passed else ("FAIL" if blocking else "warn")
|
|
263
|
+
parts.append(f"<tr class='{row_cls}'>")
|
|
264
|
+
parts.append(f"<td><code>{html_lib.escape(str(c.get('id', '?')))}</code></td>")
|
|
265
|
+
parts.append(f"<td>{html_lib.escape(str(c.get('expectation', '-')))}</td>")
|
|
266
|
+
parts.append(f"<td>{html_lib.escape(result)}</td>")
|
|
267
|
+
parts.append(f"<td>{html_lib.escape(str(c.get('detail', '-')))}</td>")
|
|
268
|
+
parts.append(f"<td>{'yes' if blocking else 'no'}</td>")
|
|
269
|
+
parts.append("</tr>")
|
|
270
|
+
parts.append("</tbody></table>")
|
|
271
|
+
return "\n".join(parts)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _render_sub_table(run: RunRecord) -> str:
|
|
275
|
+
parts = ["<h3>Recorded invocations</h3>"]
|
|
276
|
+
parts.append("<table class='rows'><thead><tr>")
|
|
277
|
+
parts.append("<th>tool</th><th>subcmd</th><th>started</th><th>exit</th>"
|
|
278
|
+
"<th>duration</th><th>evidence</th>")
|
|
279
|
+
parts.append("</tr></thead><tbody>")
|
|
280
|
+
for s in run.sub_invocations:
|
|
281
|
+
if s.exit_code is None:
|
|
282
|
+
outcome = "?"
|
|
283
|
+
elif s.exit_code == 0 and not s.exception_type:
|
|
284
|
+
outcome = "0"
|
|
285
|
+
else:
|
|
286
|
+
outcome = f"{s.exit_code}{' / ' + s.exception_type if s.exception_type else ''}"
|
|
287
|
+
dur = f"{s.duration_ms:.1f}ms" if s.duration_ms is not None else "-"
|
|
288
|
+
parts.append("<tr>")
|
|
289
|
+
parts.append(f"<td>{html_lib.escape(s.tool)}</td>")
|
|
290
|
+
parts.append(f"<td>{html_lib.escape(s.subcmd)}</td>")
|
|
291
|
+
parts.append(f"<td>{html_lib.escape((s.started_at or '-')[:19])}</td>")
|
|
292
|
+
parts.append(f"<td>{html_lib.escape(outcome)}</td>")
|
|
293
|
+
parts.append(f"<td>{html_lib.escape(dur)}</td>")
|
|
294
|
+
parts.append(f"<td><code>{html_lib.escape(s.record_dir.name)}</code></td>")
|
|
295
|
+
parts.append("</tr>")
|
|
296
|
+
parts.append("</tbody></table>")
|
|
297
|
+
return "\n".join(parts)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def write_report(output: Path, runs: list[RunRecord] | None = None) -> Path:
|
|
301
|
+
"""Render and write the report to ``output``; returns the path written."""
|
|
302
|
+
output = Path(output)
|
|
303
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
output.write_text(build_report(runs), encoding="utf-8")
|
|
305
|
+
return output
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# === HTML chrome (inline; no external deps) ===
|
|
309
|
+
|
|
310
|
+
_HTML_HEAD = """<!DOCTYPE html>
|
|
311
|
+
<html lang="en">
|
|
312
|
+
<head>
|
|
313
|
+
<meta charset="utf-8">
|
|
314
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
315
|
+
<title>{title}</title>
|
|
316
|
+
<style>
|
|
317
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
318
|
+
Helvetica, Arial, sans-serif;
|
|
319
|
+
margin: 2em auto; max-width: 1100px; padding: 0 1em;
|
|
320
|
+
color: #222; background: #fdfdfd; line-height: 1.45; }}
|
|
321
|
+
h1, h2, h3 {{ color: #1a1a1a; }}
|
|
322
|
+
h1 {{ border-bottom: 2px solid #888; padding-bottom: 0.3em; }}
|
|
323
|
+
h2 {{ margin-top: 0.2em; font-size: 1.1em; }}
|
|
324
|
+
h3 {{ margin-top: 1em; color: #555; font-size: 1em; }}
|
|
325
|
+
.meta {{ color: #666; font-size: 0.9em; }}
|
|
326
|
+
.count {{ color: #888; font-weight: normal; font-size: 0.9em; }}
|
|
327
|
+
.lead {{ font-size: 1.05em; margin: 1em 0; }}
|
|
328
|
+
.note {{ color: #777; font-style: italic; }}
|
|
329
|
+
.bad-text {{ color: #a11; }}
|
|
330
|
+
.ok-text {{ color: #171; }}
|
|
331
|
+
.cards {{ display: flex; flex-wrap: wrap; gap: 1em; margin: 1.2em 0; }}
|
|
332
|
+
.card {{ flex: 1 1 140px; border: 1px solid #ddd; border-radius: 6px;
|
|
333
|
+
padding: 0.8em 1em; background: #fff; }}
|
|
334
|
+
.card-val {{ font-size: 1.8em; font-weight: 700; }}
|
|
335
|
+
.card-lbl {{ color: #666; font-size: 0.85em; }}
|
|
336
|
+
.card.ok .card-val {{ color: #171; }}
|
|
337
|
+
.card.bad .card-val {{ color: #a11; }}
|
|
338
|
+
.card.warn .card-val {{ color: #a60; }}
|
|
339
|
+
.badge {{ display: inline-block; font-size: 0.7em; font-weight: 700;
|
|
340
|
+
text-transform: uppercase; letter-spacing: 0.04em;
|
|
341
|
+
padding: 2px 7px; border-radius: 10px; vertical-align: middle; }}
|
|
342
|
+
.badge.ok {{ background: #e3f5e3; color: #171; }}
|
|
343
|
+
.badge.bad {{ background: #fbe3e3; color: #a11; }}
|
|
344
|
+
.badge.warn {{ background: #fbf1dd; color: #a60; }}
|
|
345
|
+
.badge.neutral {{ background: #eee; color: #555; }}
|
|
346
|
+
.badge.drift {{ background: #efe0fb; color: #6b21a8; }}
|
|
347
|
+
.spec {{ font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
|
348
|
+
font-size: 0.8em; color: #777; font-weight: normal; }}
|
|
349
|
+
.drift-note {{ margin: 0.3em 0 0.6em; padding: 0.5em 0.8em;
|
|
350
|
+
border-left: 4px solid #8b3fd1; background: #f6edfc;
|
|
351
|
+
color: #6b21a8; font-size: 0.9em; }}
|
|
352
|
+
section.session {{ margin-top: 1.8em; padding: 1em 1.2em 1.2em;
|
|
353
|
+
border-radius: 8px; border: 1px solid #d3d3d3;
|
|
354
|
+
background: #f4f5f7; }}
|
|
355
|
+
section.session > .session-h {{ margin-top: 0; font-size: 1.15em; }}
|
|
356
|
+
section.session.contradicted {{ border-left: 6px solid #c33; }}
|
|
357
|
+
section.session.verified {{ border-left: 6px solid #3a3; }}
|
|
358
|
+
section.session.unverified {{ border-left: 6px solid #d9a441; }}
|
|
359
|
+
.session-body section.run {{ margin-top: 1em; }}
|
|
360
|
+
section.run {{ margin-top: 1.6em; padding: 1em 1.2em; border-radius: 6px;
|
|
361
|
+
border: 1px solid #e2e2e2; background: #fff; }}
|
|
362
|
+
section.run.contradicted {{ border-left: 5px solid #c33; }}
|
|
363
|
+
section.run.verified {{ border-left: 5px solid #3a3; }}
|
|
364
|
+
section.run.unverified {{ border-left: 5px solid #d9a441; }}
|
|
365
|
+
table {{ border-collapse: collapse; margin: 0.5em 0 1em 0;
|
|
366
|
+
font-size: 0.9em; width: 100%; }}
|
|
367
|
+
th, td {{ border: 1px solid #ddd; padding: 4px 8px; text-align: left;
|
|
368
|
+
vertical-align: top; }}
|
|
369
|
+
th {{ background: #f0f0f0; font-weight: 600; }}
|
|
370
|
+
tr.row-fail td {{ background: #fdecec; }}
|
|
371
|
+
tr.row-warn td {{ background: #fdf6e8; }}
|
|
372
|
+
code {{ font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
|
373
|
+
background: #f0f0f0; padding: 1px 4px; border-radius: 3px; }}
|
|
374
|
+
table.rows td {{ font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
|
375
|
+
overflow-wrap: anywhere; }}
|
|
376
|
+
</style>
|
|
377
|
+
</head>
|
|
378
|
+
<body>
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
_HTML_FOOT = """
|
|
382
|
+
</body>
|
|
383
|
+
</html>
|
|
384
|
+
"""
|