sema-browser

0.1.0

Drive a real browser over CDP: navigate, click, fill, wait, screenshot, and agent-facing page snapshots

$ sema pkg install sema-browser
Readme Versions 1 Dependencies 0

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; #f restores.
  • (page/set-geolocation! pg 59.9139 10.7522) — override coordinates and grant the permission (:accuracy optional).
  • (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, :parentnil 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 (or nil) — 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

VersionSizePublished
0.1.0 33 KB 2026-08-25 14:26:54
No dependencies