Beyond navigation, clicking, and form-filling, Playwriter exposes the lower-level browser-automation surface that a dev/test harness needs: arbitrary JavaScript evaluation, predicate waiting, context init scripts, CDP-based network fault injection, clean binary returns, and (experimentally) page-to-Elixir callbacks.
Every capability is a callback on Playwriter.Transport.Behaviour, implemented
by each transport, and surfaced as a Playwriter.Browser.Session function. The
page-scoped ones also have thin Playwriter facade wrappers for use inside
with_browser/2.
Transport support matrix
| Capability | :windows | :local | :remote |
|---|---|---|---|
evaluate/4 | ✅ | ✅ | — |
wait_for_function/4 | ✅ | ✅ | — |
add_init_script/4 | ✅ | ✅ | — |
add_cookies/3 + storage_state/2 | ✅ | ✅ | — |
new_cdp_session/2 + cdp_send/4 | ✅ | :not_supported | — |
expose_binding/4 | ✅ (experimental) | :not_supported | — |
binary returns (screenshot/3) | ✅ value_b64 | ✅ | — |
:remote is a deliberate dead stub (WSL2's Hyper-V firewall blocks its
WebSocket); every callback returns {:error, :not_supported}.
evaluate/4 — run JavaScript, get the result
{:ok, true} = Session.evaluate(session, page, "crossOriginIsolated")
{:ok, title} = Session.evaluate(session, page, "document.title")
# Pass an argument to a function body
{:ok, 42} = Session.evaluate(session, page, "(n) => n * 2", arg: 21, is_function: true)Options: :is_function (treat the expression as a function body, default
false), :arg (argument passed to the function), :timeout (ms).
On the Windows wire protocol the result comes back in a distinct {"json": v}
envelope, so an evaluated value that itself looks like a handle (e.g. a map with
a "guid" key) is never misinterpreted.
wait_for_function/4 — poll until truthy
:ok = Session.wait_for_function(session, page, "window.__ready === true", timeout: 60_000)
{:error, _} = Session.wait_for_function(session, page, "false", timeout: 500)Options: :is_function, :arg, :polling (a number of ms or "raf",
default "raf"), :timeout. Returns :ok once the predicate is truthy, or
{:error, _} on timeout. This is the backbone of the harness's
"snapshot-and-assert" loop.
add_init_script/4 — seed the page before it loads
{:ok, ctx} = Session.new_context(session, [])
:ok = Session.add_init_script(session, ctx, "window.__debug = 1")
{:ok, page} = Session.new_page(session, context_guid: ctx)
# the script has already run by the time any page script executesThe script is context-scoped and runs before any page script on every page
and navigation in that context — so it must be added before new_page/2.
Use it to install a debug bridge or seed determinism (Math.random, timers).
add_cookies/3 + storage_state/2 — start past an auth gate
For apps behind a login/onboarding gate, seed a pre-signed session cookie instead of driving the login UI every test:
{:ok, ctx} = Session.new_context(session, [])
:ok = Session.add_cookies(session, ctx, [
%{name: "_listener_web_key", value: signed_cookie,
domain: "localhost", path: "/", sameSite: "Lax"}
])
{:ok, page} = Session.new_page(session, context_guid: ctx) # already authenticatedCookie maps use Playwright's field names (name, value, and either url or
domain+path; optionally httpOnly, secure, sameSite, expires).
Alternatively, log in once through the real forms (fill/4 + click/4),
capture the context's cookies + localStorage with storage_state/2, and re-seed
the cookies with add_cookies/3 in later runs:
{:ok, state} = Session.storage_state(session, ctx) # %{"cookies" => [...], "origins" => [...]}CDP — network fault injection (:windows only)
{:ok, cdp} = Session.new_cdp_session(session, page)
{:ok, _} = Session.cdp_send(session, cdp, "Network.emulateNetworkConditions", %{
offline: false, latency: 200, downloadThroughput: 100_000, uploadThroughput: 100_000
})
{:ok, _} = Session.cdp_send(session, cdp, "Network.setBlockedURLs", %{urls: ["*://ads.example/*"]})new_cdp_session/2 opens a Chrome DevTools Protocol session for a page (via
page.context().newCDPSession(page) inside the Windows Node script) and returns
an opaque session id; cdp_send/4 sends CDP commands over it.
playwright_ex exposes no CDP surface, so the :local transport returns
{:error, :not_supported} for both. For the headless-Linux backend prefer
server-side fault injection.
Binary returns
Binary results (e.g. screenshot/3) use an explicit base64 contract: the
Windows Node script returns {"value_b64": "<base64>"}, which Playwriter decodes
with Base.decode64!/1. This replaces earlier magic-byte sniffing, so a {"value"}
string (like content/2's HTML) is never mistaken for an image and vice-versa.
{:ok, png} = Session.screenshot(session, page, [])
<<0x89, 0x50, 0x4E, 0x47, _::binary>> = png # decoded PNG bytesexpose_binding/4 — page → Elixir callbacks (experimental)
Experimental,
:windows-only. This is the one verb that needs a bidirectional event channel over the stdio bridge. The request/response verbs are unaffected, and the message routing is unit-tested, but the full page → Elixir round trip is validated only on a Windows box (the:requires_windows_serversuite). Prefer polling withevaluate/4+wait_for_function/4where you can.
{:ok, ctx} = Session.new_context(session, [])
:ok = Session.expose_binding(session, ctx, "report", fn [payload] ->
send(orchestrator, {:from_page, payload})
:ack
end)
{:ok, page} = Session.new_page(session, context_guid: ctx)
# the page can now call window.report(data); the callback receives [data]The callback is invoked with the argument list the page passed, and its return value is passed back to the page. Register the binding on the context before creating the page.
Provisioning the :local driver
The :local transport shells out to a Node Playwright driver. Provision it
reproducibly:
mix playwriter.setup # npm ci + npx playwright install chromium
mix playwriter.setup --with-deps # also install OS libraries (needs sudo)
The driver is resolved from PLAYWRIGHT_CLI, then
config :playwriter, :playwright_cli, then node_modules/playwright/cli.js. The
pinned version lives in the project's package.json and tracks the version
playwright_ex targets.