Browser automation, API testing, test reporting, and native CLI — for Chromium, Firefox, and WebKit.
| 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.
- Data-driven: Maps for options, anomaly maps for errors — no option builders, no checked exceptions
- Composable:
with-*macros for lifecycle management — resources always cleaned up - Batteries included: API testing, Allure reporting, code generation, accessibility snapshots, native CLI
- Not a port: Wraps the official Playwright Java library directly — full API coverage, same browser versions
| Function | Description |
|---|---|
with‑playwright |
Resource-managed Playwright instance. Binds a Playwright object and ensures cleanup on exit. All browser work starts here. |
with‑browser |
Resource-managed browser instance. Launches Chromium, Firefox, or WebKit with automatic cleanup. Nests inside with-playwright. |
with‑context |
Resource-managed browser context. Isolates cookies, storage, and permissions. Nests inside with-browser. |
with‑page |
Resource-managed page instance. Opens a new tab with automatic cleanup. Nests inside with-context. |
launch‑chromium |
Launch a Chromium browser with options map. Also: launch-firefox, launch-webkit. |
navigate |
Navigate a page to a URL. Supports timeout, wait-until, and referer options. Returns the main resource response. |
locator |
Locate elements by CSS selector. Returns a Locator for chaining actions and assertions. |
get‑by‑text |
Locate elements by text content. Also: get-by-role, get-by-label, get-by-placeholder, get-by-test-id, get-by-alt-text, get-by-title. |
screenshot |
Capture page screenshot as byte array. Supports full-page, clip region, and file output via options. Also: pdf for Chromium. |
evaluate |
Execute JavaScript in page context. Returns the evaluation result as Clojure data. |
on‑console |
Register page event handlers. Also: on-dialog, on-download, on-popup, on-request, on-response, on-page-error, on-close. |
route! |
Intercept network requests matching a URL pattern. Use with route-fulfill!, route-continue!, route-abort! from the network namespace. |
click |
Click an element. Supports click count, button, modifiers, position, force, and timeout options. Also: dblclick. |
fill |
Clear and fill an input element. Also: type-text (type without clearing), press (key press with modifiers), clear. |
check |
Check a checkbox or radio button. Also: uncheck, hover, focus, blur, tap-element, select-option, set-input-files!. |
text‑content |
Read element text content. Also: inner-text, inner-html, input-value, get-attribute, bounding-box. |
is‑visible? |
Query element state. Also: is-hidden?, is-enabled?, is-disabled?, is-editable?, is-checked?. |
loc‑filter |
Filter locator results by text or sub-locator. Also: first-element, last-element, nth-element, loc-locator. |
assert‑that |
Create an assertion object from a Locator, Page, or API response. Required before calling any assertion function. |
has‑text |
Assert element has exact text. Also: contains-text, has-attribute, has-class, has-css, has-id, has-value, has-count. |
is‑visible |
Assert element visibility. Also: is-hidden, is-enabled, is-disabled, is-checked, is-editable, is-focused, is-empty, is-attached. |
has‑title |
Assert page title. Also: has-url. Page-level assertions via assert-that on a Page object. |
loc‑not |
Negate locator assertions. Also: page-not (negate page assertions), api-not (negate API response assertions). |
with‑api‑context |
Resource-managed API request context with base URL, headers, and auth. Automatic disposal. Also: with-api-contexts for multiple. |
api‑get |
HTTP GET request. Also: api-post, api-put, api-patch, api-delete, api-head, api-fetch. Supports params, headers, data, and form options. |
api‑response‑>map |
Convert API response to Clojure map with :status, :ok?, :headers, :body. Also: individual accessors like api-response-status, api-response-text. |
with‑hooks |
Wrap API calls with request/response interceptors for logging, auth injection, or response transformation. Composable — hooks nest. |
retry |
Retry API calls with configurable backoff (linear/exponential), max attempts, and retry predicate. Also: with-retry macro. |
capture‑snapshot |
Capture accessibility tree with numbered refs (e1, e2, ...). Returns :tree (YAML-like), :refs (map), :counter. Also: capture-full-snapshot (includes iframes). |
resolve‑ref |
Resolve a snapshot ref (e.g., "e3") back to a Playwright Locator for interaction. |
step |
Allure test step — wraps code in a named step for reports. Supports nesting. Also: ui-step (auto-screenshots), api-step (auto-attach response). |
epic |
Allure metadata labels. Also: feature, story, severity, owner, tag, description, link, issue, tms, parameter. |
attach |
Attach string content to Allure report. Also: attach-bytes (binary), screenshot (convenience PNG capture). |
jsonl‑>clojure |
Transform Playwright codegen JSONL recordings into idiomatic Clojure test code. Supports :test, :script, and :body output formats. Also: jsonl-str->clojure. |
init‑agents |
Scaffold E2E testing agents for OpenCode, Claude Code, or VS Code. Generates planner, generator, and healer agents with API reference skills. |
spel |
Native CLI binary with 100+ commands. Instant startup, persistent browser via daemon. Navigation, interactions, snapshots, screenshots, network inspection, and more. |
;; deps.edn
{:deps {com.blockether/spel {:mvn/version "0.0.1-SNAPSHOT"}}}Install browsers:
npx playwright install --with-deps chromium(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 (5 platforms):
# Example for macOS ARM
curl -LO https://github.com/blockether/spel/releases/latest/download/spel-macos-arm64
chmod +x spel-macos-arm64
mv spel-macos-arm64 /usr/local/bin/spel
spel install
spel open https://example.com
spel snapshot -i
spel closePlatforms: linux-amd64, linux-arm64, macos-amd64, macos-arm64, windows-amd64.
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.page :as page])
;; Navigation
(page/navigate pg "https://example.com")
(page/navigate pg "https://example.com" {:timeout 30000})
(page/go-back pg)
(page/go-forward pg)
(page/reload pg)
;; Page state
(page/url pg)
;; => "https://example.com/"
(page/title pg)
;; => "Example Domain"
(page/content pg)
;; => "<!DOCTYPE html><html>..."
(page/is-closed? pg)
;; => falseLocators — find elements by CSS, text, role, label, or test ID:
;; By CSS
(page/locator pg "h1")
(page/locator pg "#my-id")
;; By text
(page/get-by-text pg "Click me")
;; By role (requires AriaRole import)
(page/get-by-role pg AriaRole/BUTTON)
;; By label
(page/get-by-label pg "Email")
;; By placeholder
(page/get-by-placeholder pg "Enter email")
;; By test ID
(page/get-by-test-id pg "submit-btn")Screenshots and PDF:
;; Screenshot as byte array
(page/screenshot pg)
;; Screenshot to file with options
(page/screenshot pg {:path "screenshot.png" :full-page true})
;; PDF (Chromium only)
(page/pdf pg)JavaScript evaluation:
(page/evaluate pg "document.title")
;; => "Example Domain"Page events:
(page/on-console pg (fn [msg] (println (.text msg))))
(page/on-dialog pg (fn [dialog] (.dismiss dialog)))
;; Network routing (requires network ns for route handlers)
(page/route! pg "**/api/**"
(fn [route] (net/route-fulfill! route {:status 200 :body "mocked"})))All locator actions and queries:
(require '[com.blockether.spel.locator :as locator])
;; Actions
(locator/click loc)
(locator/click loc {:click-count 2})
(locator/dblclick loc)
(locator/fill loc "text")
(locator/type-text loc "text")
(locator/press loc "Enter")
(locator/press loc "Control+a")
(locator/clear loc)
(locator/check loc)
(locator/uncheck loc)
(locator/hover loc)
(locator/focus loc)
(locator/select-option loc "value")
(locator/set-input-files! loc "/path/to/file.txt")
(locator/set-input-files! loc ["/path/a.txt" "/path/b.txt"])
;; State queries
(locator/text-content loc)
;; => "Example Domain"
(locator/inner-text loc)
;; => "Example Domain"
(locator/input-value loc)
;; => "user@example.com"
(locator/get-attribute loc "href")
;; => "https://www.iana.org/domains/examples"
(locator/is-visible? loc)
;; => true
(locator/is-enabled? loc)
;; => true
(locator/is-checked? loc)
;; => false
(locator/bounding-box loc)
;; => {:x 100.0 :y 50.0 :width 200.0 :height 30.0}
(locator/count-elements loc)
;; => 3Filtering and positioning:
;; Filter by text or sub-locator
(locator/loc-filter loc {:has-text "Submit"})
;; Position-based selection
(locator/first-element loc)
(locator/last-element loc)
(locator/nth-element loc 2)
;; Sub-locators
(locator/loc-locator (page/locator pg ".card") "h2")
(locator/loc-get-by-text (page/locator pg ".card") "Title")
;; Locator screenshots
(locator/locator-screenshot loc)
(locator/highlight loc)Assertion functions take assertion objects, not raw locators/pages. Create them with assert-that first. All assertions return nil on success, throw on failure.
(require '[com.blockether.spel.assertions :as assert])
;; Locator assertions — assert-that returns LocatorAssertions
(let [la (assert/assert-that (page/locator pg "#btn1"))]
(assert/has-text la "Click Me")
(assert/contains-text la "Click")
(assert/has-attribute la "data-test" "submit")
(assert/has-class la "active")
(assert/has-css la "color" "rgb(0, 0, 0)")
(assert/has-id la "btn1")
(assert/has-value la "hello")
(assert/has-count la 1)
(assert/is-visible la)
(assert/is-enabled la)
(assert/is-checked la)
(assert/is-focused la)
(assert/is-empty la)
(assert/is-attached la))
;; Page assertions — assert-that returns PageAssertions
(let [pa (assert/assert-that pg)]
(assert/has-title pa "Example Domain")
(assert/has-url pa #"example\.com"))
;; API response assertions
(assert/is-ok (assert/assert-that api-response))Negation — flip assertion expectation:
;; Locator negation
(assert/is-visible (assert/loc-not (assert/assert-that (page/locator pg ".hidden"))))
;; Page negation (page-not takes PageAssertions, not Page)
(assert/has-title (assert/page-not (assert/assert-that pg)) "Wrong Title")
;; API response negation (api-not takes APIResponseAssertions)
(assert/is-ok (assert/api-not (assert/assert-that api-response)))In Lazytest it blocks, always wrap with expect:
(expect (nil? (assert/has-text (assert/assert-that (page/locator *page* "h1")) "Welcome")))
(expect (nil? (assert/has-title (assert/assert-that *page*) "My Page")))Set default timeout:
(assert/set-default-assertion-timeout! 5000)(require '[com.blockether.spel.network :as net])
;; Response inspection
(let [resp (page/navigate pg "https://example.com")]
(net/response-status resp) ;; => 200
(net/response-status-text resp) ;; => "OK"
(net/response-headers resp) ;; => {"content-type" "text/html" ...}
(net/response-text resp) ;; => "<!doctype html>..."
(net/response-ok? resp)) ;; => true
;; Request inspection
(let [req (net/response-request resp)]
(net/request-url req) ;; => "https://example.com/"
(net/request-method req) ;; => "GET"
(net/request-headers req) ;; => {"accept" "..." ...}
(net/request-post-data req) ;; => nil
(net/request-is-navigation? req));; => true
;; Route handling
(net/route-fulfill! route {:status 200 :body "response" :headers {"Content-Type" "text/plain"}})
(net/route-continue! route)
(net/route-abort! route)
(net/route-fallback! route)(require '[com.blockether.spel.input :as input])
;; Keyboard
(input/key-press keyboard "Enter")
(input/key-type keyboard "text")
(input/key-down keyboard "Shift")
(input/key-up keyboard "Shift")
(input/key-insert-text keyboard "text")
;; Mouse
(input/mouse-click mouse 100 200)
(input/mouse-move mouse 100 200)
(input/mouse-down mouse)
(input/mouse-up mouse)
(input/mouse-wheel mouse 0 100)
;; Touchscreen
(input/touchscreen-tap touchscreen 100 200)(require '[com.blockether.spel.frame :as frame])
;; Frame navigation and content
(frame/frame-navigate frame "https://example.com")
(frame/frame-url frame)
(frame/frame-title frame)
;; Frame locators
(frame/frame-locator frame "button")
(frame/frame-get-by-text frame "text")
(frame/frame-get-by-role frame AriaRole/BUTTON)
;; FrameLocator (preferred for iframes)
(let [fl (frame/frame-locator-obj pg "iframe")]
(locator/click (frame/fl-locator fl "button")))
;; Nested frames
(let [fl1 (frame/frame-locator-obj pg "iframe.outer")
fl2 (.frameLocator (frame/fl-locator fl1 "iframe.inner") "iframe.inner")]
(locator/click (frame/fl-locator fl2 "button")))
;; Frame hierarchy
(frame/parent-frame frame)
(frame/child-frames frame)Capture the accessibility tree with numbered refs for element interaction:
(require '[com.blockether.spel.snapshot :as snapshot])
;; Capture snapshot with refs
(let [snap (snapshot/capture-snapshot pg)]
(:tree snap) ;; YAML-like tree: "- heading \"Example Domain\" [ref=e1] [level=1]\n..."
(:refs snap) ;; {"e1" {:role "heading" :name "Example Domain" :bbox {...}} ...}
(:counter snap)) ;; Total refs assigned
;; Resolve ref to locator — click element e3
(let [loc (snapshot/resolve-ref pg "e3")]
(locator/click loc))
;; Full page with iframes
(snapshot/capture-full-snapshot pg)
;; Clear refs between snapshots
(snapshot/clear-refs! pg)All wrapped functions return either a value or an anomaly map (via com.blockether/anomaly):
(let [result (page/navigate pg "https://example.com")]
(if (anomaly/anomaly? result)
(println "Error:" (:cognitect.anomalies/message result))
(println "Navigated!")))| Playwright Exception | Anomaly Category | Error Type |
|---|---|---|
TimeoutError |
:cognitect.anomalies/busy |
:playwright.error/timeout |
TargetClosedError |
:cognitect.anomalies/interrupted |
:playwright.error/target-closed |
PlaywrightException |
:cognitect.anomalies/fault |
:playwright.error/playwright |
Generic Exception |
:cognitect.anomalies/fault |
:playwright.error/unknown |
(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.
(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.test-fixtures :refer [*page* with-playwright with-browser with-page]]
[lazytest.core :refer [defdescribe describe expect it]])
(:import
[com.microsoft.playwright.options AriaRole]))
(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
clojure -M:test --output com.blockether.spel.allure-reporter/allure
# Generate HTML report
allure generate allure-results --cleanWhen using test fixtures with Allure reporter active, Playwright tracing is automatically enabled:
- Screenshots captured on every action
- DOM snapshots included
- Network activity recorded
- Sources captured
- HAR file generated
Trace and HAR files are automatically attached to test results and viewable in the Allure report.
Record browser sessions and transform to idiomatic Clojure code.
# Record to JSONL file
spel codegen --target=jsonl -o recording.jsonl https://example.com
# Transform JSONL to Clojure test
spel codegen transform recording.jsonl > my_test.clj
spel codegen transform --format=script recording.jsonl
spel codegen transform --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| File | Purpose |
|---|---|
agents/spel-test-planner |
Explores app, writes structured test plans |
agents/spel-test-generator |
Reads test plans, generates Clojure Lazytest 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 |
--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 |
Pre-compiled native binary with 100+ commands for browser automation. Instant startup, persistent browser via daemon.
# Navigation
spel open https://example.com
spel back
spel forward
spel reload
# Interactions
spel click @e1 # Click by snapshot ref or selector
spel dblclick @e1
spel fill @e2 "user@example.com"
spel type @e2 "search text"
spel press Enter
spel hover @e1
spel check @e3
spel uncheck @e3
spel select @e4 "option"
# Accessibility snapshot
spel snapshot # Full accessibility tree with refs
spel snapshot -i # Interactive elements only
spel snapshot -i -c # Compact format
spel snapshot -i -c -d 3 # Limit depth
spel snapshot -s "#main" # Scoped to selector
# Get info
spel get text @e1
spel get html @e1
spel get value @e2
spel get attr @e1 href
spel get url
spel get title
spel get count ".items"
spel get box @e1
# Check state
spel is visible @e1
spel is enabled @e1
spel is checked @e3
# Find by semantic
spel find role button click --name Submit
spel find text "Welcome" click
spel find label "Email" fill "user@example.com"
# Wait
spel wait @e1 # Wait for element visible
spel wait 2000 # Wait for timeout (ms)
spel wait --text "Welcome" # Wait for text
spel wait --url "**/dashboard" # Wait for URL pattern
spel wait --load networkidle # Wait for load state
# Screenshots & PDF
spel screenshot shot.png
spel screenshot -f full.png # Full page
spel pdf page.pdf # PDF (Chromium only)
# JavaScript
spel eval "document.title"
# Network
spel network requests # View tracked requests
spel network requests --type fetch # Filter by type
spel network requests --status 4 # Filter by status prefix
# Browser settings
spel set viewport 1280 720
spel set device "iphone 14"
spel set media dark
# Tabs
spel tab # List tabs
spel tab new https://example.com # New tab
spel tab 0 # Switch to tab
# Close
spel close--session NAME # Named session (multiple browsers)
--json # JSON output for tools
--interactive # Show browser window (headed mode)
--proxy URL # HTTP proxy
--proxy-bypass DOMAINS # Proxy bypass list
--user-agent STRING # Custom User-Agent
--executable-path PATH # Custom browser binary
--args "ARG1,ARG2" # Browser args (comma-separated)
--cdp URL # Connect via CDP endpoint
--ignore-https-errors # Ignore SSL errors
--profile PATH # Persistent browser profile
--debug # Debug loggingEvaluate Clojure code via embedded SCI — no JVM startup needed:
spel --eval '(+ 1 2)'
# => 3
spel --eval '(spel/start!) (spel/goto "https://example.com") (spel/title)'
# => "Example Domain"
spel --timeout 5000 --eval '(do (spel/start!) (spel/goto "https://example.com") (spel/text "h1"))'
# => "Example Domain"# Install browsers
npx playwright install --with-deps chromium
# Build JAR
clojure -T:build jar
# Build native image (requires GraalVM)
native-image -jar target/spel-standalone.jar -o spel
# Run tests
make test
make test-allure
# Start REPL
make replApache License 2.0 — see LICENSE.



