sema-browser
0.1.0Drive a real browser over CDP: navigate, click, fill, wait, screenshot, and agent-facing page snapshots
sema-browser
Drive a real browser over CDP: navigate, click, fill, wait, screenshot, and agent-facing page snapshots.
A Playwright-style layer over sema-cdp.
Selector actions auto-wait until the element is visible and enabled, clicks and
keystrokes are real input events dispatched through the browser (not JS
.click()), and page/snapshot renders the accessibility tree as compact text
with stable refs — built for agent loops as much as for tests.
Install
sema pkg add sema-browser
Needs a Chrome-compatible browser (Chrome, Chromium, Edge, Brave). One is found
automatically; set SEMA_CHROME or pass {:chrome "/path"} to override.
Quick start
(import "sema-browser")
(browser/with-page
(fn (pg)
(page/goto pg "https://sema-lang.com")
(println (page/title pg))
;; => Sema — A Lisp for Reliable LLM Workflows
(page/screenshot pg {:path "sema.png"})))
An agent loop reads the page as a snapshot and acts on refs:
(browser/with-page
(fn (pg)
(page/goto pg "https://example.com/login")
(println (page/snapshot pg))
;; - RootWebArea "Log in" [ref=e2]
;; - textbox "Email" [ref=e5]
;; - textbox "Password" [ref=e7]
;; - button "Log in" [ref=e9]
(page/fill-ref pg "e5" "ada@example.com")
(page/fill-ref pg "e7" (env "PASSWORD"))
(page/click-ref pg "e9")))
Selectors
CSS by default, with two prefixes:
"text=Get started"— the element whose own text contains the string (case-insensitive)"xpath=//h1"— an XPath expression
CSS and text= selectors pierce open shadow roots (including nested ones),
so elements inside web components are reachable with plain selectors. Closed
shadow roots are unreachable by design, and xpath= only sees the light DOM.
Every selector action auto-waits (default 30 s, :timeout to override) until
the element is attached, visible, and enabled, then acts.
API
| Function | Description |
|---|---|
browser/launch |
Launch a browser |
browser/connect |
Attach to a running browser |
browser/close! |
Close the browser |
browser/page |
Open a new tab |
browser/pages |
Adopt every already-open tab as pages |
browser/incognito-page |
Page in a fresh incognito context |
browser/version |
Browser version info |
browser/with-page |
Launch, run a function with a page, always close |
page/goto |
Navigate and wait for load |
page/back, page/forward, page/reload |
History navigation |
page/set-content |
Replace the document with an HTML string |
page/url, page/title, page/html |
Read page basics |
page/eval |
Run JS in the page, get the value back |
page/text, page/texts, page/attr |
Read element text / attributes |
page/count, page/exists? |
Count / probe matches (no waiting) |
page/click, page/dblclick, page/hover, page/focus |
Mouse and focus |
page/fill, page/type, page/press, page/select |
Text and keys |
page/check, page/uncheck |
Checkbox state (idempotent) |
page/upload |
Set files on an <input type=file> |
page/wait-for, page/wait-for-function |
Wait for an element state / a JS condition |
page/scroll, page/drag |
Wheel scrolling, mouse dragging |
page/bounding-box |
Element geometry in viewport coordinates |
page/screenshot, page/pdf |
Capture image (also per-element) / PDF |
page/add-init-script! |
Run JS in every new document before page scripts |
page/track-console!, page/console, page/clear-console! |
Console + page errors |
page/downloads-to!, page/wait-for-download |
Downloads |
page/set-viewport!, page/set-user-agent! |
Emulation |
page/set-media!, page/set-offline! |
Dark mode/print media, offline |
page/set-geolocation!, page/set-timezone! |
Location and timezone overrides |
page/emulate-device!, browser/devices |
Device presets (viewport + UA) |
page/set-cpu-throttle! |
CPU slowdown factor |
page/websocket-frames |
Recorded WebSocket traffic |
page/start-js-coverage!, page/stop-js-coverage |
JS coverage per script |
page/clipboard-write!, page/clipboard-read |
Clipboard |
page/start-screencast!, page/stop-screencast! |
Record frames as PNGs |
browser/new-context!, browser/context-page, browser/dispose-context! |
Shared incognito contexts |
page/cookies, page/set-cookie!, page/clear-cookies! |
Cookies |
page/on-dialog! |
Auto-answer alert/confirm/prompt |
page/track-network!, page/requests, page/wait-for-response, page/response-body |
Network |
page/authenticate! |
Answer HTTP basic/proxy auth challenges |
page/frames, page/frame, page/eval-in-frame |
Iframes |
page/on-request!, page/block!, page/stop-intercept! |
Request interception |
page/snapshot |
Accessibility tree as text with refs |
page/click-ref, page/fill-ref |
Act on snapshot refs |
page/close! |
Close the tab |
browser/launch, browser/connect, browser/close!
(browser/launch) / (browser/launch {:headless #f :chrome "/path" :args [...]})
launches a browser (headless by default). (browser/connect "http://localhost:9222")
attaches to one already running with --remote-debugging-port. browser/close!
shuts it down.
(browser/page b) / (browser/pages b) / (browser/incognito-page b)
browser/page opens a new tab and returns a page. browser/pages attaches to
every already-open tab (useful after browser/connect to drive an existing
session). browser/incognito-page opens a page in a fresh incognito browser
context — isolated cookies and storage.
A page doubles as a sema-cdp session, so the whole protocol stays reachable
when the wrapper falls short:
(import "sema-cdp")
(cdp/send pg "Animation.enable")
(browser/with-page f) / (browser/with-page opts f)
Launch a browser, open a page, call f with it, and always close the browser —
also when f raises. Returns f's result.
(page/goto pg url) / (page/goto pg url {:timeout ms :wait-until w})
Navigate and wait. Raises when navigation fails (bad host, blocked, ...).
Same-document navigations (#anchor) return immediately. :wait-until picks
the milestone: :load (default), :domcontentloaded, or :networkidle (no
in-flight requests for 500 ms — never reached by pages holding a connection
open, e.g. WebSockets). page/reload accepts the same option.
(page/back pg) / (page/forward pg) / (page/reload pg)
History navigation, waiting for the load. page/back/page/forward raise when
there is no adjacent entry. page/reload accepts {:ignore-cache #t} for a
hard reload.
(page/set-content pg html)
Replace the page's document with the given HTML string.
(page/eval pg js)
Evaluate a JS expression in the page and return its value as data (maps, lists, strings, numbers). Promises are awaited. Raises if the page throws.
(page/eval pg "[...document.querySelectorAll('a')].map(a => a.href)")
(page/text pg sel) / (page/texts pg sel) / (page/attr pg sel name)
innerText of the first matching element / of every match (a list) / one
attribute value (nil when the attribute is absent). page/text and
page/attr auto-wait for the element to be attached; page/texts does not.
(page/count pg sel) / (page/exists? pg sel)
How many elements match / whether any does, right now — neither waits.
(page/click pg sel) / (page/dblclick pg sel)
Scroll the element into view and click its center with real mouse events.
Options: :button (:left default, :right, :middle), :click-count,
:modifiers (list of "Shift", "Control", "Alt", "Meta"), :timeout.
(page/hover pg sel) / (page/focus pg sel)
Move the mouse over the element (fires mouseover/mouseenter) / focus it.
(page/fill pg sel text) / (page/type pg sel text)
page/fill focuses the element and replaces its contents with text using
real text-input events — inputs, textareas, contenteditable; "" clears.
page/type types character by character with per-key events (for autocomplete
and masked inputs) and appends instead of replacing.
(page/press pg sel key)
Focus the element and press a key or chord: "Enter", "Tab", "Escape",
"Backspace", "Delete", "Insert", "Home", "End", "PageUp",
"PageDown", "Space", "F1"–"F12", the arrow keys, any single character,
or with modifiers — "Control+a", "Meta+Shift+r".
(page/select pg sel value)
Set a <select> to value and fire input/change events.
(page/check pg sel) / (page/uncheck pg sel)
Ensure a checkbox/radio is checked/unchecked — clicks only when the state differs, so they are idempotent.
(page/upload pg sel paths)
Set the files of an <input type=file>: one absolute path or a list. Raises
when a file does not exist.
(page/wait-for pg sel) / (page/wait-for pg sel {:state s :timeout ms})
Wait until the element reaches a state: :visible (default), :attached,
:hidden, or :detached.
(page/wait-for-function pg js) / (page/wait-for-function pg js {:timeout ms :interval ms})
Poll a JS expression until it is truthy and return its value.
(page/scroll pg dx dy) / (page/drag pg from-sel to-sel)
page/scroll dispatches a mouse-wheel event (positive dy scrolls down;
:selector targets an element instead of the viewport center). page/drag
presses on one element's center, moves in :steps increments (default 10),
and releases on the other's — sliders, canvases, pointer-event UIs. HTML5
draggable drag-and-drop uses a separate DataTransfer pipeline that synthetic
mouse events do not trigger.
(page/bounding-box pg sel)
The element's {:x :y :width :height} in main-viewport coordinates (correct
inside iframes too).
(page/add-init-script! pg js)
Run js in every new document before any page script — polyfills, feature
flags, stubs. Applies from the next navigation.
Console capture
(page/track-console! pg) starts recording; (page/console pg) returns
{:type :text} entries for console calls and uncaught page errors;
(page/clear-console! pg) drops them.
Downloads
(page/downloads-to! pg dir) routes downloads to dir and enables events;
(page/wait-for-download pg) waits for the next download to complete and
returns {:path :filename :url}.
(page/downloads-to! pg "/tmp/dls")
(page/click pg "text=Export CSV")
(file/read (:path (page/wait-for-download pg)))
(page/screenshot pg) / (page/screenshot pg {:path "x.png"})
Capture a screenshot. Returns PNG bytes, or writes to :path and returns the
path. Options: :format ("png" default, "jpeg", "webp"), :quality
(0–100, jpeg/webp), :full-page (capture beyond the viewport).
(page/pdf pg) / (page/pdf pg {:path "out.pdf"})
Print the page to PDF (headless only). Returns bytes or writes :path.
Options: :landscape, :scale (0.1–2), :page-ranges ("1-3").
(page/set-viewport! pg width height) / (page/set-user-agent! pg ua)
Emulate a viewport (:scale for deviceScaleFactor, :mobile #t) / override
the User-Agent for subsequent requests.
WebSocket frames
After page/track-network!, (page/websocket-frames pg) returns the page's
WebSocket traffic as {:url :direction (:sent/:received) :opcode :payload},
oldest first.
CPU throttling and JS coverage
(page/set-cpu-throttle! pg 4) runs the page at quarter speed (1 restores).
(page/start-js-coverage! pg) … (page/stop-js-coverage pg) returns per-script
{:url :used-bytes :total-bytes} — byte counts are approximated from executed
function ranges (unexecuted function spans subtracted from the script length),
good for "how much of this file ran", not exact line coverage.
Clipboard
(page/clipboard-write! pg text) / (page/clipboard-read pg) — grants the
permission automatically. Requires a secure context (localhost or https, not
data: URLs).
Device presets
(page/emulate-device! pg "iPhone 14") sets viewport, scale, mobile flag, and
User-Agent in one call; (browser/devices) lists the preset names
(iPhone 14, Pixel 7, iPad Mini, Desktop 1080p). Unknown names error with the
list.
Screencast
(page/start-screencast! pg dir) records the page as numbered PNG frames in
dir; (page/stop-screencast! pg handle) stops and returns the frame paths in
order. Frames only arrive while the connection pumps — during page/* calls, or
explicitly via (cdp/pump pg ms).
Shared incognito contexts
(browser/new-context! b) creates an isolated cookie/storage universe;
(browser/context-page b ctx) opens pages inside it (they share state with
each other, not with other contexts); (browser/dispose-context! b ctx)
discards it. browser/incognito-page is the one-shot shorthand.
More emulation
(page/set-media! pg {:color-scheme "dark"})— prefers-color-scheme; also:media "print"and:reduced-motion "reduce";{}resets.(page/set-offline! pg #t)— cut the network;#frestores.(page/set-geolocation! pg 59.9139 10.7522)— override coordinates and grant the permission (:accuracyoptional).(page/set-timezone! pg "Europe/Oslo")— override the timezone.
Cookies
(page/cookies pg) returns the cookies visible to the page;
(page/set-cookie! pg {:name "k" :value "v"}) sets one (defaults to the
current page's URL; pass :url or :domain to target elsewhere, plus
:path, :expires, :httpOnly, :secure, :sameSite);
(page/clear-cookies! pg) deletes all browser cookies.
(page/on-dialog! pg :accept) / (page/on-dialog! pg :dismiss)
Auto-respond to alert/confirm/prompt dialogs. A third argument answers
prompt(). Returns a handler id (cdp/off! to remove). Without a handler an
open dialog blocks the page's JS.
(page/on-dialog! pg :accept "my answer")
(page/eval pg "prompt('name?')") ; => "my answer"
Network
(page/track-network! pg) starts recording traffic. (page/requests pg)
returns responses seen so far (:url, :status, :mime-type, :type).
(page/wait-for-response pg "/api/" {:timeout 10000}) waits for a response
whose URL contains the substring and returns it.
(page/response-body pg request-id) returns a recorded response's body by the
:request-id from those maps — a string for text, a bytevector for binary.
Bodies are held only until the next navigation.
Iframes
page/frames lists frames (:id, :url, :name, :parent — nil for the
main frame). (page/frame pg spec) returns a frame handle for the child
frame whose id, name, or URL (substring) matches — and the handle works like a
page for everything selector-based: page/text, page/click, page/fill,
page/press, page/count, page/snapshot, refs, ... all scoped to that
frame, with clicks landing at the right main-viewport coordinates.
(let ((fr (page/frame pg "checkout")))
(page/fill fr "#card" "4242424242424242")
(page/click fr "text=Pay"))
(page/eval-in-frame pg frame-id js) evaluates JS in a frame by id (an
isolated world: it sees the frame's DOM, not its page-script variables).
Frames run in isolated worlds that are recreated automatically when the frame
navigates. Cross-origin frames that Chrome moves into their own process
(OOPIFs) are separate targets, not part of this page's tree — adopt them via
browser/pages.
Request interception
(page/on-request! pg handler) sees every request before it leaves. The
handler receives {:url :method :resource-type :headers :request-id} and
returns one of:
:continue(ornil) — let it through:abort— block it{:fulfill {:status 200 :headers {...} :body "..."}}— answer it without touching the network{:continue {:url ... :method ... :headers ... :post-data ...}}— rewrite it
(page/on-request! pg
(fn (req)
(if (string/contains? (:url req) "/api/flags")
{:fulfill {:status 200
:headers {:content-type "application/json"}
:body "{\"beta\":true}"}}
:continue)))
(page/block! pg ["analytics" ".png"]) is the shorthand for blocking by URL
substring. Both return an interceptor id; (page/stop-intercept! pg id)
removes it and stops intercepting. One interceptor per page — stop the old one
before installing another.
(page/authenticate! pg username password)
Answer HTTP basic/proxy auth challenges with these credentials. Returns an
interceptor id for page/stop-intercept!. Owns the Fetch domain like
page/on-request! — the two are mutually exclusive until stopped. A challenge
the credentials already failed once is cancelled rather than re-answered, so a
wrong password fails promptly instead of looping. Headless Chrome has no auth
dialog, so without this an auth-protected navigation simply fails.
(page/snapshot pg)
Render the accessibility tree the way a screen reader sees the page — one node
per line, - role "name" [ref=eNNN]. Orders of magnitude smaller than HTML:
grep it instead of parsing markup. Refs are stable element handles for
page/click-ref / page/fill-ref.
(page/click-ref pg ref) / (page/fill-ref pg ref text)
Act on an element by its [ref=eNNN] handle from the latest snapshot.
Testing
sema tests.sema
The suite drives a real headless browser against data: URLs (no network) and
is skipped when no browser is installed.
License
MIT
| Version | Size | Published |
|---|---|---|
| 0.1.0 | 33 KB | 2026-08-25 14:26:54 |