From ef8839365697e7f0758e7e93ca9a7c0727597368 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Fri, 18 Sep 2026 11:55:58 -0700 Subject: [PATCH 1/2] ci: publish extensive test and coverage job summaries Job summaries listed the highest-coverage packages (almost all 100%) and never named failing tests. Capture go test -json / Vitest JSON and lead the summary with failures, then lowest packages, files, functions, area rollups, and coverage bands so a newly red test is visible on the Actions page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e --- .github/scripts/backend_coverage_summary.py | 615 ++++++++++++++++++ .github/scripts/frontend_coverage_summary.mjs | 401 ++++++++++++ .github/workflows/ci.yml | 85 +-- 3 files changed, 1046 insertions(+), 55 deletions(-) create mode 100644 .github/scripts/backend_coverage_summary.py create mode 100644 .github/scripts/frontend_coverage_summary.mjs diff --git a/.github/scripts/backend_coverage_summary.py b/.github/scripts/backend_coverage_summary.py new file mode 100644 index 000000000..759cd2b33 --- /dev/null +++ b/.github/scripts/backend_coverage_summary.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +"""Render an extensive GitHub Actions job summary from Go test + cover data. + +The previous CI summary listed the *highest* package percentages (almost +all 100%), which hid gaps and never mentioned failing tests. This script +leads with failures, then lowest-coverage packages/files/functions. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + +MODULE = "github.com/ev-dev-labs/teslasync/" +COVER_LINE = re.compile( + r"^(?P.+):(?P\d+\.\d+),(?P\d+\.\d+) (?P\d+) (?P\d+)$" +) +FUNC_LINE = re.compile( + r"^(?P.+):(?P\d+):\s+(?P\S+)\s+(?P[\d.]+)%$" +) + + +@dataclass +class Block: + file: str + stmts: int + covered: int + + +@dataclass +class Func: + file: str + name: str + pct: float + + +@dataclass +class TestEvent: + action: str + package: str + test: str = "" + elapsed: float = 0.0 + output: str = "" + + +@dataclass +class Report: + blocks: list[Block] = field(default_factory=list) + funcs: list[Func] = field(default_factory=list) + events: list[TestEvent] = field(default_factory=list) + + +def short_pkg(path: str) -> str: + rel = path + if rel.startswith(MODULE): + rel = rel[len(MODULE) :] + if rel.endswith(".go"): + rel = rel.rsplit("/", 1)[0] + return rel or path + + +def layer_of(pkg: str) -> str: + parts = pkg.split("/") + if not parts: + return "other" + if parts[0] == "cmd": + return "cmd" + if parts[0] == "tools": + return "tools" + if parts[0] == "internal" and len(parts) > 1: + return f"internal/{parts[1]}" + return parts[0] + + +def pct(covered: int, total: int) -> float: + if total <= 0: + return 100.0 + return 100.0 * covered / total + + +def parse_coverprofile(text: str) -> list[Block]: + out: list[Block] = [] + for raw in text.splitlines(): + if raw.startswith("mode:") or not raw.strip(): + continue + m = COVER_LINE.match(raw) + if not m: + continue + stmts = int(m.group("stmts")) + count = int(m.group("count")) + out.append( + Block( + file=m.group("file"), + stmts=stmts, + covered=stmts if count > 0 else 0, + ) + ) + return out + + +def parse_func(text: str) -> list[Func]: + out: list[Func] = [] + for raw in text.splitlines(): + if raw.startswith("total:"): + continue + m = FUNC_LINE.match(raw) + if not m: + continue + out.append( + Func( + file=m.group("file"), + name=m.group("name"), + pct=float(m.group("pct")), + ) + ) + return out + + +def parse_test_json(text: str) -> list[TestEvent]: + events: list[TestEvent] = [] + for raw in text.splitlines(): + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except json.JSONDecodeError: + continue + action = str(obj.get("Action") or "") + if action not in {"pass", "fail", "skip", "output"}: + continue + events.append( + TestEvent( + action=action, + package=str(obj.get("Package") or ""), + test=str(obj.get("Test") or ""), + elapsed=float(obj.get("Elapsed") or 0), + output=str(obj.get("Output") or ""), + ) + ) + return events + + +def load_report(profile: Path | None, func_path: Path | None, json_path: Path | None) -> Report: + r = Report() + if profile and profile.exists(): + r.blocks = parse_coverprofile(profile.read_text(encoding="utf-8", errors="replace")) + if func_path and func_path.exists(): + r.funcs = parse_func(func_path.read_text(encoding="utf-8", errors="replace")) + if json_path and json_path.exists(): + r.events = parse_test_json(json_path.read_text(encoding="utf-8", errors="replace")) + return r + + +def aggregate_files(blocks: Iterable[Block]) -> dict[str, tuple[int, int]]: + acc: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for b in blocks: + acc[b.file][0] += b.stmts + acc[b.file][1] += b.covered + return {k: (v[0], v[1]) for k, v in acc.items()} + + +def aggregate_packages(files: dict[str, tuple[int, int]]) -> dict[str, tuple[int, int]]: + acc: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for file, (stmts, covered) in files.items(): + pkg = short_pkg(file) + acc[pkg][0] += stmts + acc[pkg][1] += covered + return {k: (v[0], v[1]) for k, v in acc.items()} + + +def aggregate_layers(packages: dict[str, tuple[int, int]]) -> dict[str, tuple[int, int]]: + acc: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for pkg, (stmts, covered) in packages.items(): + layer = layer_of(pkg) + acc[layer][0] += stmts + acc[layer][1] += covered + return {k: (v[0], v[1]) for k, v in acc.items()} + + +def band(p: float) -> str: + if p >= 100: + return "100%" + if p >= 80: + return "80–99%" + if p >= 50: + return "50–79%" + if p > 0: + return "1–49%" + return "0%" + + +def md_table(headers: list[str], rows: list[list[str]]) -> list[str]: + lines = [ + "| " + " | ".join(headers) + " |", + "| " + " | ".join(["---"] * len(headers)) + " |", + ] + for row in rows: + lines.append("| " + " | ".join(row) + " |") + return lines + + +def fmt_pct(p: float) -> str: + return f"{p:.1f}%" + + +def rel_file(path: str) -> str: + if path.startswith(MODULE): + return path[len(MODULE) :] + return path + + +def test_outcome(report: Report) -> dict[str, object]: + failed_tests: list[tuple[str, str]] = [] + passed_tests = 0 + skipped_tests = 0 + failed_pkgs: list[str] = [] + passed_pkgs = 0 + skipped_pkgs = 0 + slow: list[tuple[float, str, str]] = [] + fail_output: dict[tuple[str, str], list[str]] = defaultdict(list) + + for ev in report.events: + if ev.action == "output" and ev.test: + fail_output[(ev.package, ev.test)].append(ev.output) + continue + if ev.test: + if ev.action == "fail": + failed_tests.append((ev.package, ev.test)) + slow.append((ev.elapsed, ev.package, ev.test)) + elif ev.action == "pass": + passed_tests += 1 + slow.append((ev.elapsed, ev.package, ev.test)) + elif ev.action == "skip": + skipped_tests += 1 + continue + if ev.action == "fail" and ev.package: + failed_pkgs.append(ev.package) + elif ev.action == "pass" and ev.package: + passed_pkgs += 1 + elif ev.action == "skip" and ev.package: + skipped_pkgs += 1 + + slow.sort(reverse=True) + snippets: dict[tuple[str, str], str] = {} + for key in failed_tests: + buf = "".join(fail_output.get(key, [])) + lines = [ln.rstrip() for ln in buf.splitlines() if ln.strip()] + # Keep FAIL lines and assertion context, drop RUN noise. + keep = [ + ln + for ln in lines + if not ln.startswith("=== RUN") and not ln.startswith("=== PAUSE") + ] + snippets[key] = "\n".join(keep[-24:]) + return { + "failed_tests": failed_tests, + "passed_tests": passed_tests, + "skipped_tests": skipped_tests, + "failed_pkgs": failed_pkgs, + "passed_pkgs": passed_pkgs, + "skipped_pkgs": skipped_pkgs, + "slow": slow[:15], + "snippets": snippets, + } + + +def render(report: Report) -> str: + files = aggregate_files(report.blocks) + packages = aggregate_packages(files) + layers = aggregate_layers(packages) + total_stmts = sum(s for s, _ in files.values()) + total_cov = sum(c for _, c in files.values()) + overall = pct(total_cov, total_stmts) + outcome = test_outcome(report) + + lines: list[str] = [] + lines.append("## Backend tests") + lines.append("") + + failed_tests: list[tuple[str, str]] = outcome["failed_tests"] # type: ignore[assignment] + failed_pkgs: list[str] = outcome["failed_pkgs"] # type: ignore[assignment] + if failed_tests or failed_pkgs: + lines.append("### Failures — start here") + lines.append("") + lines.append( + f"**{len(failed_tests)} test(s)** failed in **{len(failed_pkgs)} package(s)**." + ) + lines.append("New red tests show up in this section on the next run.") + lines.append("") + rows = [] + for pkg, name in failed_tests[:80]: + rows.append([f"`{name}`", f"`{short_pkg(pkg)}`"]) + if rows: + lines.extend(md_table(["Test", "Package"], rows)) + lines.append("") + snippets: dict[tuple[str, str], str] = outcome["snippets"] # type: ignore[assignment] + shown = 0 + for key in failed_tests: + snippet = snippets.get(key, "").strip() + if not snippet: + continue + pkg, name = key + lines.append(f"
Log: `{name}` (`{short_pkg(pkg)}`)") + lines.append("") + lines.append("```") + lines.append(snippet[:4000]) + lines.append("```") + lines.append("
") + lines.append("") + shown += 1 + if shown >= 12: + break + if len(failed_tests) > 80: + lines.append(f"_…and {len(failed_tests) - 80} more failing tests._") + lines.append("") + else: + lines.append( + f"**All reported tests passed** " + f"({outcome['passed_tests']} pass, {outcome['skipped_tests']} skip, " + f"{outcome['passed_pkgs']} packages)." + ) + lines.append("") + + lines.append("| Result | Tests | Packages |") + lines.append("| --- | ---: | ---: |") + lines.append( + f"| Fail | {len(failed_tests)} | {len(failed_pkgs)} |" + ) + lines.append( + f"| Pass | {outcome['passed_tests']} | {outcome['passed_pkgs']} |" + ) + lines.append( + f"| Skip | {outcome['skipped_tests']} | {outcome['skipped_pkgs']} |" + ) + lines.append("") + + slow: list[tuple[float, str, str]] = outcome["slow"] # type: ignore[assignment] + if slow: + lines.append("
Slowest tests") + lines.append("") + lines.extend( + md_table( + ["Seconds", "Test", "Package"], + [ + [f"{elapsed:.2f}", f"`{name}`", f"`{short_pkg(pkg)}`"] + for elapsed, pkg, name in slow + if name + ], + ) + ) + lines.append("") + lines.append("
") + lines.append("") + + lines.append("## Backend coverage") + lines.append("") + if total_stmts == 0: + lines.append("_No coverage profile was produced for this run._") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + lines.append( + f"**{fmt_pct(overall)}** of statements covered " + f"(`{total_cov} / {total_stmts}`)." + ) + lines.append("") + lines.append( + "Lowest packages and files are listed first. " + "HTML + per-function reports are on the **backend-coverage** artifact." + ) + lines.append("") + + band_counts: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for stmts, covered in packages.values(): + b = band(pct(covered, stmts)) + band_counts[b][0] += 1 + band_counts[b][1] += stmts + order = ["0%", "1–49%", "50–79%", "80–99%", "100%"] + lines.append("### Package coverage bands") + lines.append("") + lines.extend( + md_table( + ["Band", "Packages", "Statements"], + [ + [b, str(band_counts[b][0]), str(band_counts[b][1])] + for b in order + if band_counts[b][0] + ], + ) + ) + lines.append("") + + if layers: + lines.append("### Coverage by area") + lines.append("") + layer_rows = [] + for name, (stmts, covered) in sorted( + layers.items(), key=lambda kv: pct(kv[1][1], kv[1][0]) + ): + layer_rows.append( + [ + f"`{name}`", + fmt_pct(pct(covered, stmts)), + str(covered), + str(stmts), + str(stmts - covered), + ] + ) + lines.extend( + md_table(["Area", "Covered", "Hit", "Stmts", "Missed"], layer_rows) + ) + lines.append("") + + ranked_pkgs = sorted( + packages.items(), + key=lambda kv: (pct(kv[1][1], kv[1][0]), -(kv[1][0] - kv[1][1]), kv[0]), + ) + lowest = [(p, s, c) for p, (s, c) in ranked_pkgs if s > 0 and pct(c, s) < 100][:40] + if lowest: + lines.append("### Lowest packages (actionable)") + lines.append("") + lines.extend( + md_table( + ["Covered", "Hit", "Stmts", "Missed", "Package"], + [ + [ + fmt_pct(pct(c, s)), + str(c), + str(s), + str(s - c), + f"`{p}`", + ] + for p, s, c in lowest + ], + ) + ) + lines.append("") + + ranked_files = sorted( + files.items(), + key=lambda kv: (pct(kv[1][1], kv[1][0]), -(kv[1][0] - kv[1][1]), kv[0]), + ) + low_files = [ + (f, s, c) for f, (s, c) in ranked_files if s > 0 and pct(c, s) < 100 + ][:30] + if low_files: + lines.append("### Lowest files") + lines.append("") + lines.extend( + md_table( + ["Covered", "Missed", "File"], + [ + [fmt_pct(pct(c, s)), str(s - c), f"`{rel_file(f)}`"] + for f, s, c in low_files + ], + ) + ) + lines.append("") + + low_funcs = sorted( + [fn for fn in report.funcs if fn.pct < 100], + key=lambda fn: (fn.pct, fn.file, fn.name), + )[:30] + if low_funcs: + lines.append("### Lowest functions") + lines.append("") + lines.extend( + md_table( + ["Covered", "Function", "File"], + [ + [fmt_pct(fn.pct), f"`{fn.name}`", f"`{rel_file(fn.file)}`"] + for fn in low_funcs + ], + ) + ) + lines.append("") + + zero = [p for p, (s, c) in ranked_pkgs if s > 0 and c == 0] + full = [p for p, (s, c) in ranked_pkgs if s > 0 and c == s] + if zero: + lines.append(f"
Zero-coverage packages ({len(zero)})") + lines.append("") + for p in zero[:80]: + lines.append(f"- `{p}`") + if len(zero) > 80: + lines.append(f"- …and {len(zero) - 80} more") + lines.append("") + lines.append("
") + lines.append("") + if full: + lines.append( + f"
Fully covered packages ({len(full)})" + ) + lines.append("") + for p in full[:80]: + lines.append(f"- `{p}`") + if len(full) > 80: + lines.append(f"- …and {len(full) - 80} more") + lines.append("") + lines.append("
") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def write_package_table(packages: dict[str, tuple[int, int]], dest: Path) -> None: + rows = sorted( + packages.items(), + key=lambda kv: (pct(kv[1][1], kv[1][0]), kv[0]), + ) + lines = ["coverage stmts covered missed package"] + for pkg, (stmts, covered) in rows: + lines.append( + f"{pct(covered, stmts):7.2f}% {stmts:5d} {covered:7d} {stmts - covered:6d} {pkg}" + ) + dest.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_file_table(files: dict[str, tuple[int, int]], dest: Path) -> None: + rows = sorted( + files.items(), + key=lambda kv: (pct(kv[1][1], kv[1][0]), kv[0]), + ) + lines = ["coverage stmts covered missed file"] + for file, (stmts, covered) in rows: + lines.append( + f"{pct(covered, stmts):7.2f}% {stmts:5d} {covered:7d} {stmts - covered:6d} {rel_file(file)}" + ) + dest.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +SELF_TEST_PROFILE = """mode: set +github.com/ev-dev-labs/teslasync/internal/api/foo/a.go:10.1,12.2 5 1 +github.com/ev-dev-labs/teslasync/internal/api/foo/a.go:12.2,14.3 5 0 +github.com/ev-dev-labs/teslasync/internal/models/alert/alert.go:1.1,2.2 10 10 +github.com/ev-dev-labs/teslasync/cmd/teslasync/main.go:1.1,2.2 4 0 +""" + +SELF_TEST_FUNC = """github.com/ev-dev-labs/teslasync/internal/api/foo/a.go:10:\tServeHTTP\t50.0% +github.com/ev-dev-labs/teslasync/internal/models/alert/alert.go:1:\tValidate\t100.0% +github.com/ev-dev-labs/teslasync/cmd/teslasync/main.go:1:\tmain\t0.0% +total:\t\t(statements)\t60.0% +""" + +SELF_TEST_JSON = """ +{"Action":"fail","Package":"github.com/ev-dev-labs/teslasync/internal/api/foo","Test":"TestWakeUp","Elapsed":0.04} +{"Action":"output","Package":"github.com/ev-dev-labs/teslasync/internal/api/foo","Test":"TestWakeUp","Output":" client_test.go:250: status 408\\n"} +{"Action":"fail","Package":"github.com/ev-dev-labs/teslasync/internal/api/foo","Elapsed":0.2} +{"Action":"pass","Package":"github.com/ev-dev-labs/teslasync/internal/models/alert","Test":"TestValidate","Elapsed":0.01} +{"Action":"pass","Package":"github.com/ev-dev-labs/teslasync/internal/models/alert","Elapsed":0.02} +""" + + +def self_test() -> None: + report = Report( + blocks=parse_coverprofile(SELF_TEST_PROFILE), + funcs=parse_func(SELF_TEST_FUNC), + events=parse_test_json(SELF_TEST_JSON), + ) + md = render(report) + assert "Failures — start here" in md, md + assert "TestWakeUp" in md, md + assert "Lowest packages" in md, md + assert "cmd" in md, md + assert "0.0%" in md or "0%" in md + files = aggregate_files(report.blocks) + pkgs = aggregate_packages(files) + assert pkgs["cmd/teslasync"][1] == 0 + assert pkgs["internal/models/alert"][1] == 10 + print("backend_coverage_summary self-test OK") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser() + p.add_argument("--profile", type=Path, help="go coverprofile (coverage.out)") + p.add_argument("--func", type=Path, dest="func_path", help="go tool cover -func output") + p.add_argument("--json", type=Path, dest="json_path", help="go test -json NDJSON") + p.add_argument("--summary", type=Path, help="append markdown (GITHUB_STEP_SUMMARY)") + p.add_argument("--markdown-out", type=Path, help="write markdown file") + p.add_argument("--package-out", type=Path) + p.add_argument("--file-out", type=Path) + p.add_argument("--self-test", action="store_true") + args = p.parse_args(argv) + + if args.self_test: + self_test() + return 0 + + report = load_report(args.profile, args.func_path, args.json_path) + md = render(report) + if args.markdown_out: + args.markdown_out.write_text(md, encoding="utf-8") + if args.summary: + with args.summary.open("a", encoding="utf-8") as fh: + fh.write(md if md.endswith("\n") else md + "\n") + if not args.summary and not args.markdown_out: + sys.stdout.write(md) + + files = aggregate_files(report.blocks) + packages = aggregate_packages(files) + if args.package_out: + write_package_table(packages, args.package_out) + if args.file_out: + write_file_table(files, args.file_out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/frontend_coverage_summary.mjs b/.github/scripts/frontend_coverage_summary.mjs new file mode 100644 index 000000000..7e35edcf7 --- /dev/null +++ b/.github/scripts/frontend_coverage_summary.mjs @@ -0,0 +1,401 @@ +#!/usr/bin/env node +/** + * Extensive GitHub Actions job summary from Vitest JSON + Istanbul + * coverage-summary.json. Leads with failing tests so a newly red spec + * is visible on the job page without opening logs. + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +function readJson(file) { + if (!file || !fs.existsSync(file)) return null + return JSON.parse(fs.readFileSync(file, 'utf8')) +} + +function pct(covered, total) { + if (!total) return 100 + return (100 * covered) / total +} + +function fmtPct(n) { + return `${Number(n).toFixed(1)}%` +} + +function mdTable(headers, rows) { + const lines = [ + `| ${headers.join(' | ')} |`, + `| ${headers.map(() => '---').join(' | ')} |`, + ] + for (const row of rows) lines.push(`| ${row.join(' | ')} |`) + return lines +} + +function relSrc(filePath) { + const norm = String(filePath).replaceAll('\\', '/') + const idx = norm.lastIndexOf('/src/') + if (idx >= 0) return norm.slice(idx + 1) + if (norm.startsWith('src/')) return norm + return norm +} + +function areaOf(rel) { + const parts = rel.split('/') + if (parts[0] !== 'src') return 'other' + if (parts[1] === 'features' && parts[2]) return `features/${parts[2]}` + if (parts[1] === 'components' && parts[2]) return `components/${parts[2]}` + if (parts[1]) return parts[1] + return 'src' +} + +function band(p) { + if (p >= 100) return '100%' + if (p >= 80) return '80–99%' + if (p >= 50) return '50–79%' + if (p > 0) return '1–49%' + return '0%' +} + +function collectVitest(results) { + const failed = [] + const slow = [] + let passed = 0 + let skipped = 0 + if (!results) { + return { failed, slow, passed, skipped, numTotal: 0, success: true } + } + + const files = Array.isArray(results.testResults) ? results.testResults : [] + for (const file of files) { + const fileName = relSrc(file.name || file.file || '') + const assertions = file.assertionResults || [] + for (const a of assertions) { + const title = (a.fullName || a.title || a.name || '').trim() || '(unnamed)' + const status = a.status || file.status + const dur = Number(a.duration || 0) + if (status === 'failed' || status === 'fail') { + failed.push({ + title, + file: fileName, + message: (a.failureMessages || []).join('\n').slice(0, 4000), + }) + } else if (status === 'pending' || status === 'skipped' || status === 'todo') { + skipped += 1 + } else { + passed += 1 + } + if (dur) slow.push({ dur, title, file: fileName }) + } + } + slow.sort((a, b) => b.dur - a.dur) + return { + failed, + slow: slow.slice(0, 15), + passed, + skipped, + numTotal: Number(results.numTotalTests || passed + skipped + failed.length), + success: results.success !== false && failed.length === 0, + } +} + +function collectCoverage(summary) { + const files = [] + let total = null + if (!summary || typeof summary !== 'object') return { files, total } + for (const [key, val] of Object.entries(summary)) { + if (!val || typeof val !== 'object') continue + if (key === 'total') { + total = val + continue + } + files.push({ + file: relSrc(key), + lines: val.lines || { total: 0, covered: 0, pct: 0 }, + statements: val.statements || { total: 0, covered: 0, pct: 0 }, + functions: val.functions || { total: 0, covered: 0, pct: 0 }, + branches: val.branches || { total: 0, covered: 0, pct: 0 }, + }) + } + return { files, total } +} + +function render({ tests, coverage }) { + const lines = [] + lines.push('## Frontend tests') + lines.push('') + + if (tests.failed.length) { + lines.push('### Failures — start here') + lines.push('') + lines.push( + `**${tests.failed.length} test(s)** failed. New red specs show up in this section on the next run.`, + ) + lines.push('') + lines.push( + ...mdTable( + ['Test', 'File'], + tests.failed.slice(0, 80).map((f) => [`\`${f.title.replaceAll('|', '\\|')}\``, `\`${f.file}\``]), + ), + ) + lines.push('') + let shown = 0 + for (const f of tests.failed) { + if (!f.message) continue + lines.push(`
Log: \`${f.title.replaceAll('`', "'")}\``) + lines.push('') + lines.push('```') + lines.push(f.message) + lines.push('```') + lines.push('
') + lines.push('') + shown += 1 + if (shown >= 12) break + } + } else { + lines.push( + `**All reported tests passed** (${tests.passed} pass, ${tests.skipped} skip).`, + ) + lines.push('') + } + + lines.push('| Result | Count |') + lines.push('| --- | ---: |') + lines.push(`| Fail | ${tests.failed.length} |`) + lines.push(`| Pass | ${tests.passed} |`) + lines.push(`| Skip | ${tests.skipped} |`) + lines.push(`| Total | ${tests.numTotal || tests.passed + tests.skipped + tests.failed.length} |`) + lines.push('') + + if (tests.slow.length) { + lines.push('
Slowest tests') + lines.push('') + lines.push( + ...mdTable( + ['ms', 'Test', 'File'], + tests.slow.map((s) => [ + String(Math.round(s.dur)), + `\`${s.title.replaceAll('|', '\\|')}\``, + `\`${s.file}\``, + ]), + ), + ) + lines.push('') + lines.push('
') + lines.push('') + } + + const { files, total } = coverage + lines.push('## Frontend coverage') + lines.push('') + if (!total && files.length === 0) { + lines.push('_No coverage summary was produced for this run._') + lines.push('') + return `${lines.join('\n').replace(/\n+$/, '')}\n` + } + if (total) { + lines.push('| Metric | Covered | Total | % |') + lines.push('| --- | ---: | ---: | ---: |') + for (const k of ['statements', 'branches', 'functions', 'lines']) { + const m = total[k] || { covered: 0, total: 0, pct: 0 } + lines.push(`| ${k} | ${m.covered} | ${m.total} | ${fmtPct(m.pct)} |`) + } + lines.push('') + } + lines.push( + 'Lowest files first. Browsable HTML is on the **frontend-coverage** artifact (`coverage/index.html`).', + ) + lines.push('') + + const bandCounts = new Map() + const areas = new Map() + for (const f of files) { + const p = Number(f.lines.pct) || pct(f.lines.covered, f.lines.total) + const b = band(p) + const cur = bandCounts.get(b) || { files: 0, lines: 0 } + cur.files += 1 + cur.lines += f.lines.total || 0 + bandCounts.set(b, cur) + const area = areaOf(f.file) + const a = areas.get(area) || { covered: 0, total: 0 } + a.covered += f.lines.covered || 0 + a.total += f.lines.total || 0 + areas.set(area, a) + } + + const bandOrder = ['0%', '1–49%', '50–79%', '80–99%', '100%'] + const bandRows = bandOrder + .filter((b) => bandCounts.has(b)) + .map((b) => [b, String(bandCounts.get(b).files), String(bandCounts.get(b).lines)]) + if (bandRows.length) { + lines.push('### File coverage bands') + lines.push('') + lines.push(...mdTable(['Band', 'Files', 'Lines'], bandRows)) + lines.push('') + } + + const areaRows = [...areas.entries()] + .map(([name, v]) => ({ + name, + pct: pct(v.covered, v.total), + covered: v.covered, + total: v.total, + missed: v.total - v.covered, + })) + .sort((a, b) => a.pct - b.pct || b.missed - a.missed) + if (areaRows.length) { + lines.push('### Coverage by area') + lines.push('') + lines.push( + ...mdTable( + ['Area', 'Covered', 'Hit', 'Lines', 'Missed'], + areaRows.map((a) => [ + `\`${a.name}\``, + fmtPct(a.pct), + String(a.covered), + String(a.total), + String(a.missed), + ]), + ), + ) + lines.push('') + } + + const lowest = [...files] + .filter((f) => (f.lines.total || 0) > 0 && Number(f.lines.pct) < 100) + .sort((a, b) => { + const pa = Number(a.lines.pct) + const pb = Number(b.lines.pct) + const ma = (a.lines.total || 0) - (a.lines.covered || 0) + const mb = (b.lines.total || 0) - (b.lines.covered || 0) + return pa - pb || mb - ma + }) + .slice(0, 40) + if (lowest.length) { + lines.push('### Lowest files (actionable)') + lines.push('') + lines.push( + ...mdTable( + ['Lines', 'Branches', 'Missed', 'File'], + lowest.map((f) => [ + fmtPct(f.lines.pct), + fmtPct(f.branches.pct), + String((f.lines.total || 0) - (f.lines.covered || 0)), + `\`${f.file}\``, + ]), + ), + ) + lines.push('') + } + + const zero = files.filter((f) => (f.lines.total || 0) > 0 && (f.lines.covered || 0) === 0) + if (zero.length) { + lines.push(`
Zero-coverage files (${zero.length})`) + lines.push('') + for (const f of zero.slice(0, 80)) lines.push(`- \`${f.file}\``) + if (zero.length > 80) lines.push(`- …and ${zero.length - 80} more`) + lines.push('') + lines.push('
') + lines.push('') + } + + return `${lines.join('\n').replace(/\n+$/, '')}\n` +} + +function selfTest() { + const tests = collectVitest({ + numTotalTests: 3, + success: false, + testResults: [ + { + name: '/repo/web/src/components/ai/Foo.test.tsx', + assertionResults: [ + { + fullName: 'Foo fails loudly', + status: 'failed', + duration: 12, + failureMessages: ['expected true to be false'], + }, + { fullName: 'Foo ok', status: 'passed', duration: 4 }, + ], + }, + ], + }) + const coverage = collectCoverage({ + total: { + statements: { covered: 8, total: 10, pct: 80 }, + branches: { covered: 3, total: 5, pct: 60 }, + functions: { covered: 2, total: 2, pct: 100 }, + lines: { covered: 8, total: 10, pct: 80 }, + }, + '/repo/web/src/features/driving/pages/DrivePage.tsx': { + lines: { covered: 1, total: 10, pct: 10 }, + statements: { covered: 1, total: 10, pct: 10 }, + functions: { covered: 0, total: 2, pct: 0 }, + branches: { covered: 0, total: 4, pct: 0 }, + }, + '/repo/web/src/lib/cn.ts': { + lines: { covered: 7, total: 7, pct: 100 }, + statements: { covered: 7, total: 7, pct: 100 }, + functions: { covered: 1, total: 1, pct: 100 }, + branches: { covered: 2, total: 2, pct: 100 }, + }, + }) + const md = render({ tests, coverage }) + if (!md.includes('Failures — start here')) throw new Error('missing failures heading') + if (!md.includes('Foo fails loudly')) throw new Error('missing failed test') + if (!md.includes('features/driving')) throw new Error('missing area rollup') + if (!md.includes('Lowest files')) throw new Error('missing lowest files') + console.log('frontend_coverage_summary self-test OK') +} + +function parseArgs(argv) { + const out = { + summaryJson: '', + vitestJson: '', + stepSummary: '', + markdownOut: '', + selfTest: false, + } + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i] + const next = argv[i + 1] + if (a === '--self-test') out.selfTest = true + else if (a === '--summary-json' && next) { + out.summaryJson = next + i += 1 + } else if (a === '--vitest-json' && next) { + out.vitestJson = next + i += 1 + } else if (a === '--step-summary' && next) { + out.stepSummary = next + i += 1 + } else if (a === '--markdown-out' && next) { + out.markdownOut = next + i += 1 + } + } + return out +} + +function main(argv) { + const args = parseArgs(argv) + if (args.selfTest) { + selfTest() + return 0 + } + const tests = collectVitest(readJson(args.vitestJson)) + const coverage = collectCoverage(readJson(args.summaryJson)) + const md = render({ tests, coverage }) + if (args.markdownOut) fs.writeFileSync(args.markdownOut, md) + if (args.stepSummary) fs.appendFileSync(args.stepSummary, md) + if (!args.markdownOut && !args.stepSummary) process.stdout.write(md) + return 0 +} + +const isMain = + Boolean(process.argv[1]) && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) +if (isMain) { + process.exit(main(process.argv.slice(2))) +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec8439fe6..2fbc1f888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,44 +114,26 @@ jobs: DATABASE_USER: test DATABASE_PASS: test DATABASE_NAME: teslasync_test - run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + run: | + set -o pipefail + go test -race -coverprofile=coverage.out -covermode=atomic -json ./... | tee test-events.json - name: Generate backend coverage reports - if: always() && hashFiles('coverage.out') != '' + if: always() && (hashFiles('coverage.out') != '' || hashFiles('test-events.json') != '') run: | - go tool cover -func=coverage.out > coverage.txt - go tool cover -html=coverage.out -o coverage.html - # Per-package roll-up (statement coverage averaged across the package's funcs) - awk ' - /^total:/ { next } - { - n = split($1, parts, "/") - file = parts[n]; sub(/:.*$/, "", file) - pkg = parts[1] - for (i = 2; i < n; i++) pkg = pkg "/" parts[i] - gsub("%", "", $3) - sum[pkg] += $3 - cnt[pkg]++ - } - END { - for (p in sum) printf "%6.2f%% %s\n", sum[p]/cnt[p], p - } - ' coverage.txt | sort -nr > coverage-by-package.txt - TOTAL_LINE=$(grep '^total:' coverage.txt || echo "total: (statements) 0.0%") - { - echo "## 📊 Backend Coverage" - echo "" - echo "**$TOTAL_LINE**" - echo "" - echo "Full HTML + per-function reports are attached to this run as the **backend-coverage** artifact." - echo "" - echo "
Per-package coverage (top 50)" - echo "" - echo '```' - head -n 50 coverage-by-package.txt - echo '```' - echo "
" - } >> "$GITHUB_STEP_SUMMARY" + python3 .github/scripts/backend_coverage_summary.py --self-test + if [ -f coverage.out ]; then + go tool cover -func=coverage.out > coverage.txt + go tool cover -html=coverage.out -o coverage.html + fi + python3 .github/scripts/backend_coverage_summary.py \ + --profile coverage.out \ + --func coverage.txt \ + --json test-events.json \ + --summary "$GITHUB_STEP_SUMMARY" \ + --markdown-out coverage-summary.md \ + --package-out coverage-by-package.txt \ + --file-out coverage-by-file.txt - name: Architecture test (no forbidden import edges) run: | @@ -213,6 +195,9 @@ jobs: coverage.html coverage.txt coverage-by-package.txt + coverage-by-file.txt + coverage-summary.md + test-events.json frontend: name: Frontend (lint + test + build) @@ -245,29 +230,19 @@ jobs: # doing this after an upgrade; this boots the real runtime. run: npm run check:vite-deprecations - name: Test - run: npx vitest run --coverage --reporter=verbose + run: | + mkdir -p coverage + npx vitest run --coverage --reporter=verbose --reporter=json --outputFile=coverage/vitest-results.json - name: Generate frontend coverage report - if: always() && hashFiles('web/coverage/coverage-summary.json') != '' + if: always() && (hashFiles('web/coverage/coverage-summary.json') != '' || hashFiles('web/coverage/vitest-results.json') != '') run: | - node -e ' - const fs = require("fs"); - const path = "coverage/coverage-summary.json"; - if (!fs.existsSync(path)) { process.exit(0); } - const t = JSON.parse(fs.readFileSync(path, "utf8")).total; - const out = []; - out.push("## 📊 Frontend Coverage"); - out.push(""); - out.push("| Metric | Covered | Total | % |"); - out.push("|--------|--------:|------:|--:|"); - for (const k of ["statements","branches","functions","lines"]) { - const m = t[k]; - out.push(`| ${k} | ${m.covered} | ${m.total} | ${m.pct}% |`); - } - out.push(""); - out.push("Browsable HTML report attached as the **frontend-coverage** artifact (open `coverage/index.html`)."); - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, out.join("\n") + "\n"); - ' + node ../.github/scripts/frontend_coverage_summary.mjs --self-test + node ../.github/scripts/frontend_coverage_summary.mjs \ + --summary-json coverage/coverage-summary.json \ + --vitest-json coverage/vitest-results.json \ + --step-summary "$GITHUB_STEP_SUMMARY" \ + --markdown-out coverage/job-summary.md - name: Upload coverage if: always() From 492c9c9159cdd41c5c6f34c4970ff305c9a59008 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Fri, 18 Sep 2026 11:56:59 -0700 Subject: [PATCH 2/2] docs: add GitHub Actions workflow badges to README Surface CI, SI, ops, security, frontend quality, Helm, ai-eval, release, and docs status on main so a red workflow is visible without opening Actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 357a3b451..da4883bde 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ React and TypeScript frontend

+

+ CI + SI canonical gate + Ops gate + Security + Frontend quality gates + Helm Chart CI + ai-eval + Release + Docs +

+ --- ## More than a snapshot of your car