All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.9.0 - 2026-06-14
Breaking
CDPEx.Page.click/3now dispatches a trustedInputmouse event by default (realevent.isTrusted, hit-tested at the element's center after scroll-into-view) instead of a syntheticel.click(). Passtrusted: falsefor the old synthetic behavior. A trusted click can newly fail with{:error, {:not_clickable, css}}for an element with no usable box (zero-size / off-screen even after scroll) (#72).CDPEx.Protocol.parse_ws_url/1now returns a 4-tuple{scheme, host, port, target}(was{host, port, path}) and acceptswss://again (reversing the 0.8.0 rejection), enabling TLS DevTools endpoints / cloud browser providers behindconnect/2. The added leadingschemeelement is a breaking change for any caller destructuring the old 3-tuple — update{host, port, path} = parse_ws_url(url)to{_scheme, host, port, target} = parse_ws_url(url). The fourth element is now the WebSocket request target (path plus any?query), so a token-bearing cloud endpoint (wss://host/cdp?token=…) keeps its query through the upgrade (#73).
Added
CDPEx.connect/2— drive an already-running Chrome instead of launching one. Accepts aws:///wss://browser URL or anhttp:///https://base URL (discovered viaGET /json/version). Returns the same handle aslaunch/1;stop/1closes only the pages cdp_ex opened and never reaps the remote Chrome. Pages default to:sessiontransport (:dedicatedover a connected browser is not yet supported — returns{:error, {:unsupported_transport, :dedicated}}).wss://is verified against the OS trust store (:public_key.cacerts_get()), with:insecure/:cacertfile/:cacertsescape hatches;:discovery_timeoutbounds a slow remote/json/version. A failed endpoint discovery returns{:error, {:connect_discovery_failed, reason}}(classified:unknown). Combining:proxywith:connectis rejected with{:error, {:unsupported_with_connect, :proxy}}(a--proxy-serverflag can't apply to a Chrome cdp_ex didn't launch).with_page([connect: endpoint], fun)is the one-shot form (#73).CDPEx.Page.type/4— focus an element and enter text viaInput.insertText(#72).CDPEx.Page.press/4— press a named key (Enter,Tab,Escape,Backspace,Delete, the arrows,Home,End) with realkeyDown/keyUpevents; anilselector targets the currently-focused element. An unsupported key returns{:error, {:unknown_key, key}}(#72).
Fixed
- The page WebSocket URL now brackets an IPv6 host (
ws://[::1]:9222/…) instead of producing a malformedws://::1:9222/…, so a:dedicatedpage can be opened against a Chrome bound to an IPv6 address (#73). CDPEx.Page.observe_network/2now scopes its subscription to the page's session, so on a:session-transport connection a caller receives only that page'sNetworkevents instead of every session's on the shared socket.wait_for_network_idle/2— which already filtered other sessions out in its own receive loop — is now scoped at the source too, so it no longer receives siblings' events at all.CDPEx.Connection.subscribe/4gains asession_id:option (defaultnil= all sessions, the prior behaviour) (#27).
0.8.0 - 2026-06-14
Breaking
CDPEx.Page.evaluate/3(andcall_function/3, which delegates to it) now return{:error, {:unserializable_value, uv}}for a result Chrome can only express as anunserializableValue(NaN/Infinity/-0/BigInt), carrying the raw string. These previously fell through to{:error, {:unexpected_evaluate, _}}— which now means only an unrecognized result envelope. Update any matcher; the new reason classifies as:terminal. This lets callers tell a recoverable unserializable value from a malformed result (#75).
Changed
CDPEx.Protocol.parse_ws_url/1now rejectswss://(and any non-ws://scheme) with anArgumentError. CDPEx launches a local Chrome and speaks plaintextws://to itsDevToolsActivePort, and there is no public connect-to-remote API, so the connect path was always plaintext — acceptingwss://only to fail at connect was a latent contract mismatch. Connecting to a remote/TLS DevTools endpoint is tracked in #73.CDPEx.Page.click/3doc now states it dispatches a synthetic DOM.click()(not a trusted OS-level input event); realInput-domain dispatch is tracked in #72.CDPEx.Page.evaluate/3/CDPEx.Protocol.evaluate_result/1docs now describe the realreturnByValueoutcomes (verified against live Chrome): a DOM node or function serializes lossily to{:ok, %{}}; an unserializable number (NaN/Infinity/-0/BigInt) surfaces as{:error, {:unserializable_value, _}}(see Breaking, above); a value Chrome can't serialize (window, a circular object, aSymbol) fails the call as{:error, {:cdp_error, "Runtime.evaluate", _}}. Covered by unit, doctest, and integration tests.
0.7.0 - 2026-06-06
Added
CDPEx.launch/1(andwith_page/3,CDPEx.Pool) accept a:proxyoption — a URL ("http://user:pass@host:8080") or keyword list ([server:, scheme:, username:, password:]). It sets Chrome's--proxy-serverand, for an authenticated proxy, automatically answers the auth challenge on each:dedicatedpage, so callers justnew_page+navigate(no manualauthenticate/4). A credentialed proxy requires:dedicatedtransport (:sessionreturns{:error, {:unsupported_transport, :session}}) and can't combine withenable_request_interception/2on the same page (both driveFetch); a malformed value — or combining:proxywith a full:argsoverride (which would discard the injected--proxy-server) — fails the launch with{:error, {:invalid_proxy, _}}. Parsing lives in the newCDPEx.Proxymodule (#62).CDPEx.Page.set_user_agent/3accepts:user_agent_metadata(a CDPEmulation.UserAgentMetadatamap) and:accept_language, passed through toEmulation.setUserAgentOverride. Overriding only the UA string leaves the UA Client Hints surface (navigator.userAgentData, theSec-CH-UA*headers) at Chrome's defaults — a visiblenavigator.userAgent↔ Client-Hints mismatch;:user_agent_metadatakeeps them consistent (#34).
0.6.0 - 2026-06-04
Added
CDPEx.classify_error/1andCDPEx.transient?/1— classify any{:error, reason}CDPEx returns as:transient(a fresh attempt may succeed — connection dropped or couldn't be established, timeout, Chrome died or was slow to start, an internal helper crashed, or a connection-layernet::ERR_*navigation failure such asERR_CONNECTION_REFUSED/ERR_TIMED_OUT),:terminal(deterministic — selector miss, JS exception, usage/validation error, missing Chrome binary), or:unknown(payload- or timing-dependent — an ambiguousnet::ERR_*navigation error such as DNS / blocked / aborted, a CDP error code, or a no-document navigation — you decide). The library owns the transient/terminal decision and tracks the error surface as it evolves, so retry logic isn't reimplemented (and re-drifted) in every caller. The return type is the namedCDPEx.error_classification/0. See the "Error handling" section of theCDPExdocs (#56). Consumer note:transient?/1treats:unknownas not transient (conservative); retries remain the caller's responsibility (bound attempts, back off, and re-establish the resource — don't retry a dead handle).CDPEx.error_reason/0reconciled with what the public ops actually return: added{:capture_failed, _}/{:idle_wait_failed, _}(helper crashes fromnavigate/3response: trueandwait_for_network_idle/2, returned since 0.5.0) and{:ws_connect, _}/{:ws_upgrade, _}(browser-socket connect/upgrade failures fromlaunch/1andnew_page/2) — all previously missing from the aggregate type. No runtime change. A compile-time coverage test now fails if anyerror_reason/0member lacks aclassify_error/1exemplar, so a documented member can't be added to the type without being classified (the reverse direction — an error the code produces but never adds to the type — stays a review responsibility) (#56).
0.5.0 - 2026-06-04
Changed
CDPEx.Poolnow launches browsers asynchronously. A cold Chrome start used to run synchronously inside the pool process, blocking every other checkout, checkin, and timeout for its duration (a few seconds), so a short:checkout_timeoutwas unreliable while the pool grew to:size. Each launch now runs in its own task: the pool stays responsive during warmup, launches for simultaneous waiters run concurrently, and:checkout_timeoutis honored even mid-launch. Behavior is otherwise unchanged —count + in-flight launchesnever exceeds:size, a launch failure surfaces to the caller, and a launched browser is adopted (re-linked) by the pool so its crash handling andterminatereaping are preserved (#22).
Fixed
CDPEx.Page.navigate/3withresponse: trueno longer disturbs a same-processobserve_network/2subscription. The document-response capture now runs in a short-lived, isolated helper process with its ownNetwork.responseReceivedsubscription and mailbox, so a caller that is also observing the network on the same page keeps its subscription and its buffered events intact (previously the navigation unsubscribed the caller and drained those events). Cross-process observation was already unaffected (#42).CDPEx.Page.wait_for_network_idle/2no longer disturbs a same-processobserve_network/2subscription — the same fix as #42 applied to the idle wait. The in-flight tracking now runs in an isolated helper process, so a caller observing the network on the same page keeps its subscription and buffered events intact (previously the idle wait tore down the overlapping request-lifecycle subscriptions and drained those events). An abnormal crash of the internal helper surfaces as{:error, {:idle_wait_failed, reason}}(#48).CDPEx.Page.authenticate/4no longer leaves a page stuck on{:error, :already_authenticated}when its caller times out. If the 15s call deadline elapses during a slow arm (severe scheduler starvation), the caller used to depart while the page stayed armed with no way to recover but closing it. TheCDPEx.Browsernow monitors the authenticating caller and, on its departure, cancels the orphanedFetchhandler — which disablesFetch(releasing any paused request) and stops, clearing the page's auth entry so it can be authenticated again (#40).CDPEx.Page.navigate/3andwait_for_navigation/2now treat:timeoutas a true overall ceiling. Previously the lazyNetwork.enableand the event subscription each drew from a fresh budget on top of the navigation wait, so a slow connection could block well past:timeout(worst case ~2×). One absolute deadline now spans enable + subscribe + navigate + the readiness/capture wait — matchingwait_for_response/3andwait_for_network_idle/2.CDPEx.Connection.subscribe/3gained an optionaltimeoutso the subscription can be folded into that deadline, and the internal capture/idle helper enforces the deadline with a force-kill backstop (#49).
0.4.0 - 2026-06-04
Added
CDPEx.Page.navigate/3acceptsresponse: trueto return{:ok, page, %{status, url}}— the main document's HTTP status and final (post-redirect) URL, correlated to the navigation by itsloaderId. Lets callers detect a 403 wall / 404 / login-redirect instead of treating every navigation as success. Lazily enables theNetworkdomain; returns{:error, {:no_document_response, url}}when no document response arrives (#31).CDPEx.Page.wait_for_response/3blocks until a network response whose URL matches a function /Regex/ substring arrives, returning theNetwork.responseReceivedparams (HTTP status +requestIdfor a follow-upresponse_body/3) — for an XHR/fetchkicked off by aclick/3(#32).CDPEx.Page.wait_for_network_idle/2blocks until the network is idle (≤:max_inflightin-flight requests for:idle_timems continuously) — the Puppeteer "networkidle" primitive for waiting out SPA hydration (#32).:telemetryinstrumentation — CDPEx emits launch + navigate spans, page open/close events, and error events (Chrome exit, browser-connection down, WebSocket close). Silent by default (no handlers attached); seeCDPEx.Telemetryfor the event taxonomy. Adds a{:telemetry, "~> 1.2"}dependency (#33).
Breaking
CDPEx.Connection.await_event/4now returns{:ok, params}(the matched event's params) on a match, instead of a bare:ok(#32).- Request interception ↔
authenticate/4mutual exclusion is now enforced (it previously failed silently — returning a misleading:okwhile breaking the page, since both drive theFetchdomain). On the same page:enable_request_interception/2returns{:error, {:conflict, :authenticated}}when the page is authenticated,authenticate/4returns{:error, {:conflict, :intercepting}}when it's intercepting, and re-enabling interception returns{:error, :already_intercepting}(was:ok). Interception also now rejects a:session-transport page with{:error, {:unsupported_transport, :session}}, matchingauthenticate/4. Update any caller that only matched:okon these paths (#30).
Fixed
CDPEx.Pagerequest interception no longer bricks the page when its owner dies:CDPEx.Browsernow monitors the process that calledenable_request_interception/2and auto-Fetch.disables the page (off the Browser process, so a hung connection can't stall other pages) if that process exits without disabling — a crashed or forgetful caller can't leave every request paused with no resolver (#30).CDPEx.Page.authenticate/4no longer blocks theCDPEx.BrowserGenServer for the duration ofFetch.enable: the per-page Fetch handler now arms asynchronously and signals readiness, so the browser keeps serving other pages while a page authenticates.authenticate/4still returns only once interception is armed, preserving the "armed beforenavigate/3" guarantee (#36).- Compiles cleanly on Elixir 1.20 under
--warnings-as-errors(dropped an unusedrequire; pinned bitstring sizes in the test WebSocket parser). CI now covers Elixir 1.15 / 1.19 / 1.20.
0.3.0 - 2026-06-03
Added
CDPEx.Pool— a fixed-size pool of reusable browsers (checkout/2,checkin/2,with_browser/3,with_page/3) that keeps Chrome warm so a per-job fetch avoids a cold launch. Lazy launch up to:size, blocking checkout with timeout, automatic reclaim of a crashed caller's browser, and on-demand relaunch of a crashed one.CDPEx.Page.observe_network/2,stop_observing_network/2, andresponse_body/3— observe a page's network traffic (subscribe the caller toNetwork.requestWillBeSent/responseReceivedevents) and fetch a response body by requestId. Builds on the existing event-subscription machinery and the lazyNetwork.enable.CDPEx.Page.authenticate/4— answer proxy (--proxy-server) or HTTP Basic auth challenges with credentials, so authenticated proxies and Basic-auth-gated origins work. Backed by a per-pageCDPEx.Fetchhandler that enables theFetchdomain, auto-continues paused requests, and answersauthRequired(with a:sourcefilter and a bad-credentials loop guard). Supported on:dedicatedpages only — a:sessionpage returns{:error, {:unsupported_transport, :session}}(its handler would outlive the shared connection); an unknown:sourcereturns{:error, {:invalid_source, value}}. Authenticating a page from another browser (or an already-closed one) returns{:error, :unknown_page}, and re-arming an already-authenticated page returns{:error, :already_authenticated}.CDPEx.Pagerequest interception —enable_request_interception/2/disable_request_interception/2pause matching requests (deliveringFetch.requestPausedto the calling process), each resolved withcontinue_request/3(optionally rewriting url/method/headers/post-data),fulfill_request/3(a synthetic response), orfail_request/3(an error reason; unknown reasons return{:error, {:invalid_error_reason, value}}). Event-driven likeobserve_network/2; mutually exclusive withauthenticate/4per page (both drive theFetchdomain).CDPEx.error_reason/0— a documented (best-effort) type of the{:error, reason}shapes CDPEx returns, so consumers know which tagged kinds to match. Not closed/exhaustive — kinds like{:cdp_error, method, payload}wrap open data.
Breaking
- Error-reason shapes normalized toward tagged tuples (#20). A reason shape is a public contract — a matcher on the old bare atom silently falls through its catch-all with no compile/Dialyzer warning, so update any matchers:
CDPEx.Connection.await_event/4timeout:{:error, :timeout}→{:error, {:timeout, :await_event}}, sharing the{:timeout, _}shape withcall/5's{:timeout, method}.- A malformed
DevToolsActivePortat launch:{:error, :devtools_file_malformed}→{:error, {:devtools_file_malformed, excerpt}}, carrying the file's contents excerpt like its sibling{:debug_url_not_found, _}. - Base64 validation failures now carry the offending data:
CDPEx.Page.response_body/3returns{:error, {:invalid_response_body, excerpt}}(was:invalid_response_body),CDPEx.Page.pdf/2returns{:error, {:invalid_pdf_data, excerpt}}(was:invalid_pdf_data), andCDPEx.Page.screenshot/2returns{:error, {:invalid_screenshot_data, excerpt}}(was:invalid_screenshot_data) when Chrome sends a body / PDF / screenshot that isn't decodable base64. - Unchanged (still bare, intentional):
:noproc; the high-level "didn't happen in time":timeoutfromCDPEx.Pagewait_for_*andCDPEx.Pool.checkout/2; and the control-flow outcomes:unknown_page/:already_authenticated— self-describing states with no payload to carry.
Changed
CDPEx.Page.navigate/3andwait_for_navigation/2raiseArgumentErroron an unknown:wait_untilvalue instead of silently treating it as:network_almost_idle.
Fixed
CDPEx.Page.wait_for_navigation/2now waits via the same lifecycle machinery asnavigate/3(subscribe-before-wait, scoped to the page's session and thePage.lifecycleEventmethod) rather than a generic event matcher — so it can no longer be falsely resolved by another event method whose params happen to carry a matching"name".
0.2.2 - 2026-06-02
Added
- Documented
:launch_timeoutas a ceiling (not a fixed wait) onCDPEx.launch/1andCDPEx.with_page/3, plus a "Running in containers" README section (timeout tuning, the fresh-profile cold-start cost,/dev/shmsizing,--remote-allow-origins).
Changed
- Chrome readiness is now polled:
CDPEx.Chromechecks theDevToolsActivePortfile throughout the wait (not only at the deadline), so launch returns as soon as Chrome is reachable and:launch_timeoutacts as a ceiling rather than a fixed cost — robust to Chrome builds that don't print theDevTools listening on ws://…stderr line. - A launch that never exposes the DevTools endpoint now returns
{:error, {:debug_url_not_found, stderr_excerpt}}(was the bare atom:debug_url_not_found), carrying Chrome's captured stderr so the failure is self-diagnosing. Migration: code matching the bare:debug_url_not_foundatom (e.g. a retry classifier) must match{:debug_url_not_found, _}instead, or that error silently stops matching. CDPEx.with_page/3, given launch options, now contains a throwaway-browser crash: it returns{:error, reason}instead of letting the browser's linked exit propagate to and kill the caller. It briefly traps exits in the calling process for the duration of the call (see thewith_page/3docs for the foreign-EXIT caveat and escape hatch).
Fixed
CDPEx.Connectionno longer ignores an owning-process exit that arrives during the WebSocket upgrade — it aborts the connect at once instead of lingering until the upgrade timeout.CDPEx.Connectionnow stops when a WebSocket pong write fails (mirroring a failed command write), rather than continuing on a dead socket until the next command notices.CDPEx.Page.navigate/3prefers a just-arrived connection:DOWNover a best-effort readiness timeout when the two tie at the deadline, so a connection death surfaces as an error rather than a stale{:ok, page}.CDPEx.Browserno longer leaks Chrome if the browser connection drops in the window between connecting and its first subscribe — the init-time exit is caught and Chrome is reaped.CDPEx.Chromelaunch-failure cleanup waits for the OS process to exit before removing the temp profile (matchingstop/1), closing akill/rmrace.CDPEx.Connection.call/5andawait_event/4return the documented timeout tuple instead of crashing the caller if the outerGenServer.calldeadline fires first under scheduler starvation.CDPEx.Connectionaccumulates WebSocket upgrade headers across response chunks instead of replacing them (defensive; dormant for single-response101upgrades).
0.2.1 - 2026-06-02
Changed
CDPEx.Browsersetsshutdown: 10_000inchild_spec/1so a supervisor givesterminate/2enough time to reap Chrome.
Fixed
CDPEx.Protocol.parse_ws_url/1parses IPv6 hosts and raises a clearArgumentErroron a malformed URL (previously aMatchError).CDPEx.Connectionno longer crashes whencall/5/await_event/4is given a negative timeout (it fires immediately).CDPEx.Connectionteardown fails in-flight callers with{:error, {:ws_closed, _}}instead of{:error, :noproc}onclose/1.CDPEx.Page.navigate/3subscribes to lifecycle events before issuing the navigate, so a fast readiness event (e.g.loadon a cached/local page) can no longer be dropped — a register-after-navigate race.
0.2.0 - 2026-06-02
Added
- Opt-in
sessionIdmultiplexing:CDPEx.new_page(browser, transport: :session)drives many pages over the one browser WebSocket (default:dedicated= one socket per page).CDPEx.Connection.call/5andawait_event/4gain a:session_idoption. CDPEx.Page.wait_for_navigation/2— await a navigation lifecycle milestone without issuing a navigation (e.g. after a click that navigates).CDPEx.Page.wait_for_function/3— poll a JavaScript expression until it is truthy.CDPEx.Page.text/3,attribute/4,visible?/3— element text / attribute / visibility helpers.CDPEx.Page.cookies/2,set_cookies/3,clear_cookies/2— cookie get / set / clear (lazily enables theNetworkdomain).CDPEx.Page.set_extra_headers/3,set_user_agent/3— extra HTTP headers and User-Agent override.CDPEx.Page.set_viewport/4— viewport / device-metrics emulation.CDPEx.Page.pdf/2— render the page to PDF (Page.printToPDF); returns bytes or writes to:path.CDPEx.Page.call_function/4— call a JS function with JSON-serialized arguments.
Changed
- A clean browser-connection close (its socket dropping, e.g. Chrome exiting) now stops
CDPEx.Browserwith a:shutdownreason rather than a crash reason — no spurious error report on expected teardown. Abnormal connection failures still surface loudly.
Fixed
CDPEx.Connection.call/5andawait_event/4no longer crash the connection when given an:infinitytimeout (which is valid per thetimeout()spec).CDPEx.Connectionnow stops when its owning process exits, closing the socket even if the owner skipped its own teardown (e.g. a:brutal_kill).
0.1.0 - 2026-06-01
Added
- Initial OTP-native Chrome DevTools Protocol client.
CDPEx.launch/1andCDPEx.stop/1— supervised headless Chrome lifecycle.CDPEx.new_page/2,CDPEx.close_page/2,CDPEx.with_page/3.CDPEx.Page:navigate/3,wait_for_selector/3,evaluate/3,click/3,html/2,screenshot/2.