init commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
target
|
||||
.cpcache
|
||||
.git
|
||||
@@ -0,0 +1,3 @@
|
||||
.cpcache
|
||||
target
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FROM clojure:openjdk-11-tools-deps
|
||||
|
||||
RUN sed -i \
|
||||
-e 's|deb.debian.org/debian|archive.debian.org/debian|g' \
|
||||
-e '/security.debian.org/d' \
|
||||
/etc/apt/sources.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Aliases whose deps get pre-fetched into the image; the coverage compose override adds `cov`.
|
||||
ARG DEPS_ALIASES=run
|
||||
|
||||
COPY deps.edn ./
|
||||
RUN clojure -P -X:${DEPS_ALIASES} \
|
||||
&& echo "[build] cached jars: $(find /root/.m2 -name '*.jar' | wc -l)"
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["clojure", "-X:run"]
|
||||
@@ -0,0 +1,27 @@
|
||||
{:paths ["integration-tests" "scripts"]
|
||||
:override-builtin true
|
||||
:tasks
|
||||
{:requires ([integration-tasks :as it])
|
||||
unit-test {:doc "Run the Clojure unit tests (test/ directory)."
|
||||
:task (shell "clojure" "-M:test")}
|
||||
test {:doc "Run the unit tests, then the integration tests (stops at the first failure)."
|
||||
:task (do (run 'unit-test)
|
||||
(run 'integration-test))}
|
||||
repl {:doc "Start an nREPL server with dev dependencies"
|
||||
:task (shell "clojure" "-A:dev" "-M" "-m" "nrepl.cmdline"
|
||||
"--middleware" "[cider.nrepl/cider-middleware]"
|
||||
"--port" (try (slurp ".nrepl-port") (catch Exception _ "3000")))}
|
||||
integration-test {:doc "Run the dockerized integration tests in isolation (builds the app image, starts it on a random port, runs the test suite against it, then tears everything down)."
|
||||
:task (it/run-integration {})}
|
||||
unit-coverage {:doc "Run the unit tests under Cloverage; reports go to target/coverage/unit."
|
||||
:task (shell "clojure" "-M:cov" "-m" "cloverage.coverage"
|
||||
"--src-ns-path" "src" "--test-ns-path" "test"
|
||||
"--lcov" "--html" "--output" "target/coverage/unit")}
|
||||
integration-coverage {:doc "Run the dockerized integration tests against a Cloverage-instrumented app; reports go to target/coverage/integration."
|
||||
:task (it/run-integration {:coverage? true})}
|
||||
coverage-report {:doc "Merge the unit and integration line-coverage results into target/coverage/merged and print a summary."
|
||||
:task (shell "bb" "scripts/coverage_report.clj")}
|
||||
coverage {:doc "Run the full suite (unit + integration) with line coverage and write the joint report to target/coverage/merged."
|
||||
:task (do (run 'unit-coverage)
|
||||
(run 'integration-coverage)
|
||||
(run 'coverage-report))}}}
|
||||
@@ -0,0 +1,2 @@
|
||||
[worker.oci]
|
||||
apparmor-profile = ""
|
||||
@@ -0,0 +1,23 @@
|
||||
{:paths ["src" "resources" "integration-tests"]
|
||||
:deps
|
||||
{org.clojure/clojure {:mvn/version "1.12.0"}
|
||||
ring/ring-jetty-adapter {:mvn/version "1.12.2"}
|
||||
ring/ring-defaults {:mvn/version "0.4.0"}
|
||||
metosin/reitit {:mvn/version "0.7.2"}}
|
||||
|
||||
:aliases
|
||||
{:dev
|
||||
{:extra-deps
|
||||
{nrepl/nrepl {:mvn/version "1.2.0"}
|
||||
cider/cider-nrepl {:mvn/version "0.48.0"}
|
||||
org.babashka/http-client {:mvn/version "0.4.25"}}}
|
||||
:test
|
||||
{:extra-paths ["test"]
|
||||
:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}}
|
||||
:main-opts ["-m" "cognitect.test-runner"]}
|
||||
;; Line coverage via Cloverage. Only adds the dep + test path; the entry points
|
||||
;; (unit: cloverage.coverage, integration: vibe.integration.coverage) are chosen in bb.edn.
|
||||
:cov
|
||||
{:extra-paths ["test"]
|
||||
:extra-deps {cloverage/cloverage {:mvn/version "1.2.4"}}}
|
||||
:run {:exec-fn vibe.core/start-server}}}
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM babashka/babashka:latest
|
||||
|
||||
COPY integration_test.bb /integration_test.bb
|
||||
|
||||
ENTRYPOINT ["bb", "/integration_test.bb"]
|
||||
@@ -0,0 +1,80 @@
|
||||
# Integration Tests
|
||||
|
||||
Dockerized integration tests for the vibe server.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run the full test suite (builds the app, starts it, runs tests):
|
||||
|
||||
```bash
|
||||
cd integration-tests
|
||||
docker-compose up --abort-on-container-exit
|
||||
```
|
||||
|
||||
## Manual Run (app already running)
|
||||
|
||||
If your vibe app is running locally on port 3000:
|
||||
|
||||
```bash
|
||||
cd integration-tests
|
||||
docker run --rm -v "$(pwd)/integration_test.bb:/integration_test.bb:ro" babashka/babashka:latest bb /integration_test.bb http://host.docker.internal:3000
|
||||
```
|
||||
|
||||
Or run directly on a host with babashka installed:
|
||||
|
||||
```bash
|
||||
cd integration-tests
|
||||
bb integration_test.bb http://localhost:3000
|
||||
```
|
||||
|
||||
Or from the repo root, via the `bb.edn` task:
|
||||
|
||||
```bash
|
||||
bb integration-test
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|--------------|
|
||||
| `[base-url]` (positional) | `http://localhost:3000` | Server to test against |
|
||||
| `--watch` | off | Re-run the suite on a timer instead of exiting after one run |
|
||||
| `--interval <ms>` | `5000` | Delay between runs in watch mode |
|
||||
|
||||
Example — watch a local server every 2 seconds:
|
||||
|
||||
```bash
|
||||
bb integration-test http://localhost:3000 --watch --interval 2000
|
||||
```
|
||||
|
||||
## What's Tested
|
||||
|
||||
| # | Test | Checks |
|
||||
|---|------|--------|
|
||||
| 1 | `GET /` returns 200 | HTTP status code |
|
||||
| 2 | `Content-Type` header | `text/plain` |
|
||||
| 3 | Response body | Contains expected Clojure/Ring content |
|
||||
| 4 | Not HTML | Confirms plain text response |
|
||||
| 5 | `HEAD /` works | HTTP 200 |
|
||||
|
||||
## Line Coverage
|
||||
|
||||
Line coverage is measured with [Cloverage](https://github.com/cloverage/cloverage) for the
|
||||
unit tests, the integration tests, and both combined. From the repo root:
|
||||
|
||||
| Task | What it does | Output |
|
||||
|------|--------------|--------|
|
||||
| `bb unit-coverage` | Runs `test/` under Cloverage | `target/coverage/unit/` |
|
||||
| `bb integration-coverage` | Runs the integration suite against a Cloverage-instrumented `vibe-app` container | `target/coverage/integration/` |
|
||||
| `bb coverage-report` | Merges the two `lcov.info` files, prints a per-file table | `target/coverage/merged/` |
|
||||
| `bb coverage` | All three, in order (the full suite with a joint report) | all of the above |
|
||||
|
||||
Each directory contains an `index.html` (open it in a browser) and an `lcov.info`. In the
|
||||
merged report a line counts as covered if either suite hit it; the HTML shows per-line hit
|
||||
counts per suite (`u:` unit, `i:` integration).
|
||||
|
||||
How the integration numbers are collected: `docker-compose.coverage.yml` starts the app via
|
||||
`vibe.integration.coverage`, which lets Cloverage instrument `src/` and then serves real
|
||||
traffic until it receives SIGTERM. `bb integration-coverage` stops the container
|
||||
(`docker compose stop`) so the report gets written, then copies it out with `docker compose cp`.
|
||||
Plain `bb integration-test` / `bb test` are unchanged and run the uninstrumented app.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Overlay for `bb integration-coverage`: runs vibe-app under Cloverage instead of plainly.
|
||||
# Reports land in /app/target/coverage/integration inside the container; bb.edn copies
|
||||
# them out after the app is stopped (SIGTERM is what triggers the report).
|
||||
services:
|
||||
vibe-app:
|
||||
build:
|
||||
args:
|
||||
DEPS_ALIASES: run:cov
|
||||
command:
|
||||
- clojure
|
||||
- -M:cov
|
||||
- -m
|
||||
- vibe.integration.coverage
|
||||
- --runner
|
||||
- integration
|
||||
- --src-ns-path
|
||||
- src
|
||||
- --extra-test-ns
|
||||
- vibe.integration.coverage
|
||||
- --lcov
|
||||
- --html
|
||||
- --output
|
||||
- /app/target/coverage/integration
|
||||
stop_grace_period: 2m
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
vibe-app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${VIBE_PORT:-3000}:${VIBE_PORT:-3000}"
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
environment:
|
||||
- PORT=${VIBE_PORT:-3000}
|
||||
# No container healthcheck: healthchecks run via `docker exec`, which hits the
|
||||
# same LXC/AppArmor runc bug (opencontainers/runc#5007) as image builds and
|
||||
# always fails here. integration_test.bb polls the server until it's ready.
|
||||
|
||||
integration-test:
|
||||
image: babashka/babashka:latest
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
volumes:
|
||||
- ./integration_test.bb:/integration_test.bb:ro
|
||||
depends_on:
|
||||
vibe-app:
|
||||
condition: service_started
|
||||
entrypoint: ["bb", "/integration_test.bb", "http://vibe-app:${VIBE_PORT:-3000}"]
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns integration-test
|
||||
(:require [babashka.http-client :as http]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def default-config
|
||||
{:base-url "http://localhost:3000"
|
||||
:max-retries 30
|
||||
:retry-delay-ms 1000
|
||||
:watch? false
|
||||
:watch-interval-ms 5000})
|
||||
|
||||
(defn get* [base-url]
|
||||
(http/get base-url {:throw false}))
|
||||
|
||||
(defn head* [base-url]
|
||||
(http/head base-url {:throw false}))
|
||||
|
||||
(defn wait-for-server [{:keys [base-url max-retries retry-delay-ms]}]
|
||||
(println (str "=== Waiting for server at " base-url " ==="))
|
||||
(loop [i 1]
|
||||
(let [ready? (try (= 200 (:status (get* base-url))) (catch Exception _ false))]
|
||||
(cond
|
||||
ready? (do (println "Server is ready!") true)
|
||||
(= i max-retries) (do (println (format "ERROR: Server did not become ready after %d attempts" max-retries))
|
||||
false)
|
||||
:else (do (Thread/sleep retry-delay-ms) (recur (inc i)))))))
|
||||
|
||||
(defn run-suite
|
||||
"Runs the integration suite once against config's :base-url.
|
||||
Returns {:pass n :fail n :errors [...]}"
|
||||
[{:keys [base-url] :as config}]
|
||||
(if-not (wait-for-server config)
|
||||
{:pass 0 :fail 1 :errors ["server did not become ready"]}
|
||||
(let [pass (atom 0)
|
||||
fail (atom 0)
|
||||
errors (atom [])
|
||||
assert-eq (fn [label expected actual]
|
||||
(if (= expected actual)
|
||||
(do (println " ✓" label) (swap! pass inc))
|
||||
(do (println " ✗" label)
|
||||
(println " expected:" expected)
|
||||
(println " actual: " actual)
|
||||
(swap! fail inc)
|
||||
(swap! errors conj (format "%s: expected '%s' but got '%s'" label expected actual)))))
|
||||
assert-contains (fn [label needle haystack]
|
||||
(if (str/includes? haystack needle)
|
||||
(do (println " ✓" label) (swap! pass inc))
|
||||
(do (println " ✗" label)
|
||||
(println " expected to contain:" needle)
|
||||
(println " actual: " haystack)
|
||||
(swap! fail inc)
|
||||
(swap! errors conj (format "%s: did not find '%s'" label needle)))))
|
||||
assert-not-empty (fn [label value]
|
||||
(if-not (str/blank? value)
|
||||
(do (println " ✓" label) (swap! pass inc))
|
||||
(do (println " ✗" label)
|
||||
(println " expected non-empty value")
|
||||
(swap! fail inc)
|
||||
(swap! errors conj (format "%s: value was empty" label)))))]
|
||||
(println)
|
||||
(println "=== Running Integration Tests ===")
|
||||
(println)
|
||||
|
||||
(println "Test 1: GET / returns 200")
|
||||
(assert-eq "HTTP status" 200 (:status (get* base-url)))
|
||||
(println)
|
||||
|
||||
(println "Test 2: Content-Type is text/plain")
|
||||
(assert-contains "Content-Type" "text/plain" (get (:headers (get* base-url)) "content-type" ""))
|
||||
(println)
|
||||
|
||||
(println "Test 3: Root response points at the API docs")
|
||||
(let [body (:body (get* base-url))]
|
||||
(assert-contains "Body mentions /api-docs" "/api-docs" body)
|
||||
(assert-not-empty "Body is non-empty" body)
|
||||
(println)
|
||||
|
||||
(println "Test 4: Response is plain text, not HTML")
|
||||
(if (and (not (str/includes? body "<!DOCTYPE"))
|
||||
(not (str/includes? body "<html")))
|
||||
(do (println " ✓ Response is not HTML") (swap! pass inc))
|
||||
(do (println " ✗ Response appears to be HTML")
|
||||
(swap! fail inc)
|
||||
(swap! errors conj "Response was HTML, expected plain text"))))
|
||||
(println)
|
||||
|
||||
(println "Test 5: HEAD / returns 200")
|
||||
(assert-eq "HEAD status" 200 (:status (head* base-url)))
|
||||
(println)
|
||||
|
||||
(println "Test 6: GET /api/ping returns 200 with body 'pong'")
|
||||
(let [resp (get* (str base-url "/api/ping"))]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-eq "Body" "pong" (:body resp)))
|
||||
(println)
|
||||
|
||||
(println "Test 7: GET /swagger.json returns a valid-looking spec")
|
||||
(let [resp (get* (str base-url "/swagger.json"))
|
||||
body (:body resp)]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-contains "Content-Type" "application/json" (get (:headers resp) "content-type" ""))
|
||||
(assert-contains "Spec references /api/ping" "/api/ping" body))
|
||||
(println)
|
||||
|
||||
(println "Test 8: GET /api-docs serves the Swagger UI")
|
||||
(let [resp (get* (str base-url "/api-docs"))]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-contains "Content-Type" "text/html" (get (:headers resp) "content-type" "")))
|
||||
(println)
|
||||
|
||||
(println "Test 9: GET /api/add?x=2&y=3 returns {:result 5} as EDN")
|
||||
(let [resp (get* (str base-url "/api/add?x=2&y=3"))]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-contains "Content-Type" "application/edn" (get (:headers resp) "content-type" ""))
|
||||
(assert-eq "Body" {:result 5} (edn/read-string (:body resp))))
|
||||
(println)
|
||||
|
||||
(println "Test 10: GET /api/add with a missing or non-numeric param returns 400")
|
||||
(assert-eq "Missing y" 400 (:status (get* (str base-url "/api/add?x=2"))))
|
||||
(assert-eq "Non-numeric x" 400 (:status (get* (str base-url "/api/add?x=abc&y=3"))))
|
||||
(println)
|
||||
|
||||
(println "================================")
|
||||
(println (format " Results: %d passed, %d failed" @pass @fail))
|
||||
(println "================================")
|
||||
|
||||
(if (pos? @fail)
|
||||
(do (println) (println "Failures:") (doseq [e @errors] (println (str " - " e))))
|
||||
(do (println) (println "All integration tests passed! ✓")))
|
||||
|
||||
{:pass @pass :fail @fail :errors @errors})))
|
||||
|
||||
(defn parse-args
|
||||
"Parses CLI args into a config override map.
|
||||
Supported: [base-url] [--watch] [--interval <ms>]"
|
||||
[args]
|
||||
(loop [args args
|
||||
overrides {}]
|
||||
(if (empty? args)
|
||||
overrides
|
||||
(let [[arg & more] args]
|
||||
(cond
|
||||
(= arg "--watch") (recur more (assoc overrides :watch? true))
|
||||
(= arg "--interval") (recur (rest more) (assoc overrides :watch-interval-ms (parse-long (first more))))
|
||||
:else (recur more (assoc overrides :base-url arg)))))))
|
||||
|
||||
(defn -main [& args]
|
||||
(let [config (merge default-config (parse-args args))
|
||||
{:keys [watch?]} config]
|
||||
(if watch?
|
||||
(do
|
||||
(println "Watch mode: watching for file changes. Press Ctrl+C to stop.")
|
||||
(println "Watching: integration_test.bb")
|
||||
(println)
|
||||
;; Run once, then wait for inotify events
|
||||
(run-suite config)
|
||||
(println)
|
||||
(println "Waiting for file changes...")
|
||||
(println)
|
||||
;; Use inotifywait to watch the file for modifications
|
||||
(let [proc (Runtime/exec (into-array String ["inotifywait" "-e" "modify" "-q" "integration_test.bb"]))]
|
||||
(try
|
||||
(.waitFor proc)
|
||||
(finally
|
||||
(.destroy proc)))
|
||||
(recur args)))
|
||||
(let [{:keys [fail]} (run-suite config)]
|
||||
(System/exit (if (pos? fail) 1 0))))))
|
||||
|
||||
|
||||
(comment
|
||||
(run-suite {:base-url "http://localhost:3000"}))
|
||||
|
||||
;; bb runs this file as a script, not via -m — nothing calls -main otherwise.
|
||||
(apply -main *command-line-args*)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
(ns vibe.integration.coverage
|
||||
"Entry point that runs the vibe server under Cloverage instrumentation so the
|
||||
black-box integration tests produce a line-coverage report.
|
||||
|
||||
Cloverage normally instruments the source and then runs a test suite in-process.
|
||||
Here the 'suite' is the outside world: the runner starts the real server and blocks
|
||||
until the JVM gets SIGTERM (`docker compose stop`), then Cloverage writes its reports.
|
||||
|
||||
Deliberately does not require any vibe.* source namespace at load time: those must
|
||||
be loaded by Cloverage (instrumented), not by us."
|
||||
(:require [cloverage.coverage :as cov]))
|
||||
|
||||
(defmethod cov/runner-fn :integration [_]
|
||||
(fn [_nses]
|
||||
(let [stop (promise)
|
||||
main (Thread/currentThread)]
|
||||
((requiring-resolve 'vibe.core/start-server))
|
||||
;; The report is written by the main thread after the runner returns, but the JVM
|
||||
;; only stays up while shutdown hooks run, so the hook must wait for the main thread.
|
||||
(.addShutdownHook (Runtime/getRuntime)
|
||||
(Thread. (fn []
|
||||
(deliver stop true)
|
||||
(.join main))))
|
||||
@stop
|
||||
{:errors 0})))
|
||||
|
||||
(defn -main [& args]
|
||||
;; Cloverage's own System/exit would deadlock inside the shutdown hook above.
|
||||
(binding [cov/*exit-after-test* false]
|
||||
(apply cov/-main args))
|
||||
(shutdown-agents))
|
||||
@@ -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 "&" "&") (str/replace "<" "<") (str/replace ">" ">")))
|
||||
|
||||
(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*)
|
||||
@@ -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))))
|
||||
@@ -0,0 +1,60 @@
|
||||
(ns vibe.core
|
||||
(:require [ring.adapter.jetty :as jetty]
|
||||
[ring.middleware.defaults :refer [wrap-defaults api-defaults]]
|
||||
[ring.middleware.head :refer [wrap-head]]
|
||||
[reitit.ring :as ring]
|
||||
[reitit.swagger :as swagger]
|
||||
[reitit.swagger-ui :as swagger-ui]
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja-middleware]
|
||||
[muuntaja.core :as m]
|
||||
[vibe.health.routes :as health]
|
||||
[vibe.math.routes :as math]))
|
||||
|
||||
;; Each logical group (health, users, orders, ...) owns a `vibe.<group>.routes`
|
||||
;; namespace that returns its own route subtree; they're concatenated here.
|
||||
(def router
|
||||
(ring/router
|
||||
[["/swagger.json"
|
||||
{:get {:no-doc true
|
||||
:swagger {:info {:title "vibe API" :version "1.0.0"}}
|
||||
:handler (swagger/create-swagger-handler)}}]
|
||||
["/" {:get {:no-doc true
|
||||
:handler (fn [_req]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/plain; charset=utf-8"}
|
||||
:body "vibe API — see /api-docs for documentation, /swagger.json for the spec"})}}]
|
||||
(health/routes)
|
||||
(math/routes)]
|
||||
{:data {:muuntaja m/instance
|
||||
:middleware [muuntaja-middleware/format-middleware]}}))
|
||||
|
||||
(def app
|
||||
(-> (ring/ring-handler
|
||||
router
|
||||
(ring/routes
|
||||
(swagger-ui/create-swagger-ui-handler {:path "/api-docs"})
|
||||
(ring/create-default-handler)))
|
||||
wrap-head))
|
||||
|
||||
(defn add-shutdown-hook [server]
|
||||
(.addShutdownHook
|
||||
(Runtime/getRuntime)
|
||||
(Thread. (fn [] (.stop server 1000)))))
|
||||
|
||||
(defn start-server [& [_ opts]]
|
||||
(let [port (Integer/parseInt (str (or (System/getenv "PORT")
|
||||
(get opts :port 3000))))]
|
||||
(println (format "[vibe] starting server on port %d..." port))
|
||||
(try
|
||||
(let [server (jetty/run-jetty (wrap-defaults app api-defaults)
|
||||
{:port port
|
||||
:join? false})]
|
||||
(println (format "vibe server running on http://localhost:%d" port))
|
||||
(add-shutdown-hook server))
|
||||
(catch Throwable t
|
||||
(println "[vibe] FAILED to start server:" (.getMessage t))
|
||||
(.printStackTrace t)
|
||||
(throw t)))))
|
||||
|
||||
(defn -main [& [_ port]]
|
||||
(start-server))
|
||||
@@ -0,0 +1,10 @@
|
||||
(ns vibe.health.routes)
|
||||
|
||||
(defn routes []
|
||||
["/api/ping"
|
||||
{:get {:tags ["health"]
|
||||
:summary "Ping the server"
|
||||
:handler (fn [_req]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/plain; charset=utf-8"}
|
||||
:body "pong"})}}])
|
||||
@@ -0,0 +1,5 @@
|
||||
(ns vibe.math.core
|
||||
"Business logic for the math group. No HTTP concerns in here.")
|
||||
|
||||
(defn add [x y]
|
||||
(+ x y))
|
||||
@@ -0,0 +1,24 @@
|
||||
(ns vibe.math.routes
|
||||
(:require [vibe.math.core :as math]))
|
||||
|
||||
(defn- parse-num [s]
|
||||
(when s
|
||||
(or (parse-long s) (parse-double s))))
|
||||
|
||||
(defn- edn-response [status body]
|
||||
{:status status
|
||||
:headers {"Content-Type" "application/edn; charset=utf-8"}
|
||||
:body (pr-str body)})
|
||||
|
||||
(defn- add-handler [{:keys [query-params]}]
|
||||
(let [x (parse-num (get query-params "x"))
|
||||
y (parse-num (get query-params "y"))]
|
||||
(if (and x y)
|
||||
(edn-response 200 {:result (math/add x y)})
|
||||
(edn-response 400 {:error "x and y query params are required and must be numbers"}))))
|
||||
|
||||
(defn routes []
|
||||
["/api/add"
|
||||
{:get {:tags ["math"]
|
||||
:summary "Add two numbers (?x=2&y=3 => {:result 5})"
|
||||
:handler add-handler}}])
|
||||
@@ -0,0 +1,15 @@
|
||||
(ns vibe.math.core-test
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[vibe.math.core :as math]))
|
||||
|
||||
(deftest add-test
|
||||
(testing "integers"
|
||||
(is (= 5 (math/add 2 3)))
|
||||
(is (= 0 (math/add 0 0))))
|
||||
(testing "negatives"
|
||||
(is (= -1 (math/add 2 -3)))
|
||||
(is (= -5 (math/add -2 -3))))
|
||||
(testing "decimals"
|
||||
(is (= 3.5 (math/add 1.5 2))))
|
||||
(testing "is commutative"
|
||||
(is (= (math/add 4 7) (math/add 7 4)))))
|
||||
Reference in New Issue
Block a user