| Accessibility Snapshots | Inline Clojure via --eval | Visual Annotations | Agent Scaffolding |
![]() | ![]() | ![]() | ![]() |
Playwright's Java API is imperative and verbose — option builders, checked exceptions, manual resource cleanup. Clojure deserves better.
spel wraps the official Playwright Java 1.58.0 library with idiomatic Clojure: maps for options, anomaly maps for errors, with-* macros for lifecycle, and a native CLI binary for instant browser automation from the terminal.
with-* macros for lifecycle management — resources always cleaned up--eval scripting — built for AI agents to see, decide, and act;; deps.edn
{:deps {com.blockether/spel {:mvn/version "0.2.0"}}}
spel install # requires spel CLI — see "As a Native CLI Binary" below
(require '[com.blockether.spel.core :as core]
'[com.blockether.spel.page :as page]
'[com.blockether.spel.locator :as locator])
(core/with-playwright [pw]
(core/with-browser [browser (core/launch-chromium pw {:headless true})]
(core/with-context [ctx (core/new-context browser)]
(core/with-page [pg (core/new-page-from-context ctx)]
(page/navigate pg "https://example.com")
(println (locator/text-content (page/locator pg "h1")))))))
;; => "Example Domain"
Download from GitHub releases:
# macOS (Apple Silicon)
curl -LO https://github.com/Blockether/spel/releases/latest/download/spel-macos-arm64
chmod +x spel-macos-arm64 && mv spel-macos-arm64 ~/.local/bin/spel
# Linux (amd64)
curl -LO https://github.com/Blockether/spel/releases/latest/download/spel-linux-amd64
chmod +x spel-linux-amd64 && mv spel-linux-amd64 ~/.local/bin/spel
# Linux (arm64)
curl -LO https://github.com/Blockether/spel/releases/latest/download/spel-linux-arm64
chmod +x spel-linux-arm64 && mv spel-linux-arm64 ~/.local/bin/spel
# Windows (PowerShell)
Invoke-WebRequest -Uri https://github.com/Blockether/spel/releases/latest/download/spel-windows-amd64.exe -OutFile spel.exe
Move-Item spel.exe "$env:LOCALAPPDATA\Microsoft\WindowsApps\spel.exe"
Tip: The examples install to
~/.local/bin/(no sudo needed). Make sure it's on yourPATH:export PATH="$HOME/.local/bin:$PATH" # add to ~/.bashrc or ~/.zshrcYou can also install system-wide with
sudo mv spel-* /usr/local/bin/spelinstead.
The binaries are not signed with an Apple Developer certificate. macOS will block the first run with "spel can't be opened because Apple cannot check it for malicious software". To allow it:
# Remove the quarantine attribute (recommended)
xattr -d com.apple.quarantine ~/.local/bin/spel
Or: System Settings → Privacy & Security → scroll down → click "Allow Anyway" after the first blocked attempt.
Install browsers and verify:
spel install
spel version
If you're behind a corporate SSL-inspecting proxy, spel install may fail with "PKIX path building failed" because the native binary uses certificates baked at build time. Set one of these environment variables to add your corporate CA:
# PEM file with corporate CA cert(s) — simplest option
export SPEL_CA_BUNDLE=/path/to/corporate-ca.pem
# Or reuse Node.js env var — covers both driver + browser downloads
export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem
# Or use a JKS/PKCS12 truststore
export SPEL_TRUSTSTORE=/path/to/truststore.jks
export SPEL_TRUSTSTORE_PASSWORD=changeit # optional
export SPEL_TRUSTSTORE_TYPE=JKS # optional, default: JKS
| Env Var | Format | Behavior on missing file |
|---|---|---|
SPEL_CA_BUNDLE | PEM file | Error (explicit config) |
NODE_EXTRA_CA_CERTS | PEM file | Warning, continues with defaults |
SPEL_TRUSTSTORE | JKS/PKCS12 | Error (explicit config) |
All options merge with the built-in default certificates — public CDN certs continue to work alongside your corporate CA.
Tip:
NODE_EXTRA_CA_CERTSis shared with the Node.js subprocess that installs browsers, so one env var covers both the driver download (Java/native) and browser download (Node.js) paths.
You can also pass a truststore directly via JVM system property (GraalVM native-image supports this at runtime):
spel -Djavax.net.ssl.trustStore=/path/to/truststore.jks install
Note: Unlike the env vars above,
-Djavax.net.ssl.trustStorereplaces the default truststore entirely — your truststore must include both corporate and public CA certificates.
For the full API reference — browser lifecycle, page operations, locators, assertions, network, input, frames, accessibility snapshots, error handling, and more — see the SKILL reference.
The SKILL file is the single source of truth for the complete API. It's auto-generated from templates and always up to date.
All browser work starts with nested with-* macros that guarantee resource cleanup:
(require '[com.blockether.spel.core :as core])
(core/with-playwright [pw]
(core/with-browser [browser (core/launch-chromium pw {:headless true})]
(core/with-context [ctx (core/new-context browser)]
(core/with-page [pg (core/new-page-from-context ctx)]
;; Your code here — pg is a fresh Page
))))
Launch specific browser engines:
(core/launch-chromium pw {:headless true})
;; Also: launch-firefox, launch-webkit
;; Browser queries
(core/browser-connected? browser)
;; => true
(core/browser-version browser)
;; => "136.0.7103.25"
(core/browser-contexts browser)
;; => [#object[BrowserContext ...]]
| Macro | Cleans Up |
|---|---|
with-playwright | Playwright instance |
with-browser | Browser instance |
with-context | BrowserContext |
with-page | Page instance |
(require '[com.blockether.spel.core :as core]
'[com.blockether.spel.api :as api])
;; Single context
(api/with-api-context [ctx (api/new-api-context (api/api-request pw)
{:base-url "https://api.example.com"
:extra-http-headers {"Authorization" "Bearer token"}})]
(let [resp (api/api-get ctx "/users")]
(println (api/api-response-status resp)) ;; 200
(println (api/api-response-text resp)))) ;; JSON body
;; Multiple contexts
(api/with-api-contexts
[users (api/new-api-context (api/api-request pw) {:base-url "https://users.example.com"})
billing (api/new-api-context (api/api-request pw) {:base-url "https://billing.example.com"})]
(api/api-get users "/me")
(api/api-get billing "/invoices"))
;; GET with params and headers
(api/api-get ctx "/users")
(api/api-get ctx "/users" {:params {:page 1 :limit 10}
:headers {"Authorization" "Bearer token"}})
;; POST with JSON body
(api/api-post ctx "/users"
{:data "{\"name\":\"Alice\"}"
:headers {"Content-Type" "application/json"}})
;; PUT, PATCH, DELETE, HEAD
(api/api-put ctx "/users/1" {:data "{\"name\":\"Bob\"}"})
(api/api-patch ctx "/users/1" {:data "{\"name\":\"Charlie\"}"})
(api/api-delete ctx "/users/1")
(api/api-head ctx "/health")
;; Custom method
(api/api-fetch ctx "/resource" {:method "OPTIONS"})
(require '[cheshire.core :as json])
;; Bind JSON encoder for :json option support
(binding [api/*json-encoder* json/generate-string]
(api/api-post ctx "/users" {:json {:name "Alice" :age 30}}))
;; Or set globally
(alter-var-root #'api/*json-encoder* (constantly json/generate-string))
;; Build FormData manually
(let [fd (api/form-data)]
(api/fd-set fd "name" "Alice")
(api/fd-append fd "tag" "clojure")
(api/api-post ctx "/submit" {:form fd}))
;; Or from a map
(api/api-post ctx "/submit" {:form (api/map->form-data {:name "Alice" :email "a@b.c"})})
(let [resp (api/api-get ctx "/users")]
(api/api-response-status resp) ;; => 200
(api/api-response-status-text resp) ;; => "OK"
(api/api-response-url resp) ;; => "https://api.example.com/users"
(api/api-response-ok? resp) ;; => true
(api/api-response-headers resp) ;; => {"content-type" "application/json" ...}
(api/api-response-text resp) ;; => "{\"users\":[...]}"
(api/api-response-body resp) ;; => #bytes[...]
;; Convert to map
(api/api-response->map resp))
;; => {:status 200, :status-text "OK", :url "...", :ok? true, :headers {...}, :body "..."}
Request/response interceptors — composable, nestable:
;; Request logging
(api/with-hooks
{:on-request (fn [method url opts] (println "→" method url) opts)
:on-response (fn [method url resp] (println "←" method (api/api-response-status resp)) resp)}
(api/api-get ctx "/users"))
;; Auth injection
(api/with-hooks
{:on-request (fn [_ _ opts]
(assoc-in opts [:headers "Authorization"]
(str "Bearer " (get-token))))}
(api/api-get ctx "/protected"))
;; Composable nesting
(api/with-hooks {:on-response (fn [_ _ resp] resp)}
(api/with-hooks {:on-request (fn [_ _ opts] opts)}
(api/api-get ctx "/users")))
;; Default: 3 attempts, exponential backoff, retry on 5xx
(api/retry #(api/api-get ctx "/flaky"))
;; Custom config
(api/retry #(api/api-get ctx "/flaky")
{:max-attempts 5
:delay-ms 1000
:backoff :linear
:retry-when (fn [r] (= 429 (:status (api/api-response->map r))))})
;; With macro
(api/with-retry {:max-attempts 3 :delay-ms 200}
(api/api-post ctx "/endpoint" {:json {:action "process"}}))
;; Standalone request (no context setup)
(api/request! pw :get "https://api.example.com/health")
(api/request! pw :post "https://api.example.com/users"
{:data "{\"name\":\"Alice\"}"
:headers {"Content-Type" "application/json"}})
Integrates with Lazytest for comprehensive test reports using Allure. Compatible with Allure 2+ result format. The built-in reporter generates the full HTML report automatically using Allure 3 (pinned to 3.1.0 via npx) with an embedded local Playwright trace viewer — no external allure generate step needed.
View live test report — 845 tests, 100% pass rate, with embedded Playwright traces.
| Allure Report | Embedded Playwright Traces |
![]() | ![]() |
(ns my-app.test
(:require
[com.blockether.spel.assertions :as assert]
[com.blockether.spel.locator :as locator]
[com.blockether.spel.page :as page]
[com.blockether.spel.roles :as role]
[com.blockether.spel.test-fixtures :refer [*page* with-playwright with-browser with-page]]
[com.blockether.spel.allure :refer [defdescribe describe expect it]]))
(defdescribe my-test
(describe "example.com"
{:context [with-playwright with-browser with-page]}
(it "navigates and asserts"
(page/navigate *page* "https://example.com")
(expect (= "Example Domain" (page/title *page*)))
(expect (nil? (assert/has-text
(assert/assert-that (page/locator *page* "h1"))
"Example Domain"))))))
| Fixture | Binds | Scope |
|---|---|---|
with-playwright | *pw* | Shared Playwright instance |
with-browser | *browser* | Shared headless Chromium browser |
with-page | *page* | Fresh page per it block (auto-cleanup, auto-tracing with Allure) |
with-traced-page | *page* | Like with-page but always enables tracing/HAR |
with-test-server | *test-server-url* | Local HTTP test server |
(require '[com.blockether.spel.allure :as allure])
;; Labels
(allure/epic "E2E Testing")
(allure/feature "Authentication")
(allure/story "Login Flow")
(allure/severity :critical) ;; :blocker :critical :normal :minor :trivial
(allure/owner "team@example.com")
(allure/tag "smoke")
;; Description and links
(allure/description "Tests the complete login flow")
(allure/link "Docs" "https://example.com/docs")
(allure/issue "BUG-123" "https://github.com/example/issues/123")
(allure/tms "TC-456" "https://tms.example.com/456")
;; Parameters
(allure/parameter "browser" "chromium")
;; Lambda step with body
(allure/step "Navigate to login page"
(page/navigate pg "https://example.com/login"))
;; Nested steps
(allure/step "Login flow"
(allure/step "Enter credentials"
(locator/fill (page/locator pg "#user") "admin")
(locator/fill (page/locator pg "#pass") "secret"))
(allure/step "Submit"
(locator/click (page/locator pg "#submit"))))
;; UI step (auto-captures before/after screenshots, requires *page* binding)
(allure/ui-step "Fill login form"
(locator/fill username-input "admin")
(locator/fill password-input "secret")
(locator/click submit-btn))
;; API step (auto-attaches response details: status, headers, body)
(allure/api-step "Create user"
(api/api-post ctx "/users" {:json {:name "Alice" :age 30}}))
;; String attachment
(allure/attach "Request Body" "{\"key\":\"value\"}" "application/json")
;; Binary attachment
(allure/attach-bytes "Screenshot" (page/screenshot pg) "image/png")
;; Convenience screenshot
(allure/screenshot pg "After navigation")
;; Attach API response
(allure/attach-api-response! resp)
# Run with Allure reporter (generates JSON + HTML report + embedded trace viewer automatically)
clojure -M:test --output nested --output com.blockether.spel.allure-reporter/allure
# Or use Make targets
make test-allure # run tests + generate report
make allure # run tests + generate + open in browser
The reporter handles the full pipeline:
allure-results/ (Allure 2+ compatible format)npx allure@3.1.0 (no manual install needed)allure-report/ using allure awesometrace.playwright.dev)./trace-viewer/ and pre-registers the Service Worker for instant loading.allure-history.jsonl (Allure 3 JSONL mechanism, default: last 10 builds)| Property | Env Var | Default | Description |
|---|---|---|---|
lazytest.allure.output | LAZYTEST_ALLURE_OUTPUT | allure-results | Results output directory |
lazytest.allure.report | LAZYTEST_ALLURE_REPORT | allure-report | HTML report directory |
lazytest.allure.history-limit | LAZYTEST_ALLURE_HISTORY_LIMIT | 10 | Max builds retained in history |
lazytest.allure.report-name | LAZYTEST_ALLURE_REPORT_NAME | (auto: "spel vX.Y.Z") | Report title (shown in header and history). Auto-includes version when not set. |
lazytest.allure.version | LAZYTEST_ALLURE_VERSION | (SPEL_VERSION) | Project version shown in build history and environment. Falls back to SPEL_VERSION resource. |
lazytest.allure.logo | LAZYTEST_ALLURE_LOGO | (none) | Path to logo image for report header |
# Keep last 20 builds in history
clojure -J-Dlazytest.allure.history-limit=20 -M:test \
--output nested --output com.blockether.spel.allure-reporter/allure
# Tag build with custom version
LAZYTEST_ALLURE_VERSION=1.2.3 clojure -M:test \
--output nested --output com.blockether.spel.allure-reporter/allure
When using test fixtures with Allure reporter active, Playwright tracing is automatically enabled:
Trace and HAR files are automatically attached to test results (MIME type application/vnd.allure.playwright-trace) and viewable directly in the Allure report via an embedded local trace viewer — no external service dependency. The report JS is patched to load traces from ./trace-viewer/ instead of trace.playwright.dev, and a Service Worker is pre-registered for instant loading.
Record browser sessions and transform to idiomatic Clojure code.
# Record browser session (opens interactive Playwright Codegen recorder)
spel codegen record -o recording.jsonl https://example.com
# Transform JSONL to Clojure test
spel codegen recording.jsonl > my_test.clj
spel codegen --format=script recording.jsonl
spel codegen --format=body recording.jsonl
(require '[com.blockether.spel.codegen :as codegen])
;; Read file and transform
(codegen/jsonl->clojure "recording.jsonl")
;; With format option
(codegen/jsonl->clojure "recording.jsonl" {:format :test}) ;; Full Lazytest test
(codegen/jsonl->clojure "recording.jsonl" {:format :script}) ;; Standalone script
(codegen/jsonl->clojure "recording.jsonl" {:format :body}) ;; Just actions
;; From string
(codegen/jsonl-str->clojure jsonl-string)
(codegen/jsonl-str->clojure jsonl-string {:format :script})
| Format | Output |
|---|---|
:test (default) | Full Lazytest file with defdescribe/it, lifecycle macros |
:script | Standalone script with require/import + with-playwright chain |
:body | Just action lines for pasting into existing code |
| Action | Codegen Output |
|---|---|
navigate | (page/navigate pg "url") |
click | (locator/click loc) with modifiers, button, position |
click (dblclick) | (locator/dblclick loc) when clickCount=2 |
fill | (locator/fill loc "text") |
press | (locator/press loc "key") with modifier combos |
hover | (locator/hover loc) |
check/uncheck | (locator/check loc) / (locator/uncheck loc) |
select | (locator/select-option loc "value") |
setInputFiles | (locator/set-input-files! loc "path") |
assertText | (assert/has-text (assert/assert-that loc) "text") |
assertChecked | (assert/is-checked (assert/assert-that loc)) |
assertVisible | (assert/is-visible (assert/assert-that loc)) |
assertValue | (assert/has-value (assert/assert-that loc) "val") |
Signals: dialog, popup, download — handled automatically in generated code.
Scaffold E2E testing agents for OpenCode, Claude Code, or VS Code:
spel init-agents # OpenCode (default)
spel init-agents --loop=claude # Claude Code
spel init-agents --loop=vscode # VS Code / Copilot
spel init-agents --flavour=clojure-test # clojure.test instead of Lazytest
spel init-agents --no-tests # SKILL only (interactive dev)
| File | Purpose |
|---|---|
agents/spel-test-planner | Explores app, writes structured test plans |
agents/spel-test-generator | Reads test plans, generates Clojure test code |
agents/spel-test-healer | Runs failing tests, diagnoses issues, applies fixes |
prompts/spel-test-workflow | Orchestrator: plan → generate → heal cycle |
skills/spel/SKILL.md | API reference for agents |
| Flag | Default | Purpose |
|---|---|---|
--loop TARGET | opencode | Agent format: opencode, claude, vscode |
--ns NS | dir name | Base namespace for generated tests |
--flavour FLAVOUR | lazytest | Test framework: lazytest or clojure-test |
--no-tests | — | Scaffold only the SKILL (API reference) — no test agents |
--dry-run | — | Preview files without writing |
--force | — | Overwrite existing files |
--test-dir DIR | test-e2e | E2E test output directory |
--specs-dir DIR | test-e2e/specs | Test plans directory |
If you use Oh My OpenCode, disable the built-in playwright skill and use the scaffolded spel skill instead. The built-in skill is a generic MCP wrapper with no knowledge of spel's Clojure API — the spel skill includes the full API reference (locators, assertions, snapshots, CLI, codegen) so agents generate idiomatic code out of the box.
Add to your project's .opencode/oh-my-opencode.json:
{
"disabled_skills": ["playwright"]
}
After scaffolding with spel init-agents, the spel skill is automatically available at .opencode/skills/spel/SKILL.md. Agents and task delegations should use load_skills=["spel"] for any browser-related work.
# Install browsers (via Playwright Java CLI)
clojure -M -e "(com.microsoft.playwright.CLI/main (into-array String [\"install\" \"--with-deps\"]))"
# Build JAR
clojure -T:build jar
# Build native image (requires GraalVM)
clojure -T:build native-image
# Run tests
make test
make test-allure
# Start REPL
make repl
See CHANGELOG.md.
Apache License 2.0 — see LICENSE.
Can you improve this documentation? These fine people already did:
Karol Wojcik & blockether-deployerEdit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |