From c0cfe40de6acdf7ed528b49d01c5e7502e98d944 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 8 Jul 2026 09:50:23 -0500 Subject: [PATCH 1/6] [adr] script and logging async/event API on driver.script --- docs/decisions/17776-bidi-script.md | 145 ++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/decisions/17776-bidi-script.md diff --git a/docs/decisions/17776-bidi-script.md b/docs/decisions/17776-bidi-script.md new file mode 100644 index 0000000000000..d2b53e03b718f --- /dev/null +++ b/docs/decisions/17776-bidi-script.md @@ -0,0 +1,145 @@ +# 17776. The script and logging async/event API is defined on `driver.script` + +- Status: Proposed +- Discussion: https://github.com/SeleniumHQ/selenium/pull/17776 + +## Context + +`driver.script` is the high-level API for two things: running scripts, and subscribing to the +script- and log-domain events a page emits — console messages, uncaught JavaScript errors, and DOM +mutations. The bindings diverge across all of it: which capabilities exist, what each is named, what +a handler returns, and what event data reaches the user. This record fixes the whole surface and its +behavior so the bindings converge. + +| Binding | Current BiDi `script` surface | +|------------|-------------------------------| +| Java | `pin` registers a preload script (auto-runs); `execute` runs a source string; `unpin`. Console and JavaScript-error handlers as two subscriptions returning a `long` id; DOM-mutation (attributes only). No run-by-handle pin. | +| JavaScript | As Java, entirely async. | +| Python | `pin` returns a typed handle and `execute` takes source *or* that handle; no `preload`. Console and error over one filtered subscription (`int` id), in two payload shapes; DOM-mutation over attribute, child-list, and character-data. | +| Ruby | Console and JavaScript-error handlers only (one filtered subscription, `int` id); no `execute` / `pin` / `preload`, no DOM-mutation. | +| .NET | No high-level surface — only the raw low-level module. | + +## Decision + +The API covers two things, both on one accessor, `driver.script`: **running scripts** (decisions +1–3) and **subscribing to the page's console, error, and DOM-mutation events** (decisions 4–7). The +accessor is protocol-neutral — nothing below returns a BiDi type — and hosts the console and error +events itself, not a separate `log`. Each decision states what every binding provides; call shapes +are per-language idiom. + +**Running scripts** + +1. **`execute(script, *args)` runs a script and returns its result.** `script` is either a source + string or a registered handle; whether the source is resent or referenced from a prior + registration is a per-language detail. + +```ruby +driver.script.execute("return document.title") +``` +```java +driver.script().execute("return document.title"); +``` + +2. **`pin(source)` registers a script to run on demand.** It returns a handle that `execute` runs + without resending the source; a pinned script runs only when executed, and `unpin` discards it. + +```ruby +pinned = driver.script.pin("return document.title") +driver.script.execute(pinned) +driver.script.unpin(pinned) +``` +```java +PinnedScript pinned = driver.script().pin("return document.title"); +driver.script().execute(pinned); +driver.script().unpin(pinned); +``` + +3. **`preload(source)` registers a script to run automatically** — before page scripts, on the + current page and every navigation. It returns a handle that `remove_preload` discards; a preload + script runs only on its own, never by handle. + +```ruby +loaded = driver.script.preload("window.__inject = true") +driver.script.remove_preload(loaded) +``` +```java +PreloadScript loaded = driver.script().preload("window.__inject = true"); +driver.script().removePreload(loaded); +``` + +**Subscribing to events** + +4. **Console messages and JavaScript errors are separate handlers**, even though one `log` event + feeds both, so a console subscriber does not also receive stack traces. **DOM-mutation handlers + select which changes to observe** — attribute, child-list, character-data, or any combination. + +```ruby +driver.script.add_console_message_handler { |m| log(m.text) } +driver.script.add_javascript_error_handler { |e| log(e.stacktrace) } +driver.script.add_dom_mutation_handler(types: [:attributes]) { |m| log(m.attribute_name) } +``` +```java +driver.script().addConsoleMessageHandler(m -> log(m.getText())); +driver.script().addJavaScriptErrorHandler(e -> log(e.getStacktrace())); +driver.script().addDomMutationHandler(m -> log(m.getAttributeName()), MutationType.ATTRIBUTES); +``` + +5. **Registering returns an object, not a bare id.** `pin`, `preload`, and each `add_*` handler + return an object, so a registration can carry more than an identifier; the object is passed back + to its remover — `unpin`, `remove_preload`, `remove_*_handler` — to detach it. + +```ruby +handler = driver.script.add_console_message_handler { |m| log(m.text) } +driver.script.remove_console_message_handler(handler) +``` +```java +Registration handler = driver.script().addConsoleMessageHandler(m -> log(m.getText())); +driver.script().removeConsoleMessageHandler(handler); +``` + +6. **Each event carries a shaped payload — not the raw protocol entry.** It includes an explicit + source location and stack trace: console message (level, text, type, arguments, source, stack), + JavaScript error (message, source, stack), DOM mutation (target element, mutation kind, attribute + name with old and new value, added and removed nodes). + +```ruby +driver.script.add_console_message_handler { |m| log(m.level, m.text, m.source.url, m.stacktrace) } +``` +```java +driver.script().addConsoleMessageHandler(m -> + log(m.getLevel(), m.getText(), m.getSource().getUrl(), m.getStacktrace())); +``` + +7. **An uncaught exception in a handler is logged, not raised** — a passive monitor must not fail the + session or stall the page. + +```ruby +driver.script.add_console_message_handler { |m| raise "boom" } # logged; the session continues +``` +```java +driver.script().addConsoleMessageHandler(m -> { throw new RuntimeException("boom"); }); // logged +``` + +## Considered options + +- **A separate `log` accessor for the console and error events.** + - These come from the BiDi `log` domain, so a `log` accessor mirrors the protocol and reads naturally to someone thinking in spec terms. But it splits console and error away from the DOM-mutation event and the scripts they are used alongside, spreading one workflow over two objects; and `log` already means browser logs in Selenium (`manage().logs()`, log levels), so `driver.log` would be read as that. Keeping everything on `script` avoids both. +- **A single `pin` covering run-always and run-on-demand.** + - Fewer commands, and it matches what Java and JavaScript ship today. But those are the bindings that quietly took the run-always meaning while Python took run-by-handle — the shared name is what let them diverge without anyone noticing. Two named commands make the choice explicit and force each binding to say which it implements. +- **A single merged console/error handler.** + - One subscription maps directly to the `log.entryAdded` event that carries both, so it is the least to implement. But a user almost always wants console output or uncaught errors, not both interleaved, and a merged handler makes every subscriber re-filter by type; splitting moves that demultiplex into the binding, done once instead of by every user. +- **Returning a bare id from `add`.** + - It is what every binding returns now, and an integer is the least it can hand back. But once code holds an id, anything more we later want — removing through the handle, exposing what it observes, disabling it — needs a new call or parameter, where an object absorbs it behind the same return. The cost is one type per registration the binding already has internally. +- **Observing attribute mutations only.** + - It is what most bindings ship, and attribute changes are the common case. But `MutationObserver` reports child-list and character-data natively, so attributes-only discards capability the browser already provides, and Python already exposes the full set. Selecting types per handler keeps the common case a one-liner without capping the rest. +- **Exposing the raw protocol entry as the payload.** + - A direct passthrough — no shaping code, and it tracks the spec automatically. But the raw entry differs field by field across the `log` and `script` domains and omits the resolved source location and stack trace these events are usually consumed for; a shaped payload gives the same fields in every binding and extracts them once. + +## Consequences + +- Convergence is mostly reclassifying what exists: Java and JavaScript rename today's `pin` (a + preload script) to `preload`, add a run-by-handle `pin`, and return registration objects instead + of ids; Python adds `preload` and drops its second payload shape; Ruby adds `execute`, `pin`, + `preload`, and the DOM-mutation handler; .NET builds the high-level surface it lacks. +- `execute` overlaps the existing `execute_script`; whether it supersedes that method is a separate + migration decision, not settled here. From 5905691de3c6ec76fdd96eb40f52b7d2bb87d3e8 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Mon, 13 Jul 2026 17:14:21 -0500 Subject: [PATCH 2/6] [adr] clarify console/error separation, preload timing, and payload wording --- docs/decisions/17776-bidi-script.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/decisions/17776-bidi-script.md b/docs/decisions/17776-bidi-script.md index d2b53e03b718f..138b77d2ed34f 100644 --- a/docs/decisions/17776-bidi-script.md +++ b/docs/decisions/17776-bidi-script.md @@ -54,9 +54,9 @@ driver.script().execute(pinned); driver.script().unpin(pinned); ``` -3. **`preload(source)` registers a script to run automatically** — before page scripts, on the - current page and every navigation. It returns a handle that `remove_preload` discards; a preload - script runs only on its own, never by handle. +3. **`preload(source)` registers a script to run automatically** — before the page's own scripts on + every navigation, and immediately when registered on the current page. It returns a handle that + `remove_preload` discards; a preload script runs only on its own, never by handle. ```ruby loaded = driver.script.preload("window.__inject = true") @@ -70,8 +70,9 @@ driver.script().removePreload(loaded); **Subscribing to events** 4. **Console messages and JavaScript errors are separate handlers**, even though one `log` event - feeds both, so a console subscriber does not also receive stack traces. **DOM-mutation handlers - select which changes to observe** — attribute, child-list, character-data, or any combination. + feeds both — a console subscriber receives console output, not the page's uncaught errors. + **DOM-mutation handlers select which changes to observe** — attribute, child-list, + character-data, or any combination. ```ruby driver.script.add_console_message_handler { |m| log(m.text) } @@ -98,9 +99,9 @@ driver.script().removeConsoleMessageHandler(handler); ``` 6. **Each event carries a shaped payload — not the raw protocol entry.** It includes an explicit - source location and stack trace: console message (level, text, type, arguments, source, stack), - JavaScript error (message, source, stack), DOM mutation (target element, mutation kind, attribute - name with old and new value, added and removed nodes). + source location and stack trace: console message (level, text, type, arguments, source, stack + trace), JavaScript error (message, source, stack trace), DOM mutation (target element, mutation + kind, attribute name with old and new value, added and removed nodes). ```ruby driver.script.add_console_message_handler { |m| log(m.level, m.text, m.source.url, m.stacktrace) } From 309ef0074edc7f3c86b9caae029b3ccdc6b0c7ef Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Mon, 13 Jul 2026 17:20:21 -0500 Subject: [PATCH 3/6] [adr] specify DOM-mutation payload by kind and default observed types --- docs/decisions/17776-bidi-script.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/decisions/17776-bidi-script.md b/docs/decisions/17776-bidi-script.md index 138b77d2ed34f..cb68023106b44 100644 --- a/docs/decisions/17776-bidi-script.md +++ b/docs/decisions/17776-bidi-script.md @@ -72,7 +72,7 @@ driver.script().removePreload(loaded); 4. **Console messages and JavaScript errors are separate handlers**, even though one `log` event feeds both — a console subscriber receives console output, not the page's uncaught errors. **DOM-mutation handlers select which changes to observe** — attribute, child-list, - character-data, or any combination. + character-data, or any combination; with none named, all are observed. ```ruby driver.script.add_console_message_handler { |m| log(m.text) } @@ -101,7 +101,8 @@ driver.script().removeConsoleMessageHandler(handler); 6. **Each event carries a shaped payload — not the raw protocol entry.** It includes an explicit source location and stack trace: console message (level, text, type, arguments, source, stack trace), JavaScript error (message, source, stack trace), DOM mutation (target element, mutation - kind, attribute name with old and new value, added and removed nodes). + kind, and the fields for that kind — an attribute name with its old and new value, the old and new + character data, or the added and removed nodes). ```ruby driver.script.add_console_message_handler { |m| log(m.level, m.text, m.source.url, m.stacktrace) } From 7437fd95128f350bf6abcb5c9ca5735dab274869 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 13 Aug 2026 08:45:32 -0500 Subject: [PATCH 4/6] [adr] script: add scoping and add_event_handler, refine pin/execute and payloads --- docs/decisions/17776-bidi-script.md | 150 +++++++++++++++++++--------- 1 file changed, 103 insertions(+), 47 deletions(-) diff --git a/docs/decisions/17776-bidi-script.md b/docs/decisions/17776-bidi-script.md index cb68023106b44..4f9628fb6d43a 100644 --- a/docs/decisions/17776-bidi-script.md +++ b/docs/decisions/17776-bidi-script.md @@ -22,16 +22,19 @@ behavior so the bindings converge. ## Decision The API covers two things, both on one accessor, `driver.script`: **running scripts** (decisions -1–3) and **subscribing to the page's console, error, and DOM-mutation events** (decisions 4–7). The -accessor is protocol-neutral — nothing below returns a BiDi type — and hosts the console and error -events itself, not a separate `log`. Each decision states what every binding provides; call shapes -are per-language idiom. +1–2) and **subscribing to page events** — console messages, uncaught errors, DOM mutations, and any +event a page-side script defines (decisions 3–7); a final decision (8) scopes every one of them to a +context. The accessor is protocol-neutral — nothing +below returns a BiDi type — and hosts the console and error events itself, not a separate `log`. Each +decision states what every binding provides; call shapes are per-language idiom. **Running scripts** -1. **`execute(script, *args)` runs a script and returns its result.** `script` is either a source - string or a registered handle; whether the source is resent or referenced from a prior - registration is a per-language detail. +1. **`execute(script, *args)` runs a script and returns its result — there is no `execute_async`.** + `script` is a source string or a handle from `pin`. A returned promise is awaited, so one method + covers both sync and async, and async-versus-sync is a per-language return-type detail, not a + second method. Passing a handle invokes the pinned script without resending its source — the + original point of pinning — so a large atom travels once and each call carries only the invocation. ```ruby driver.script.execute("return document.title") @@ -40,36 +43,29 @@ driver.script.execute("return document.title") driver.script().execute("return document.title"); ``` -2. **`pin(source)` registers a script to run on demand.** It returns a handle that `execute` runs - without resending the source; a pinned script runs only when executed, and `unpin` discards it. +2. **`pin(source)` registers a script the browser keeps on every future page** — run before the + page's own scripts on each navigation and in every new browsing context for the rest of the + session. It does not apply to the already-loaded page; `execute` runs things now. It returns a + handle: pass it to `execute` to invoke the pinned script without resending the source, or to + `unpin` to discard it. A pinned callable is defined on each load and invoked on demand through its + handle; a self-running script simply takes effect on each load. ```ruby -pinned = driver.script.pin("return document.title") -driver.script.execute(pinned) -driver.script.unpin(pinned) +displayed = driver.script.pin("(el) => el.offsetParent !== null") # the atom rides on every future page +driver.get("https://example.com") +driver.script.execute(displayed, driver.find_element(id: "menu")) # invoked by handle; atom not resent +driver.script.unpin(displayed) ``` ```java -PinnedScript pinned = driver.script().pin("return document.title"); -driver.script().execute(pinned); -driver.script().unpin(pinned); -``` - -3. **`preload(source)` registers a script to run automatically** — before the page's own scripts on - every navigation, and immediately when registered on the current page. It returns a handle that - `remove_preload` discards; a preload script runs only on its own, never by handle. - -```ruby -loaded = driver.script.preload("window.__inject = true") -driver.script.remove_preload(loaded) -``` -```java -PreloadScript loaded = driver.script().preload("window.__inject = true"); -driver.script().removePreload(loaded); +PinnedScript displayed = driver.script().pin("(el) => el.offsetParent !== null"); +driver.get("https://example.com"); +driver.script().execute(displayed, driver.findElement(By.id("menu"))); +driver.script().unpin(displayed); ``` **Subscribing to events** -4. **Console messages and JavaScript errors are separate handlers**, even though one `log` event +3. **Console messages and JavaScript errors are separate handlers**, even though one `log` event feeds both — a console subscriber receives console output, not the page's uncaught errors. **DOM-mutation handlers select which changes to observe** — attribute, child-list, character-data, or any combination; with none named, all are observed. @@ -85,9 +81,31 @@ driver.script().addJavaScriptErrorHandler(e -> log(e.getStacktrace())); driver.script().addDomMutationHandler(m -> log(m.getAttributeName()), MutationType.ATTRIBUTES); ``` -5. **Registering returns an object, not a bare id.** `pin`, `preload`, and each `add_*` handler - return an object, so a registration can carry more than an identifier; the object is passed back - to its remover — `unpin`, `remove_preload`, `remove_*_handler` — to detach it. +4. **`add_event_handler(name, script)` subscribes to events a page-side script defines.** The script + is handed a callback; each call delivers its argument to the handler unchanged — the raw value the + script emitted, not a shaped payload. This is the general mechanism the DOM-mutation handler is a + specialization of, exposed for events no binding pre-defines. The handler runs the script now and, + unless limited to the current context, keeps it on every future load so it survives navigation; + `remove_event_handler` detaches the subscription and stops future loads. + +```ruby +h = driver.script.add_event_handler("paint", + "(emit) => new PerformanceObserver(l => emit(l.getEntries())).observe({type: 'paint'})") do |entries| + log(entries) +end +driver.script.remove_event_handler(h) +``` +```java +Registration h = driver.script().addEventHandler("paint", + "(emit) => new PerformanceObserver(l => emit(l.getEntries())).observe({type: 'paint'})", + entries -> log(entries)); +driver.script().removeEventHandler(h); +``` + +5. **Registering returns an object, not a bare id.** `pin` and each `add_*` handler return an object, + so a registration can carry more than an identifier; the object is passed back to its remover — + `unpin`, `remove_*_handler` — to detach it, and `pin`'s object is also what `execute` takes to run + the pinned script. ```ruby handler = driver.script.add_console_message_handler { |m| log(m.text) } @@ -98,18 +116,23 @@ Registration handler = driver.script().addConsoleMessageHandler(m -> log(m.getTe driver.script().removeConsoleMessageHandler(handler); ``` -6. **Each event carries a shaped payload — not the raw protocol entry.** It includes an explicit - source location and stack trace: console message (level, text, type, arguments, source, stack - trace), JavaScript error (message, source, stack trace), DOM mutation (target element, mutation - kind, and the fields for that kind — an attribute name with its old and new value, the old and new - character data, or the added and removed nodes). +6. **Each event carries a shaped payload — not the raw protocol entry — and every payload names its + origin.** The origin is the source realm (always present) and the browsing context (present when + the event has one: window-backed events carry it, while shared- and service-worker events do not + and are placed by realm plus — once the protocol requires it — user context). Beyond origin, a + console message carries level, text, type, and arguments; a JavaScript error carries its message + and stack trace; a DOM mutation carries the target element, the mutation kind, and the fields for + that kind — an attribute name with its old and new value, the old and new character data, or the + added and removed nodes. Only the JavaScript error carries a stack trace; a console message does + not. ```ruby -driver.script.add_console_message_handler { |m| log(m.level, m.text, m.source.url, m.stacktrace) } +driver.script.add_console_message_handler { |m| log(m.level, m.text, m.context) } +driver.script.add_javascript_error_handler { |e| log(e.message, e.stacktrace) } ``` ```java -driver.script().addConsoleMessageHandler(m -> - log(m.getLevel(), m.getText(), m.getSource().getUrl(), m.getStacktrace())); +driver.script().addConsoleMessageHandler(m -> log(m.getLevel(), m.getText(), m.getContext())); +driver.script().addJavaScriptErrorHandler(e -> log(e.getMessage(), e.getStacktrace())); ``` 7. **An uncaught exception in a handler is logged, not raised** — a passive monitor must not fail the @@ -122,12 +145,32 @@ driver.script.add_console_message_handler { |m| raise "boom" } # logged; the s driver.script().addConsoleMessageHandler(m -> { throw new RuntimeException("boom"); }); // logged ``` +**Scoping** + +8. **Every operation is scoped to a context.** Running a script, pinning a script, and adding a + handler default to the current browsing context and accept an optional scope — another browsing + context, or a user context — so a call can target a background tab or a whole profile. There is no + separate worker parameter: a dedicated worker is reached through the browsing context that owns + it, and shared and service workers, which have no browsing context of their own, are reached + through the user context. + +```ruby +tab = driver.window_handle +driver.script.execute("return document.title", context: tab) +driver.script.add_console_message_handler(context: tab) { |m| log(m.text) } +``` +```java +String tab = driver.getWindowHandle(); +driver.script().execute("return document.title", tab); +driver.script().addConsoleMessageHandler(tab, m -> log(m.getText())); +``` + ## Considered options - **A separate `log` accessor for the console and error events.** - These come from the BiDi `log` domain, so a `log` accessor mirrors the protocol and reads naturally to someone thinking in spec terms. But it splits console and error away from the DOM-mutation event and the scripts they are used alongside, spreading one workflow over two objects; and `log` already means browser logs in Selenium (`manage().logs()`, log levels), so `driver.log` would be read as that. Keeping everything on `script` avoids both. -- **A single `pin` covering run-always and run-on-demand.** - - Fewer commands, and it matches what Java and JavaScript ship today. But those are the bindings that quietly took the run-always meaning while Python took run-by-handle — the shared name is what let them diverge without anyone noticing. Two named commands make the choice explicit and force each binding to say which it implements. +- **A separate `preload` command alongside `pin`.** + - It looks like two behaviors — `pin` a script to call by handle, versus `preload` one that runs on every page — so two names seem to fit, and today's bindings lean that way (Java and JavaScript `pin` auto-runs, Python `pin` is a handle). But it is one mechanism: `pin` keeps a script on every page, and whether it runs on its own or waits to be called is a property of the script, not the command — a pinned helper that only defines a function is exactly run-on-demand, invoked through `execute`. A second command would split one concept across two names for no behavior the first cannot express. - **A single merged console/error handler.** - One subscription maps directly to the `log.entryAdded` event that carries both, so it is the least to implement. But a user almost always wants console output or uncaught errors, not both interleaved, and a merged handler makes every subscriber re-filter by type; splitting moves that demultiplex into the binding, done once instead of by every user. - **Returning a bare id from `add`.** @@ -135,13 +178,26 @@ driver.script().addConsoleMessageHandler(m -> { throw new RuntimeException("boom - **Observing attribute mutations only.** - It is what most bindings ship, and attribute changes are the common case. But `MutationObserver` reports child-list and character-data natively, so attributes-only discards capability the browser already provides, and Python already exposes the full set. Selecting types per handler keeps the common case a one-liner without capping the rest. - **Exposing the raw protocol entry as the payload.** - - A direct passthrough — no shaping code, and it tracks the spec automatically. But the raw entry differs field by field across the `log` and `script` domains and omits the resolved source location and stack trace these events are usually consumed for; a shaped payload gives the same fields in every binding and extracts them once. + - A direct passthrough — no shaping code, and it tracks the spec automatically. But the raw entry differs field by field across the `log` and `script` domains and leaves the consumer to resolve the origin and the fields these events are read for; a shaped payload gives the same fields in every binding and extracts them once. +- **A separate worker or realm scope, alongside browsing and user context.** + - Workers are where scripts and logs often run, so a dedicated worker or realm coordinate looks like the natural way to target them. But the protocol scopes subscriptions only by browsing context and user context: a dedicated worker rides the browsing context that owns it, and shared and service workers ride the user context. A third coordinate would model an address the wire does not have, and the two contexts already reach every realm. ## Consequences -- Convergence is mostly reclassifying what exists: Java and JavaScript rename today's `pin` (a - preload script) to `preload`, add a run-by-handle `pin`, and return registration objects instead - of ids; Python adds `preload` and drops its second payload shape; Ruby adds `execute`, `pin`, - `preload`, and the DOM-mutation handler; .NET builds the high-level surface it lacks. +- Convergence is mostly reclassifying what exists: Java, JavaScript, and Python keep `pin` but return + a registration object instead of a bare id and settle on one behavior — a script the browser keeps + on every page, invoked through `execute` when it only defines a callable; Python also drops its + second payload shape; Ruby adds `execute`, `pin`, and the DOM-mutation handler; .NET builds the + high-level surface it lacks. +- `add_event_handler` is new surface every binding adds; the DOM-mutation handler becomes its first + built-in specialization (a pinned observer plus payload shaping) rather than a separate mechanism, + and custom async events no longer force a drop to the low-level module. - `execute` overlaps the existing `execute_script`; whether it supersedes that method is a separate migration decision, not settled here. +- Scoping adds a parameter, not just a rename: every binding threads an optional browsing-context or + user-context scope through `execute`, `pin`, and the handlers, where most expose only the current + context today. +- Preload removal is future-only: `unpin` (and detaching a handler) stops the script from arming + later loads but does not undo what already ran — an observer injected into the current page keeps + running until navigation. A handler that must stop cleanly on the live page has to build teardown + into the injected script; the record does not promise retroactive removal. From 67d8d4ee657bd2529b9f0d8b2aea48b50ac91f3a Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 14 Aug 2026 16:09:04 -0500 Subject: [PATCH 5/6] [adr] script: scope events to a window handle and execute to a realm --- docs/decisions/17776-bidi-script.md | 57 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/docs/decisions/17776-bidi-script.md b/docs/decisions/17776-bidi-script.md index 4f9628fb6d43a..4b58423cc65d6 100644 --- a/docs/decisions/17776-bidi-script.md +++ b/docs/decisions/17776-bidi-script.md @@ -23,8 +23,8 @@ behavior so the bindings converge. The API covers two things, both on one accessor, `driver.script`: **running scripts** (decisions 1–2) and **subscribing to page events** — console messages, uncaught errors, DOM mutations, and any -event a page-side script defines (decisions 3–7); a final decision (8) scopes every one of them to a -context. The accessor is protocol-neutral — nothing +event a page-side script defines (decisions 3–7); a final decision (8) says where each one runs — a +window handle for events, a realm for `execute`. The accessor is protocol-neutral — nothing below returns a BiDi type — and hosts the console and error events itself, not a separate `log`. Each decision states what every binding provides; call shapes are per-language idiom. @@ -116,22 +116,22 @@ Registration handler = driver.script().addConsoleMessageHandler(m -> log(m.getTe driver.script().removeConsoleMessageHandler(handler); ``` -6. **Each event carries a shaped payload — not the raw protocol entry — and every payload names its - origin.** The origin is the source realm (always present) and the browsing context (present when - the event has one: window-backed events carry it, while shared- and service-worker events do not - and are placed by realm plus — once the protocol requires it — user context). Beyond origin, a - console message carries level, text, type, and arguments; a JavaScript error carries its message - and stack trace; a DOM mutation carries the target element, the mutation kind, and the fields for - that kind — an attribute name with its old and new value, the old and new character data, or the - added and removed nodes. Only the JavaScript error carries a stack trace; a console message does - not. +6. **Each event the API defines carries a shaped payload — not the raw protocol entry — and every + payload names its origin.** The origin is the source realm (always present) and the window handle + it belongs to (present for a tab-level event; a worker-realm event carries the realm alone). Beyond + origin, a console message carries level, text, type, and arguments; a JavaScript error carries its + message and stack trace; a DOM mutation carries the target element, the mutation kind, and the + fields for that kind — an attribute name with its old and new value, the old and new character + data, or the added and removed nodes. Only the JavaScript error carries a stack trace; a console + message does not. `add_event_handler` is the exception to the shaping: it delivers whatever its + script emits, unshaped (decision 4). ```ruby -driver.script.add_console_message_handler { |m| log(m.level, m.text, m.context) } +driver.script.add_console_message_handler { |m| log(m.level, m.text, m.realm) } driver.script.add_javascript_error_handler { |e| log(e.message, e.stacktrace) } ``` ```java -driver.script().addConsoleMessageHandler(m -> log(m.getLevel(), m.getText(), m.getContext())); +driver.script().addConsoleMessageHandler(m -> log(m.getLevel(), m.getText(), m.getRealm())); driver.script().addJavaScriptErrorHandler(e -> log(e.getMessage(), e.getStacktrace())); ``` @@ -147,22 +147,19 @@ driver.script().addConsoleMessageHandler(m -> { throw new RuntimeException("boom **Scoping** -8. **Every operation is scoped to a context.** Running a script, pinning a script, and adding a - handler default to the current browsing context and accept an optional scope — another browsing - context, or a user context — so a call can target a background tab or a whole profile. There is no - separate worker parameter: a dedicated worker is reached through the browsing context that owns - it, and shared and service workers, which have no browsing context of their own, are reached - through the user context. +8. **Events attach to a window handle; `execute` runs in a realm.** A handler defaults to the current + top-level navigable and takes an optional **window handle** to watch another tab. `execute` + defaults to the current realm and takes an optional **realm** — a frame, a web worker, or a + service worker, not only a tab — since a script can run wherever one of those exists. How window + handles, realms, and user contexts relate more fully is out of scope for this record. ```ruby -tab = driver.window_handle -driver.script.execute("return document.title", context: tab) -driver.script.add_console_message_handler(context: tab) { |m| log(m.text) } +driver.script.add_console_message_handler(window_handle: tab) { |m| log(m.text) } # tab: a window handle +driver.script.execute("return self.location.href", realm: worker) # realm: frame/worker/tab ``` ```java -String tab = driver.getWindowHandle(); -driver.script().execute("return document.title", tab); -driver.script().addConsoleMessageHandler(tab, m -> log(m.getText())); +driver.script().addConsoleMessageHandler(tab, m -> log(m.getText())); // tab: a window handle +driver.script().execute("return self.location.href", worker); // worker: a frame/worker/tab realm ``` ## Considered options @@ -179,8 +176,8 @@ driver.script().addConsoleMessageHandler(tab, m -> log(m.getText())); - It is what most bindings ship, and attribute changes are the common case. But `MutationObserver` reports child-list and character-data natively, so attributes-only discards capability the browser already provides, and Python already exposes the full set. Selecting types per handler keeps the common case a one-liner without capping the rest. - **Exposing the raw protocol entry as the payload.** - A direct passthrough — no shaping code, and it tracks the spec automatically. But the raw entry differs field by field across the `log` and `script` domains and leaves the consumer to resolve the origin and the fields these events are read for; a shaped payload gives the same fields in every binding and extracts them once. -- **A separate worker or realm scope, alongside browsing and user context.** - - Workers are where scripts and logs often run, so a dedicated worker or realm coordinate looks like the natural way to target them. But the protocol scopes subscriptions only by browsing context and user context: a dedicated worker rides the browsing context that owns it, and shared and service workers ride the user context. A third coordinate would model an address the wire does not have, and the two contexts already reach every realm. +- **A single `context` argument shared by `execute` and the handlers.** + - One scope parameter everywhere is simplest to describe. But the two targets differ: an event attaches to a top-level navigable — a tab — while `execute` can run in a frame, a web worker, or a service worker, which are realms. Collapsing both into one `context` blurs a distinction users have to get right, so events take a window handle and `execute` takes a realm. ## Consequences @@ -194,9 +191,9 @@ driver.script().addConsoleMessageHandler(tab, m -> log(m.getText())); and custom async events no longer force a drop to the low-level module. - `execute` overlaps the existing `execute_script`; whether it supersedes that method is a separate migration decision, not settled here. -- Scoping adds a parameter, not just a rename: every binding threads an optional browsing-context or - user-context scope through `execute`, `pin`, and the handlers, where most expose only the current - context today. +- Scoping adds a parameter, not just a rename: handlers take an optional window handle and `execute` + an optional realm, where most bindings expose only the current one today. How window handles, + realms, and user contexts relate is out of scope here. - Preload removal is future-only: `unpin` (and detaching a handler) stops the script from arming later loads but does not undo what already ran — an observer injected into the current page keeps running until navigation. A handler that must stop cleanly on the live page has to build teardown From 7370d6413b8e65cf9ccd246e63609bde2cb43b3a Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 21 Aug 2026 09:18:47 -0500 Subject: [PATCH 6/6] [adr] script: target by window handle not realm, add sandbox, defer workers --- docs/decisions/17776-bidi-script.md | 89 +++++++++++++++++++---------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/docs/decisions/17776-bidi-script.md b/docs/decisions/17776-bidi-script.md index 4b58423cc65d6..e05aea1b33af0 100644 --- a/docs/decisions/17776-bidi-script.md +++ b/docs/decisions/17776-bidi-script.md @@ -23,8 +23,8 @@ behavior so the bindings converge. The API covers two things, both on one accessor, `driver.script`: **running scripts** (decisions 1–2) and **subscribing to page events** — console messages, uncaught errors, DOM mutations, and any -event a page-side script defines (decisions 3–7); a final decision (8) says where each one runs — a -window handle for events, a realm for `execute`. The accessor is protocol-neutral — nothing +event a page-side script defines (decisions 3–7); decision 8 scopes each by window handle, and +decision 9 can isolate a script in a sandbox. The accessor is protocol-neutral — nothing below returns a BiDi type — and hosts the console and error events itself, not a separate `log`. Each decision states what every binding provides; call shapes are per-language idiom. @@ -84,9 +84,10 @@ driver.script().addDomMutationHandler(m -> log(m.getAttributeName()), MutationTy 4. **`add_event_handler(name, script)` subscribes to events a page-side script defines.** The script is handed a callback; each call delivers its argument to the handler unchanged — the raw value the script emitted, not a shaped payload. This is the general mechanism the DOM-mutation handler is a - specialization of, exposed for events no binding pre-defines. The handler runs the script now and, - unless limited to the current context, keeps it on every future load so it survives navigation; - `remove_event_handler` detaches the subscription and stops future loads. + specialization of, exposed for events no binding pre-defines. The handler runs the script now and + by default re-arms it on every future load in the same window so it survives navigation; an option + confines it to the current document. `remove_event_handler` detaches the subscription and stops + future loads. ```ruby h = driver.script.add_event_handler("paint", @@ -117,21 +118,20 @@ driver.script().removeConsoleMessageHandler(handler); ``` 6. **Each event the API defines carries a shaped payload — not the raw protocol entry — and every - payload names its origin.** The origin is the source realm (always present) and the window handle - it belongs to (present for a tab-level event; a worker-realm event carries the realm alone). Beyond - origin, a console message carries level, text, type, and arguments; a JavaScript error carries its - message and stack trace; a DOM mutation carries the target element, the mutation kind, and the - fields for that kind — an attribute name with its old and new value, the old and new character - data, or the added and removed nodes. Only the JavaScript error carries a stack trace; a console - message does not. `add_event_handler` is the exception to the shaping: it delivers whatever its - script emits, unshaped (decision 4). + payload names the window handle it came from.** A console message carries level, text, type, and + arguments; a JavaScript error carries its message and stack trace; a DOM mutation carries the + target element, the mutation kind, and the fields for that kind — an attribute name with its old + and new value, the old and new character data, or the added and removed nodes. A field the mutation + has no counterpart for — the old value of a newly added attribute — is absent, not a sentinel. Only + the JavaScript error carries a stack trace; a console message does not. `add_event_handler` is the + exception to the shaping: it delivers whatever its script emits, unshaped (decision 4). ```ruby -driver.script.add_console_message_handler { |m| log(m.level, m.text, m.realm) } +driver.script.add_console_message_handler { |m| log(m.level, m.text, m.window_handle) } driver.script.add_javascript_error_handler { |e| log(e.message, e.stacktrace) } ``` ```java -driver.script().addConsoleMessageHandler(m -> log(m.getLevel(), m.getText(), m.getRealm())); +driver.script().addConsoleMessageHandler(m -> log(m.getLevel(), m.getText(), m.getWindowHandle())); driver.script().addJavaScriptErrorHandler(e -> log(e.getMessage(), e.getStacktrace())); ``` @@ -147,19 +147,42 @@ driver.script().addConsoleMessageHandler(m -> { throw new RuntimeException("boom **Scoping** -8. **Events attach to a window handle; `execute` runs in a realm.** A handler defaults to the current - top-level navigable and takes an optional **window handle** to watch another tab. `execute` - defaults to the current realm and takes an optional **realm** — a frame, a web worker, or a - service worker, not only a tab — since a script can run wherever one of those exists. How window - handles, realms, and user contexts relate more fully is out of scope for this record. +8. **A window handle narrows an operation to one tab; without it each takes its natural scope.** Pass + a **window handle** and a handler watches just that tab, `execute` runs in it, or a pinned script + arms only that tab's future loads. Omit it and `pin` and the handlers apply to every tab, present + and future — a pinned atom belongs on every page and a subscription on every context — while + `execute`, which returns a single result, runs in the current context. So applying a pinned script + everywhere is the default and the window handle is the narrowing, not the other way round. The + handle is a named argument, distinct from a script's positional args so a binding never mistakes it + for one, and it survives navigation so a scope the user holds stays valid. Reaching a worker or + worklet, which has no window handle, and how window handles relate to user contexts, are left to a + separate record. ```ruby -driver.script.add_console_message_handler(window_handle: tab) { |m| log(m.text) } # tab: a window handle -driver.script.execute("return self.location.href", realm: worker) # realm: frame/worker/tab +driver.script.pin("(el) => el.offsetParent !== null") # every tab, present and future +driver.script.pin("(el) => el.offsetParent !== null", window_handle: tab) # only this tab's future loads +driver.script.add_console_message_handler(window_handle: tab) { |m| log(m.text) } # just this tab ``` ```java -driver.script().addConsoleMessageHandler(tab, m -> log(m.getText())); // tab: a window handle -driver.script().execute("return self.location.href", worker); // worker: a frame/worker/tab realm +driver.script().pin("(el) => el.offsetParent !== null"); // every tab +driver.script().pin("(el) => el.offsetParent !== null", otherTab); // only that tab +``` + +**Sandbox** + +9. **`execute` and `pin` accept an optional sandbox — an isolated world.** A sandbox shares the page's + DOM but keeps its own globals, so an injected script neither reads the page's own scripts nor + collides with their names. Naming one creates it on first use; it has no separate lifecycle and + goes away with its context, so it is a plain named argument, not an object to close. + `add_event_handler`, which leaves a script and an `emit` callback on the page for the session, is + the case that most wants it. + +```ruby +driver.script.execute("window.__probe ??= performance.now()", sandbox: "selenium") +driver.script.add_event_handler("paint", paint_script, sandbox: "selenium") { |e| log(e) } +``` +```java +driver.script().pin(paintScript, Sandbox.named("selenium")); ``` ## Considered options @@ -176,8 +199,8 @@ driver.script().execute("return self.location.href", worker); // wo - It is what most bindings ship, and attribute changes are the common case. But `MutationObserver` reports child-list and character-data natively, so attributes-only discards capability the browser already provides, and Python already exposes the full set. Selecting types per handler keeps the common case a one-liner without capping the rest. - **Exposing the raw protocol entry as the payload.** - A direct passthrough — no shaping code, and it tracks the spec automatically. But the raw entry differs field by field across the `log` and `script` domains and leaves the consumer to resolve the origin and the fields these events are read for; a shaped payload gives the same fields in every binding and extracts them once. -- **A single `context` argument shared by `execute` and the handlers.** - - One scope parameter everywhere is simplest to describe. But the two targets differ: an event attaches to a top-level navigable — a tab — while `execute` can run in a frame, a web worker, or a service worker, which are realms. Collapsing both into one `context` blurs a distinction users have to get right, so events take a window handle and `execute` takes a realm. +- **Targeting `execute` (and event origins) by realm.** + - The protocol runs scripts against realms, so a `realm` argument mirrors it and can name a worker directly. But a realm id is a BiDi type, which this record's boundary keeps off the surface, and it is not durable: the remote end tears realms down on navigation, so a stored realm goes stale while a pinned script — the very thing you would run in it — survives. A window handle survives navigation and is something the user already holds, so it is the durable target. Workers, which have no handle, are left to a separate record rather than forced through the same argument, which also spares the payload an asymmetric "worker events carry only a realm" case. ## Consequences @@ -189,11 +212,15 @@ driver.script().execute("return self.location.href", worker); // wo - `add_event_handler` is new surface every binding adds; the DOM-mutation handler becomes its first built-in specialization (a pinned observer plus payload shaping) rather than a separate mechanism, and custom async events no longer force a drop to the low-level module. -- `execute` overlaps the existing `execute_script`; whether it supersedes that method is a separate - migration decision, not settled here. -- Scoping adds a parameter, not just a rename: handlers take an optional window handle and `execute` - an optional realm, where most bindings expose only the current one today. How window handles, - realms, and user contexts relate is out of scope here. +- `execute` overlaps the existing `execute_script`; whether to supersede it is still open, and if we + do it follows the normal deprecation policy, not a special migration. +- Scoping adds a window-handle parameter to handlers, `execute`, and `pin`, where most bindings expose + only the current context today. Reaching workers and the fuller window-handle/user-context model are + out of scope here. Python already exposes a realm id on `ScriptResult` and `PinnedScript`; keeping + realm off the surface means walking those back under the deprecation policy before parity work + copies them into Java and Ruby. +- Sandbox is additive: bindings pass an optional sandbox name to `execute` and `pin`, and injected + handlers should default to one so instrumentation stays out of the page's own world. - Preload removal is future-only: `unpin` (and detaching a handler) stops the script from arming later loads but does not undo what already ran — an observer injected into the current page keeps running until navigation. A handler that must stop cleanly on the live page has to build teardown