init commit

This commit is contained in:
2026-09-19 05:42:17 -09:00
commit b57e1a406d
19 changed files with 754 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bb
;; Merges the unit and integration LCOV files into one line-coverage report.
;;
;; bb scripts/coverage_report.clj [--out target/coverage/merged]
;;
;; Reads target/coverage/{unit,integration}/lcov.info (either may be missing), prints a
;; per-file table (unit / integration / merged) and writes the merged result to
;; <out>/lcov.info and <out>/index.html. A line counts as covered in the merged report
;; if either suite hit it.
(ns coverage-report
(:require [babashka.cli :as cli]
[clojure.java.io :as io]
[clojure.string :as str]))
(def suites
[[:unit "target/coverage/unit/lcov.info"]
[:integration "target/coverage/integration/lcov.info"]])
(defn parse-lcov
"lcov.info text -> {file {line hits}}"
[text]
(loop [[l & more] (str/split-lines text), file nil, acc {}]
(cond
(nil? l) acc
(str/starts-with? l "SF:") (recur more (subs l 3) (update acc (subs l 3) #(or % {})))
(str/starts-with? l "DA:") (let [[ln hits] (map parse-long (str/split (subs l 3) #","))]
(recur more file (update-in acc [file ln] (fnil + 0) hits)))
:else (recur more file acc))))
(defn stats [lines]
(let [total (count lines)
hit (count (filter pos? (vals lines)))]
{:hit hit :total total :pct (if (zero? total) 100.0 (* 100.0 (/ hit total)))}))
(defn fmt [{:keys [hit total pct]}]
(format "%3d/%-3d %6.1f%%" hit total pct))
(defn read-source [file]
;; Cloverage's SF: paths are usually project-relative already (src/vibe/core.clj); fall
;; back to the source roots in case they are classpath-relative.
(some (fn [dir] (let [f (io/file dir file)] (when (.isFile f) (slurp f))))
[nil "src" "test" "integration-tests"]))
(defn esc [s]
(-> s (str/replace "&" "&amp;") (str/replace "<" "&lt;") (str/replace ">" "&gt;")))
(defn html-report [per-suite merged]
(let [pct-class #(cond (>= % 80) "hi" (>= % 50) "mid" :else "lo")
row (fn [label lines]
(let [s (stats lines)]
(format "<td class=\"%s\">%.1f%% <small>(%d/%d)</small></td>"
(pct-class (:pct s)) (:pct s) (:hit s) (:total s))))
files (sort (keys merged))]
(str "<!doctype html><meta charset=utf-8><title>vibe line coverage</title>
<style>
body{font:14px system-ui;margin:2em;color:#222}table{border-collapse:collapse}
td,th{padding:4px 12px;border-bottom:1px solid #ddd;text-align:left}
.hi{color:#0a7d2c}.mid{color:#b36b00}.lo{color:#c0271f}
pre{margin:0;font:12px ui-monospace,monospace}pre span{display:block;white-space:pre}
.c{background:#e3f6e8}.u{background:#fde3e1}.g{display:inline-block;width:9em;color:#888}
details{margin:1em 0}summary{cursor:pointer;font-weight:600}
</style>
<h1>vibe line coverage</h1>
<table><tr><th>File</th>"
(apply str (for [k (keys per-suite)] (str "<th>" (name k) "</th>")))
"<th>merged</th></tr>"
(apply str
(for [f (concat files [nil])]
(str "<tr><td>" (if f (esc f) "<b>Total</b>") "</td>"
(apply str (for [k (keys per-suite)]
(row k (if f (get-in per-suite [k f] {})
(into {} (for [[ff ls] (get per-suite k)
[l h] ls] [[ff l] h]))))))
(row :merged (if f (merged f)
(into {} (for [[ff ls] merged [l h] ls] [[ff l] h]))))
"</tr>")))
"</table><h2>Sources</h2>"
(apply str
(for [f files
:let [lines (merged f)
src (read-source f)]
:when src]
(str "<details><summary>" (esc f) "</summary><pre>"
(apply str
(map-indexed
(fn [i text]
(let [n (inc i) h (get lines n)]
(format "<span class=\"%s\"><span class=g>%4d %s</span>%s</span>"
(cond (nil? h) "" (pos? h) "c" :else "u")
n
(if h
(str/join " " (for [k (keys per-suite)]
(str (subs (name k) 0 1) ":" (get-in per-suite [k f n] 0))))
"")
(esc text))))
(str/split-lines src)))
"</pre></details>"))))))
(defn -main [& args]
(let [{:keys [out]} (cli/parse-opts args {:coerce {:out :string}})
out (or out "target/coverage/merged")
per-suite (into {} (for [[k path] suites
:when (.exists (io/file path))]
[k (parse-lcov (slurp path))]))]
(when (empty? per-suite)
(println "No lcov.info found; run `bb unit-coverage` and/or `bb integration-coverage` first.")
(System/exit 1))
(let [merged (apply merge-with (fn [a b] (merge-with + a b)) (vals per-suite))
cols (keys per-suite)
files (sort (keys merged))
width (apply max 5 (map count files))
hdr (str (format (str "%-" width "s") "File")
(apply str (for [k (concat cols [:merged])] (format " %-16s" (name k)))))]
(println hdr)
(println (apply str (repeat (count hdr) "-")))
(doseq [f files]
(println (str (format (str "%-" width "s") f)
(apply str (for [k cols] (str " " (fmt (stats (get-in per-suite [k f] {}))))))
" " (fmt (stats (merged f))))))
(println (apply str (repeat (count hdr) "-")))
(let [flat (fn [m] (into {} (for [[f ls] m [l h] ls] [[f l] h])))]
(println (str (format (str "%-" width "s") "Total")
(apply str (for [k cols] (str " " (fmt (stats (flat (per-suite k)))))))
" " (fmt (stats (flat merged))))))
(.mkdirs (io/file out))
(spit (io/file out "lcov.info")
(apply str (for [f files]
(str "TN:\nSF:" f "\n"
(apply str (for [[l h] (sort (merged f))] (str "DA:" l "," h "\n")))
"LF:" (count (merged f)) "\nLH:" (:hit (stats (merged f)))
"\nend_of_record\n"))))
(spit (io/file out "index.html") (html-report per-suite merged))
(println (str "\nMerged report: " out "/index.html (+ lcov.info)")))))
(apply -main *command-line-args*)
+79
View File
@@ -0,0 +1,79 @@
(ns integration-tasks
"Shared implementation for the dockerized integration bb tasks (see bb.edn)."
(:require [babashka.process :refer [shell]]))
(defn run-integration
"Builds the app image, starts it on a random port, runs the integration suite against
it, then tears everything down and exits with the suite's exit code.
With :coverage? the app runs under Cloverage (docker-compose.coverage.yml); after the
suite finishes the app is stopped gracefully so it writes its report, which is copied
to target/coverage/integration."
[{:keys [coverage?]}]
(let [free-port-in-range
;; Picks a random port in [lo, hi] that's currently bindable on this
;; host; retries on collision since another process could grab it first.
(fn [lo hi]
(loop [attempts 20]
(let [port (+ lo (rand-int (inc (- hi lo))))]
(if (try
(.close (java.net.ServerSocket. port))
true
(catch java.io.IOException _ false))
port
(if (pos? attempts)
(recur (dec attempts))
(throw (ex-info "no free port found in range" {:lo lo :hi hi})))))))
port (free-port-in-range 30000 40000)
_ (println (format "[test] starting vibe-app on random port %d%s" port
(if coverage? " (under coverage instrumentation)" "")))
builder-name "vibe-builder"
compose (into ["docker" "compose"]
(when coverage?
["-f" "docker-compose.yml" "-f" "docker-compose.coverage.yml"]))
;; Works around an LXC/AppArmor bug in runc (opencontainers/runc#5007) that
;; fails RUN steps during image build; a docker-container builder with
;; apparmor disabled sidesteps it.
_ (let [{:keys [exit]} (shell {:continue true} "docker" "buildx" "inspect" builder-name)]
(when-not (zero? exit)
(shell "docker" "buildx" "create" "--name" builder-name
"--driver" "docker-container"
"--buildkitd-config" "buildkitd.toml")))
_ (shell "docker" "buildx" "use" builder-name)
;; Runs from a JVM shutdown hook (normal exit and Ctrl-C), where
;; babashka.process/shell fails: it registers its own shutdown hook,
;; which the JVM rejects with "Shutdown in progress". Plain
;; ProcessBuilder has no such hook and lets us wait for `down` to finish.
teardown (fn []
(-> (doto (ProcessBuilder. ^java.util.List (vec (concat compose ["down" "-v"])))
(.directory (java.io.File. "integration-tests"))
(.inheritIO))
.start
.waitFor))
_ (.addShutdownHook (Runtime/getRuntime) (Thread. teardown))
{:keys [exit]} (apply shell {:dir "integration-tests" :continue true
:extra-env {"VIBE_PORT" (str port)}}
;; `run` returns the test container's own exit code; `up --abort-on-container-exit`
;; can leak the app's SIGTERM exit (143) during teardown instead.
(concat compose ["--ansi" "never" "--progress" "plain"
"run" "--build" "--rm" "integration-test"]))]
(when coverage?
;; SIGTERM is what makes Cloverage write its report, so stop (not kill) the app
;; and pull the report out of the stopped container before `down` removes it.
(let [out-dir (java.io.File. "target/coverage")
dest (java.io.File. out-dir "integration")]
(println "[test] stopping vibe-app to flush coverage report")
(apply shell {:dir "integration-tests" :continue true}
(concat compose ["stop" "-t" "120" "vibe-app"]))
(when (.exists dest)
(run! (fn [^java.io.File f] (.delete f)) (reverse (file-seq dest))))
(.mkdirs out-dir)
(let [{cp-exit :exit} (apply shell {:dir "integration-tests" :continue true}
(concat compose ["cp" "vibe-app:/app/target/coverage/integration"
(.getAbsolutePath out-dir)]))]
(when-not (and (zero? cp-exit) (.exists (java.io.File. dest "lcov.info")))
(println "[test] WARNING: no integration coverage report was produced")))))
;; Only exit early on failure so chained tasks (`bb coverage`) can carry on after a pass;
;; the teardown hook still runs when bb exits.
(when-not (zero? exit)
(System/exit exit))))