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
+5
View File
@@ -0,0 +1,5 @@
FROM babashka/babashka:latest
COPY integration_test.bb /integration_test.bb
ENTRYPOINT ["bb", "/integration_test.bb"]
+80
View File
@@ -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
+25
View File
@@ -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}"]
+179
View File
@@ -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))