The core of Selenium-WebDriver, ported to Aether language
as one pure-Aether engine + thin per-language bindings, built with
aeb instead of Bazel. Same shape as
servirtium-vcr,
html-sanitizer and
libphonenumber-ae: the protocol logic
lives once, every language re-glues to it over a C ABI.
NOTE: This is not an official Selenium-team project, though they are informed of progress.
Best case - we have a jar/gem/wheel/etc for your language on the github-releases page, as well as a .so or .dll for your cpu and OS. Worst case scenario is you build your jar/gem/wheel yourself
Namespaces are as the mainstream Selenium has, so imports/usings/requires should be the same.
The engine and bindings build with aeb, which needs the Aether toolchain
(ae) — and aeb's installer needs an ae to target, so they install in that
order. aeb's get.sh
ensures both from a bare clone: binary-first per platform (source fallback), and
it fetches the pinned Aether via Aether's own get.sh. Aether compiles to C, so
the only prerequisites for a source fallback are a C compiler and GNU make. Pins
live in ci/versions.env — keep the one-liner's numbers in
step with it.
One line installs a pinned ae (>= AE_PIN) THEN a pinned aeb, into
~/.local (no sudo; PREFIX= to override). From aeb v0.309 this also fetches
aeb-resolve.jar (now a fat jar published as a release asset), so the JVM
bindings resolve their Maven deps on a fresh binary install:
curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/v0.336/get.sh \
| AE_PIN=0.841.0 AEB_REF=v0.336 shget.sh is also a sourceable library — a CI step can source it (set
AEBGET_SOURCE_ONLY=1 so sourcing only defines the functions) then drive it:
AEBGET_SOURCE_ONLY=1 . <(curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/v0.336/get.sh)
AE_PIN=0.841.0 AEB_REF=v0.336 aeb_bootstrapThen build the engine (the one thing every binding needs) and, for a given language, its binding + tests:
aeb selenium_core/.build.ae # the pure-Aether engine -> libselenium_core.so
aeb selenium_core/.build.ae python/.tests.ae # + a binding (needs that language's toolchain)Bindings carry no protocol logic. The command catalog, the W3C
command→(method, path) route table, path templating, By/capabilities
normalization, the W3C error-envelope decode, and the HTTP round-trip to the
driver/Grid all live in selenium_core/selenium_core.ae. A binding opens a session,
issues commands by name with JSON params, reads back the result value or a typed
error, and closes. Anything smarter than marshalling belongs in selenium_core/.
Twenty-eight language bindings drive the byte-identical
libselenium_core.so (one .tests.ae node each). Classic Selenium shipped five
official clients (Java, Python, Ruby, JavaScript, .NET), each a full
reimplementation of the protocol. Here all five are re-glued as thin FFI layers
over the shared engine — and twenty-three more come along, several of them
essentially free, because a runtime family only has to be bridged once:
| Family | Bridged once as | Languages riding it |
|---|---|---|
| BEAM | one Erlang NIF (selenium_nif) |
Erlang, Elixir, Gleam, LFE |
| JVM | one Panama FFM jar | Java, Kotlin, Clojure, Groovy, Scala |
| .NET | one P/Invoke assembly | C#, F# |
| Language | FFI mechanism |
|---|---|
| Java | Panama FFM (java.lang.foreign) |
| Python | ctypes (runtime load) |
| Ruby | Fiddle (runtime load) |
| JavaScript (Node) | koffi / N-API (runtime load) |
| .NET (C#) | P/Invoke |
Note: not in classic Selenium's monorepo, but in comminuty repos.
| Language | FFI mechanism |
|---|---|
| C | the C ABI directly (header + link) |
| C++ | header-only wrapper over the C ABI |
| Go | cgo (link-time) |
| Rust | extern "C" + build.rs (link-time) |
| Dart | dart:ffi |
| Swift | C interop via a module map |
| Crystal | @[Link] + lib (link-time) |
| Nim | importc (link-time) |
| Zig | @extern (link-time) |
| D | extern(C) + dlopen |
| Haskell | foreign import ccall (link-time) |
| Julia | ccall |
| Lua | Lua 5.4 C extension |
| PHP | PHP FFI (FFI\CData) |
| Erlang | Erlang NIF |
| Elixir | rides the Erlang NIF (BEAM) |
| Gleam | rides the Erlang NIF (BEAM) |
| LFE | rides the Erlang NIF (BEAM) |
| Kotlin | JVM interop over the Java FFM jar |
| Clojure | JVM interop over the Java FFM jar |
| Groovy | JVM interop over the Java FFM jar |
| Scala | JVM interop over the Java FFM jar |
| F# | .NET interop over the P/Invoke assembly |
Nineteen distinct FFI mechanisms in all — the BEAM four share the NIF, the JVM five share the jar, and the two .NET languages share the assembly, so one engine reaches twenty-eight languages.
selenium_core/
selenium_core.ae the engine: route table, path templating, By, error map,
capabilities, and the std.http.client round-trip
embed.ae the flat C ABI (aether_sel_embed_*), handle-based
_embed_strdup.c the ~15-line caller-owned-string bridge (the only C)
.build.ae aeb node -> selenium_core/native/libselenium_core.so
selenium_core/tests/
probe.ae pure-Aether engine probe (no browser, no FFI)
.tests.ae aeb node that builds + runs the probe
python/
selenium/ the Python binding, at the MAINSTREAM import paths
_native.py library loader + ctypes prototypes (1:1 with embed.ae)
_webdriver.py the one implementation: WebDriver, WebElement, ShadowRoot,
Timeouts, By, errors
webdriver/ selenium.webdriver.{common,remote,chrome,support}.* — the
Selenium 4.x module tree, re-exporting from _webdriver
common/ selenium.common.exceptions
test/
test_ffi.py no-browser FFI test (loads the .so, marshals, error path)
test_abi_surface.py no-browser parity facts (the mainstream surface, no .so)
test_live_chrome.py live headless Chrome: smoke, atoms, BiDi, shadow DOM,
virtual authenticator, timeouts
test_live_surface.py live surface coverage (cookies/windows/actions/...)
setup.py wheel packaging; bundles native/*.so via package_data
.package.ae aeb node → builds the wheel with the engine .so inside
.example.ae aeb node → installs the wheel into a clean site + runs it
example/
consumer_example.py runs from the INSTALLED package (ffi/discovery/live)
go/
selenium.go the Go binding (cgo over the .so — link-time)
ffi_test.go no-browser FFI test (Route/ErrorCode/Locator, transport err)
live_test.go live headless-Chrome end-to-end smoke test
native/ bundled .so (cgo rpath self-locates ../selenium_core/native or here)
.package.ae aeb node → stages the engine .so into go/native/
.example.ae aeb node → a consumer module with NO selenium_core/ sibling go-runs it
example/ the standalone consumer program (go.mod + main.go)
ruby/
lib/selenium-webdriver.rb the require entry point (mainstream gem name)
lib/selenium/native.rb Fiddle loader + prototypes (1:1 with embed.ae)
lib/selenium/webdriver.rb the surface: Driver, Element, By, Select, Wait
selenium-webdriver.gemspec gem packaging; bundles lib/**/* incl. native/*.so
— NO runtime gem dependencies (stdlib only)
spec/*.rb minitest suites (ffi / facade / convenience /
surface / live Chrome)
.tests.ae / .package.ae / .example.ae aeb nodes
example/consumer_example.rb runs from the INSTALLED gem (ffi/discovery/live)
javascript/
index.js the require entry point
lib/native.js koffi loader + prototypes (1:1 with embed.ae)
lib/webdriver.js the surface — async/Promise-returning, matching
mainstream selenium-webdriver (see note)
package.json npm packaging; bundles native/ + lib/; koffi dep
test/{abi,ffi,live}_test.js node:test suites (surface parity / no-browser /
live+surface)
test/content_server.js out-of-process content server for the live test
.tests.ae / .package.ae / .example.ae aeb nodes
example/consumer_example.js runs from the INSTALLED package (ffi/discovery/live)
java/
src/main/java/org/openqa/selenium/** Panama FFM binding at the MAINSTREAM
package: WebDriver, WebElement, By, Keys,
Cookie, Actions, support.ui.{Select,
WebDriverWait,ExpectedConditions}, print.*,
plus Native + a dependency-free Json
src/test/java/** JUnit 5: FfiTest, LiveTest, AbiSurfaceTest
.tests.ae / .package.ae / .example.ae aeb nodes (plain javac + jar, no Maven)
example/ConsumerExample.java runs from the INSTALLED jar (ffi/discovery/live)
dotnet/
SeleniumCore/*.cs P/Invoke binding: NativeMethods, NativeLoader,
WebDriver, WebElement, By, WebDriverError
SeleniumCore/SeleniumCore.csproj class lib; packs the .so as a runtime asset
SeleniumCore.Tests/Program.cs console harness (no xunit): ffi + live+surface
.tests.ae / .package.ae / .example.ae aeb nodes (SDK 10; package targets net8.0)
example/ NuGet consumer app (Program.cs + Consumer.csproj)
rust/
src/lib.rs extern "C" binding + WebDriver/WebElement/By
src/json.rs hand-rolled JSON (no serde → fully offline)
build.rs links the .so + publishes native_dir metadata
Cargo.toml links = "selenium_core"; zero dependencies
tests/{ffi_test,live_test}.rs cargo tests (no-browser + live+surface)
.tests.ae / .package.ae / .example.ae aeb nodes
example/ consumer crate (path dep + rpath-propagating build.rs)
The engine is ahead of classic Selenium in protocol coverage and behind it in binding ergonomics — those are separate axes, so they are measured separately. At any moment that claim could be even more out of date that it was at the time of writing.
Protocol (the engine). The route table in selenium_core/selenium_core.ae
carries the full W3C command set plus the extensions Selenium 4 ships: shadow
root, print-to-PDF, computed role/label, WebAuthn virtual authenticators, FedCM,
the Grid download endpoints, and se: log/file extensions. Nothing in the W3C
spec is missing.
Surface (the bindings). Measured by diffing the binding's public API against
the real Selenium 4.44 packages (pip install selenium==4.44; selenium-api +
selenium-support jars read with javap):
| Binding | Public names present | Notes |
|---|---|---|
| Python | 312 / 362 (86%) | WebElement, Keys, ActionChains, Select, SwitchTo, Alert, WebDriverWait and selenium.common.exceptions are at 100%; ChromeOptions 97% |
| Java | 191 / 198 (96%) | WebDriver, WebElement, Select, WebDriverWait and ExpectedConditions are at 100% |
Still behind, and deliberately so. The remaining gaps are mostly things a W3C-pure engine has no business emulating, or that upstream has deprecated:
- CDP (
execute_cdp_cmd,start_devtools, the generateddevtools/vNNN/trees — ~250 modules of upstream's wheel). BiDi is the supported path here. - Pinned scripts (
pin_script/unpin/get_pinned_scripts, and Java'sJavascriptExecutor.pin) — deprecated upstream. - Driver lifecycle internals (
start_session,start_client,stop_client,Service,SeleniumManager): this engine owns driver resolution and launch itself, throughensure_driver/resolve_driveron the C ABI. mobile/orientation: legacy JSON-Wire-Protocol, not W3C.
Genuinely missing and worth doing: the BiDi module accessors Python exposes
(driver.network, driver.script, driver.browsing_context, …) over the BiDi
that already works; file_detector for local-file upload to a remote Grid (the
engine already routes uploadFile); and the FedCM dialog wrapper (the engine
already routes all eight FedCM commands). Until those land, every one of them is
reachable through the public generic hatch — driver.execute("<command>", params)
in Python, execute(...) in Java — which goes through the same route table, so
no functionality is actually locked away.
selaenium has always been a Grid client: point a session at a hub URL and the
engine drives through it. It is now also a Grid server, in standalone mode —
grid/hub.ae builds selaenium-hub, a W3C endpoint that provisions drivers via
the engine's own driver manager and proxies sessions to them. Router, node and
local drivers in one process: the job the selenium/standalone-chromium
container does, with no container and no driver to install.
aeb grid/.build.ae # -> selaenium-hub
SEL_HUB_PORT=4444 selaenium-hub # any Selenium client can now point hereIt is standalone only. Distributed Grid — a separate Router / Distributor /
SessionQueue / SessionMap with remote Nodes over an event bus — is deliberately
out of scope, as are session reaping and slot limits. The hub keeps no session
map: it encodes the owning driver's pid and port into the session id it hands
back, which makes it stateless and so safe under std.http's worker pool without
a lock. docs/Grid.md has the reasoning and the limitations.
It is graded against the real thing rather than against itself —
grid/run-grid-test.sh --hub swaps the reference container for our hub and runs
the identical per-binding Grid legs:
grid/run-grid-test.sh <cmd> # real Selenium Grid, in a container
grid/run-grid-test.sh --hub <cmd> # our hub, no container-
.tests.ae(per binding): the binding works against the source tree with the engine.sohanded in viaSELENIUM_CORE_LIB. Proves the binding. -
.package.ae+.example.ae(where supported): the distributable — a wheel / gem / npm package / jar / crate / module. Most package formats bundle the engine; Crystal and Julia release source archives fetch a matching engine for the consumer's OS/CPU. Consumer examples install and run the artifact in a clean environment. Eleven bindings have an.example.ae; they are part ofaeb .presubmit.ae.This layer is not optional: a
.tests.aeruns against the SOURCE tree, so it cannot see a packaging break. When these were first run (2026-09-13) four were failing — including a Python wheel that shipped no engine.soat all — while every corresponding.tests.aewas green. Seedocs/Consumer-Install.md.
To cut a package (gem/jar/…) WITHOUT the Aether toolchain — fetching the prebuilt
engine from the release instead of building it, via aeb's --overrideDep — see
docs/Prebuilt-Engine-Packaging.md.
The prebuilt engine's .so/.dylib/.dll release tag is pinned in
SELENIUM_CORE_VERSION, with copies in bindings that
ship independently. This is the Selaenium engine version, separate from the
Aether and aeb versions above. Current bindings require engine v0.14.2,
including the native/locator ABI exports absent from v0.9.0 and the scoped
relative-locator export (find_relative_in) added in v0.14.0. The
v0.14.2 release
provides the prebuilt libraries. Every new engine
release must also advance the download pins
to the tag just released.
For a corporate vendoring snapshot, scripts/vendor.py
copies selected source sets and chosen native libraries from one release tag
into separate paths, with a manifest that can be checked against that tag.
To release an engine, prepare its version with
SEL_RELEASE_SET=<tag> aeb release/.version.ae, commit, and push that vX.Y.Z tag.
GitHub Actions
cross-builds and publishes the engine binaries and their checksums. Starting with
v0.11.0, the range adds Linux musl (x86_64/arm64) and FreeBSD
x86_64 to the existing six Linux/macOS/Windows libraries. Published v0.10.0
assets stay unchanged. See the release instructions.
Handle-based: N independent sessions per process. open(base_url) returns an
opaque handle; execute(h, name, params_json) runs one command, returning 0 on
success, a W3C error code on a protocol error, or -1 on transport failure;
drain the result via last_value (JSON payload), last_error_code,
last_error, last_status, session_id. Pure helpers by_locator, route,
error_code are also exported so a binding shares the ONE normalization path.
Returned char* are caller-owned — free with free_string.
aeb selenium_core/.build.ae # -> selenium_core/native/libselenium_core.so
aeb selenium_core/tests/.tests.ae # pure-Aether engine probe (fast, no browser)
# Python binding (needs the .so via SELENIUM_CORE_LIB during dev):
SELENIUM_CORE_LIB="$PWD/selenium_core/native/libselenium_core.so" python3 python/test/test_ffi.py
SELENIUM_CORE_LIB="$PWD/selenium_core/native/libselenium_core.so" python3 python/test/test_live_chrome.py
Needs Aether ≥ 0.838.0 and aeb v0.330 or newer, as recorded in
ci/versions.env. The pinned pair includes libc-aware
GNU/musl package selection and the SDK fixes needed to compile with 0.838.0.
- Engine (
selenium_core/selenium_core.ae): full W3C command map, path templating, By normalization, W3C error decode, HTTP round-trip. ✅ builds, ✅ 31/31 probes. - ABI (
selenium_core/embed.ae) + C bridge: ✅ builds tolibselenium_core.so, 70 exports — the command seam plus driver orchestration (resolve_driver/ensure_driver/stop_driver), the atoms (is_displayed,get_attribute,find_relative/ scopedfind_relative_in) and the full BiDi surface (bidi_*, including network interception). - Python binding (ctypes, runtime load): ✅ FFI marshalling + error path
(
test_ffi.py, 7 cases), ✅ surface parity (test_abi_surface.py, 43 cases), ✅ live headless Chrome (test_live_chrome.py,test_live_surface.py) — smoke, atoms, BiDi, shadow DOM, virtual authenticator, timeouts. 59 tests. - Go binding (cgo, link-time): ✅ FFI (
ffi_test.go, 5 cases), ✅ live headless Chrome (live_test.go). - Ruby binding (Fiddle, runtime load): ✅ FFI (
spec/ffi_test.rb, 5 cases), ✅ facade + convenience surface, ✅ live headless Chrome (spec/live_test.rb,spec/surface_test.rb). Stdlib only — no runtime or test gem dependencies, so it runs on a bare Ruby ≥ 3.4 (wherebase64andwebrickare no longer default gems). - Node binding (koffi / N-API, runtime load): ✅ surface parity
(
test/abi_test.js), ✅ FFI (test/ffi_test.js), ✅ live headless Chrome + surface (test/live_test.js). 29 tests. The public API is async (Promise-returning), matching mainstreamselenium-webdriver; the underlying FFI round-trip still blocks the event loop while it runs, so the live test runs its content server out-of-process — an in-process server could not answer while anawait driver.get()sits inside that blocking call. - Java binding (Panama FFM —
java.lang.foreign, no JNI/C shim): ✅ FFI, ✅ surface parity (AbiSurfaceTest), ✅ live headless Chrome + surface. 46 JUnit 5 tests, at the realorg.openqa.seleniumpackage names. Purejavac(no Maven); a tiny dependency-free JSON codec keeps it library-free. Needs a JDK ≥ 22 and--enable-native-access. - .NET binding (P/Invoke —
System.Runtime.InteropServices): ✅ FFI (7 checks), ✅ live headless Chrome + surface. UsesSystem.Text.Json; a[ModuleInitializer]DllImportResolverhandles library discovery. net8.0. - Rust binding (link-time
extern "C"+build.rs): ✅ FFI (5 cases), ✅ live headless Chrome + surface. Zero external crates — a hand-rolled JSON module + a std-only content server keep it fully offline. A consumer's rpath is propagated across the crate edge vialinks+DEP_SELENIUM_CORE_NATIVE_DIR. - Dart binding (dart:ffi, runtime load): ✅ FFI (5 cases), ✅ live headless
Chrome + surface. Synchronous (FFI blocks the isolate), so the live test runs
its content server out-of-process; the consumer self-locates its bundled
.soviaIsolate.resolvePackageUri. - BEAM family (Erlang / Elixir / Gleam): one shared C NIF (
selenium_nif) that Erlang owns and Elixir + Gleam load over the BEAM — the SAME compiled module, no second C source (exactly as the JVM family would layer over one jar).- Erlang (NIF): ✅ FFI + live headless Chrome + surface — live-verified.
- Elixir (defdelegate to the NIF): authored; ✅ verify on a box with Elixir.
- Gleam (
@externalto the NIF): authored; ✅ verify on a box with Gleam.
- Nim binding (importc + link-time): ✅ FFI + live headless Chrome +
surface. std/json;
{.passL.}links the engine with rpath. - Zig binding (
@extern+ link-time, Zig 0.16): ✅ FFI + live headless Chrome + surface. std.json; build.zig links + rpaths the engine. - D binding (
extern(C)+ link-time, dmd): ✅ FFI + live headless Chrome + surface. std.json; thedaeb SDK module (dmd -run) links + rpaths the engine. - Lua binding (Lua 5.4 C extension): ✅ FFI + live headless Chrome + surface. A real C extension (Lua has no stdlib FFI) that dlopen's the engine; hand-rolled JSON. Builds a bundled 5.4 host on boxes whose interpreter is 5.3.
- JVM family (Kotlin, Clojure): consume the ONE Java FFM jar over seamless
JVM interop — no second FFI, no second
.so(one Java jar backs the whole JVM family, exactly as one Erlang NIF backs the BEAM family).- Kotlin: ✅ FFI + live headless Chrome + surface — live-verified.
Needs Kotlin ≥ 1.9 (a modern kotlinc; Debian's 1.3 can't read JDK-22+ FFM
bytecode). Adds a
headlessChrome { }builder + element extensions. - Clojure: ✅ FFI + live headless Chrome + surface — live-verified.
Adds a
with-chromemacro + keywordby. - Groovy: a
withHeadlessChrome { }closure form. Authored here; verified on a box with a modern Groovy (≥ 4) + JDK ≥ 22 — Debian's 2.4/JVM17 can't read the FFM binding, so it skips green here.
- Kotlin: ✅ FFI + live headless Chrome + surface — live-verified.
Needs Kotlin ≥ 1.9 (a modern kotlinc; Debian's 1.3 can't read JDK-22+ FFM
bytecode). Adds a
- Haskell binding (
foreign import ccall+ link-time): FFI + live surface. Dependency-light (base + bytestring; params/values as JSON strings). Authored here; verified on a box with GHC (skips green without it). - Twenty-eight languages across nineteen FFI mechanisms all drive the
byte-identical
libselenium_core.so— see the Bindings table above for the per-language mechanism and the three runtime families (BEAM / JVM / .NET) that are each bridged only once. - Consumer install: ✅ the packaged wheel / Go module / gem / jar / NuGet
stands alone with the
.sobundled inside — clean-env install, no source tree, no env var — and drives real headless Chrome from the installed artifact (*/.example.ae). - Live browser: ✅ a real headless Chrome session driven
entirely through the pure-Aether core. The whole pipeline: {Python ctypes | Go
cgo | Ruby Fiddle} → libselenium_core.so → std.http.client → chromedriver →
Chrome.
- Smoke: newSession, get, find_element (By.ID + By.CLASS_NAME→CSS), click, send_keys, get_property, execute_script, typed NoSuchElement error, quit.
- Surface (against a local HTTP server for a real cookie/nav origin): timeouts, back/forward history, cookies (add/get/get-one/delete), window handles + set/get rect, execute_script return shapes (scalar/array/object/ args), W3C actions (a real pointer-click), and screenshots (valid PNG). Exhaustive in Python; a representative subset in Go and Ruby.
Two engine bugs found and fixed via the live test (both browser-only, invisible
to the offline probe until then): a dangling borrowed base_url FFI string in
the heap-boxed session struct (fixed with an owned copy), and a wrong JSON type
code in the error-envelope check (== 4 ARRAY instead of == 3 STRING) that
silently swallowed every WebDriver error as success — now pinned by the
response_error_code probe cases.