Docs · Guide

Build your own report

ShiftLefter has no reporting plugin API — on purpose. What it has instead is a machine record: the --edn summary stream plus the bytes in the run directory. Those two surfaces are the extension contract. The in-tree JUnit reporter earns its output from the same record your code reads; nothing it does is privileged. This page documents the record and walks a complete, runnable rendering recipe.

The built-in outputs

Console writes human-readable progress and failure detail to stderr, always — it never competes with machine output on stdout.

EDN (--edn) writes the machine record to stdout: a stream of EDN forms carrying the verdict, counts, failures, diagnostics, and attachment refs. This is the surface the rest of this page documents. Note the most common misconception up front: the EDN summary is not a file — nothing like summary.edn lands in the run directory. If you want the record on disk, redirect stdout (sl run … --edn > run.edn).

JUnit XML (--junit-xml PATH) writes the lossy interop subset — flat testsuites/testcase — for native CI ingestion (GitLab, GitHub, Jenkins). Wiring and worked pipelines: Running ShiftLefter in CI.

HTML (report.html in the run directory, plus an optional --html fixed-path copy) is the self-contained human report; it sits beside the attachments/ directories so relative references resolve, and the whole run directory zips and travels as one artifact (the run results directory). Its status vocabulary mirrors the exit ladder: a scenario whose failure carried :error/class :observation renders the word UNOBSERVED (styled by the st-unobserved modifier, distinct from ERROR — the harness family — and from FAILED, a verdict), the header’s counts row gains an N unobserved entry whenever that count is positive, and a halted run shows the halted after scenario N (<name>): observation failure line under the run meta. The same words appear on the multi-group invocation TOC’s rows.

The machine-record contract

The --edn summary is the machine surface, and it is a locked data contract: keys are additive, never repurposed — code written against the record survives releases. The canonical shape lives in the shiftlefter.runner.report.edn namespace docstring (src/shiftlefter/runner/report/edn.clj); the CI-facing summary is CI.md → Consuming --edn output.

Three rules cover every consumer:

Multi-group runs (more than one setup.clj group after CLI narrowing) add two things, absent everywhere else so single-group streams stay byte-identical:

Dry-run summaries (single- or multi-group) additionally carry :run/reports-not-written, a vector of report kinds ([:html :junit-xml]), whenever a report is declared by flag or by the [:runner :report …] config mirror: the dry run proceeds and writes nothing, and this key (plus one stderr line in console mode) says so. Absent when no report is declared.

The evidence walk

When capture is enabled (:capture config or --capture) or an After hook claims attachments, the summary carries evidence references — bytes never ride the envelope; files live in the run directory (payload-by-reference).

Refs appear in two places, deliberately:

The key is absent when the run produced no refs (default capture policy is :never — attachment-less summaries are unchanged by this feature).

A ref is a map of :attachment/* keys. :attachment/kind (keyword) and :attachment/path (string) are required; the rest are optional: :attachment/bytes, :attachment/media-type, :attachment/source (:capture-policy, or [:hook "name"] — framework-stamped, never self-asserted), :attachment/duration-ms, :attachment/excerpt (bounded triage courtesy), and :attachment/error.

Paths follow the portability contract (canonical: src/shiftlefter/attachments.clj namespace docstring): a relative path is run-dir-relative (group-root-relative under setup.clj groups) — portable, resolve it against the directory you archived. The summary names that directory itself as :run/results-path (config-root-relative), so resolve relative refs beneath it — no directory guessing. An absolute path outside the run directory stays verbatim — legible but non-portable; render it as text, never a link.

:attachment/error is the honesty marker: a failed capture, a claimed file that doesn’t exist, or a claim with no run directory to resolve against. The ref stays visible — evidence loss is recorded, never silently dropped — but treat it as a report line, not a link. Also read :capture-lints under :diagnostics: with stderr suppressed under --edn, that key is where a machine consumer learns promised capture evidence is never coming (unknown kind, invalid policy, no results dir, or :capture/adapter-unsupported — the kind exists but no adapter this group uses supports it; that one is computed at plan time and appears in dry-run summaries too, per group).

Recipe: a markdown digest in ~45 lines

A complete consumer, honoring every rule above: reads the redirected form stream, keeps the summaries, renders a markdown digest with resolved evidence links. Save it as render-digest.bb (babashka).

#!/usr/bin/env bb
;; render-digest.bb — markdown digest from a ShiftLefter --edn run record.
;; Usage: bb render-digest.bb <run.edn> <run-dir> > digest.md

(require '[clojure.edn :as edn]
         '[clojure.java.io :as io])

(defn read-forms
  "All EDN forms in the file. Unknown tagged literals are kept as data —
   the skip-unknown rule, applied to tags."
  [file]
  (with-open [r (java.io.PushbackReader. (io/reader file))]
    (into [] (take-while #(not= ::eof %))
          (repeatedly #(edn/read {:eof ::eof :default tagged-literal} r)))))

(defn evidence-line
  "One markdown line per ref: errored refs render as FAILED text, absolute
   paths as text (non-portable), relative paths as links into the run dir."
  [run-dir {:attachment/keys [kind path error]}]
  (cond
    error                        (str "- " (name kind) " FAILED: " error)
    (.isAbsolute (io/file path)) (str "- " (name kind) ": " path)
    :else                        (str "- [" (name kind) "](" (io/file run-dir path) ")")))

(defn scenario-section [run-dir entry]
  (concat [(str "### " (:scenario/name entry))]
          (map #(evidence-line run-dir %) (:attachments entry))
          (mapcat (fn [step]
                    (cons (str "**" (:step/text step) "**")
                          (map #(evidence-line run-dir %) (:attachments step))))
                  (:steps entry))))

(defn digest [run-dir {:run/keys [status exit-code] :keys [counts failures attachments]}]
  (concat
   [(str "## Run: " (name status) " (exit " exit-code ")")
    (str (:passed counts) " passed, " (:failed counts) " failed — "
         (:scenarios counts) " scenarios, " (:steps counts) " steps")]
   (for [f failures]
     (str "- FAILED: " (:scenario/name f) " — " (or (:step/text f) "(scenario)")
          (when-let [m (-> f :error :message)] (str " — " m))))
   (when (seq attachments) ["" "## Evidence"])
   (mapcat #(scenario-section run-dir %) attachments)))

(let [[edn-file run-dir] *command-line-args*]
  (doseq [line (->> (read-forms edn-file)
                    (filter :run/exit-code)   ; summaries; skip everything else
                    (mapcat #(digest run-dir %)))]
    (println line)))

Produce the inputs from any capture-enabled run. Concretely, from examples/02-browser-zero-code (offline — its setup.clj starts a bundled fixture server; needs ChromeDriver):

cd examples/02-browser-zero-code
../../bin/sl run sl/features/ \
  --capture screenshot=every-step,console=every-step --edn > /tmp/run.edn
# refs are GROUP-root-relative: pass the group's summary :run/results-path
# (config-root-relative — here it resolves under sl/), e.g.
# sl/results/<STAMP>/login. No setup.clj = the run dir itself.
bb render-digest.bb /tmp/run.edn sl/results/<STAMP>/login

The digest lists every scenario’s evidence — this is a green run, which is exactly the point: the :attachments index is where green-run evidence lives, and a consumer reading only :failures would report none.

The in-tree proof of the pattern

The JUnit reporter (src/shiftlefter/runner/report/junit.clj) is the existence proof that the record is enough: it renders testsuites, per-step transcripts, and Jenkins [[ATTACHMENT|…]] lines from the same scenario record — same refs, same portability contract, same honesty marker (errored refs get FAILED lines, never links). One honest nuance: as an in-tree reporter it consumes the scenario envelopes in-process through the Reporter protocol rather than parsing its own serialized output — but the envelope holds the same data the EDN summary serializes, and it uses no API a consumer of the serialized record lacks. Anything it does, your renderer can do from run.edn + the run directory.

Non-goals

Related pages: Running ShiftLefter in CI · Hooks — the attachments claim · The execution lifecycle · Project layout