From cb38f24f80775506136f08b7248c29401c306001 Mon Sep 17 00:00:00 2001 From: Puja Jagani Date: Tue, 28 Jul 2026 18:26:14 +0530 Subject: [PATCH 01/56] [js] Ensure BiDi is not exposed on driver Related to #17814 --- .../selenium-webdriver/lib/webdriver.js | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/javascript/selenium-webdriver/lib/webdriver.js b/javascript/selenium-webdriver/lib/webdriver.js index b2059de816491..dc67225c6341d 100644 --- a/javascript/selenium-webdriver/lib/webdriver.js +++ b/javascript/selenium-webdriver/lib/webdriver.js @@ -35,6 +35,7 @@ const http = require('../http/index') const fs = require('node:fs') const { Capabilities } = require('./capabilities') const path = require('node:path') +const util = require('node:util') const { NoSuchElementError } = require('./error') const cdpTargets = ['page', 'browser'] const { Credential } = require('./virtual_authenticator') @@ -1302,10 +1303,13 @@ class WebDriver { } /** - * Initiates bidi connection using 'webSocketUrl' - * @returns {BIDI} + * Initiates bidi connection using 'webSocketUrl'. Internal implementation + * backing the deprecated {@link WebDriver#getBidi}; composed BiDi modules + * (bidi/*.js factories, generated `.create(driver)` classes) call + * this directly so they don't trip the deprecation warning on that method. + * @returns {Promise} */ - async getBidi() { + async _getBidiConnection() { if (this._bidiConnection === undefined) { const caps = await this.getCapabilities() let WebSocketUrl = caps['map_'].get('webSocketUrl') @@ -1778,6 +1782,24 @@ class WebDriver { } } +/** + * Returns the WebDriver BiDi connection for this session. + * + * @deprecated BiDi is an internal implementation detail (see + * docs/decisions/17670-bidi-implementation-boundaries.md) — this accessor hands + * back the raw transport directly, which is no longer supported public API. + * Use a composed BiDi module instead, e.g. `Network.create(driver)` or + * `require('selenium-webdriver/bidi/network')`. + * @function + * @name WebDriver#getBidi + * @returns {Promise} + */ +WebDriver.prototype.getBidi = util.deprecate( + WebDriver.prototype._getBidiConnection, + 'WebDriver#getBidi() is deprecated. Use a composed BiDi module instead, e.g. Network.create(driver) or ' + + "require('selenium-webdriver/bidi/network'). See docs/decisions/17670-bidi-implementation-boundaries.md.", +) + /** * Interface for navigating back and forth in the browser history. * From a9f2bc549dcd979d27241404dcd21c7e8b3e1329 Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:25:01 -0400 Subject: [PATCH 02/56] [py] Include generated files in API docs published on Read the Docs (#17794) --- py/docs/.readthedocs.yaml | 46 ++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/py/docs/.readthedocs.yaml b/py/docs/.readthedocs.yaml index f42dd933bb524..c3655d0eff3e3 100644 --- a/py/docs/.readthedocs.yaml +++ b/py/docs/.readthedocs.yaml @@ -11,11 +11,45 @@ build: os: ubuntu-24.04 tools: python: "3.12" + commands: - - pip install -r py/requirements_lock.txt - - cd py && python3 generate_api_module_listing.py && cd - - PYTHONPATH=py sphinx-autogen -o $READTHEDOCS_OUTPUT/html py/docs/source/api.rst - - PYTHONPATH=py sphinx-build -b html -d build/docs/doctrees py/docs/source $READTHEDOCS_OUTPUT/html + - | + set -euo pipefail + + REPO_DIR="$(pwd)" + PACKAGE="selenium" + VERSION="$( + curl -fsSL "https://test.pypi.org/pypi/${PACKAGE}/json" | + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | + head -n1 + )" + URL="$( + curl -fsSL "https://test.pypi.org/pypi/${PACKAGE}/${VERSION}/json" | + sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\.tar\.gz\)".*/\1/p' | + head -n1 + )" + SDIST="$(basename "${URL}")" + + # install dependencies + pip install -r "${REPO_DIR}/py/requirements_lock.txt" + + # fetch latest nightly sdist tarball from TestPyPI + curl -fsSL -o "${SDIST}" "${URL}" + + # overlay packaged/generated code from the sdist into the repo checkout + tar -xzf "${SDIST}" + cp -a "${SDIST%.tar.gz}/selenium/." "${REPO_DIR}/py/${PACKAGE}/" + + # remove extracted sdist contents + rm -rf "${SDIST%.tar.gz}" "${SDIST}" + + # generate new .rst with API modules + cd "${REPO_DIR}/py" && python3 "generate_api_module_listing.py" && cd .. + + export PYTHONPATH="${REPO_DIR}/py:${PYTHONPATH:-}" + + # generate doc stubs + sphinx-autogen -o "${READTHEDOCS_OUTPUT}/html" "py/docs/source/api.rst" -sphinx: - configuration: py/docs/source/conf.py + # build docs + sphinx-build -b html -d build/docs/doctrees py/docs/source ${READTHEDOCS_OUTPUT}/html From 02934469fb97502f3f823e0e049260675e367880 Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:43:18 -0400 Subject: [PATCH 03/56] [py] Fix docs build for RtD (#17830) --- py/docs/.readthedocs.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/py/docs/.readthedocs.yaml b/py/docs/.readthedocs.yaml index c3655d0eff3e3..34e06e590f077 100644 --- a/py/docs/.readthedocs.yaml +++ b/py/docs/.readthedocs.yaml @@ -14,8 +14,6 @@ build: commands: - | - set -euo pipefail - REPO_DIR="$(pwd)" PACKAGE="selenium" VERSION="$( From 3760c6c1703df72afb256cf2d2b57a9e2aa7395f Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:15:03 -0400 Subject: [PATCH 04/56] [py] Revert RtD doc build changes (#17831) --- py/docs/.readthedocs.yaml | 44 ++++++--------------------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/py/docs/.readthedocs.yaml b/py/docs/.readthedocs.yaml index 34e06e590f077..f42dd933bb524 100644 --- a/py/docs/.readthedocs.yaml +++ b/py/docs/.readthedocs.yaml @@ -11,43 +11,11 @@ build: os: ubuntu-24.04 tools: python: "3.12" - commands: - - | - REPO_DIR="$(pwd)" - PACKAGE="selenium" - VERSION="$( - curl -fsSL "https://test.pypi.org/pypi/${PACKAGE}/json" | - sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | - head -n1 - )" - URL="$( - curl -fsSL "https://test.pypi.org/pypi/${PACKAGE}/${VERSION}/json" | - sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\.tar\.gz\)".*/\1/p' | - head -n1 - )" - SDIST="$(basename "${URL}")" - - # install dependencies - pip install -r "${REPO_DIR}/py/requirements_lock.txt" - - # fetch latest nightly sdist tarball from TestPyPI - curl -fsSL -o "${SDIST}" "${URL}" - - # overlay packaged/generated code from the sdist into the repo checkout - tar -xzf "${SDIST}" - cp -a "${SDIST%.tar.gz}/selenium/." "${REPO_DIR}/py/${PACKAGE}/" - - # remove extracted sdist contents - rm -rf "${SDIST%.tar.gz}" "${SDIST}" - - # generate new .rst with API modules - cd "${REPO_DIR}/py" && python3 "generate_api_module_listing.py" && cd .. - - export PYTHONPATH="${REPO_DIR}/py:${PYTHONPATH:-}" - - # generate doc stubs - sphinx-autogen -o "${READTHEDOCS_OUTPUT}/html" "py/docs/source/api.rst" + - pip install -r py/requirements_lock.txt + - cd py && python3 generate_api_module_listing.py && cd + - PYTHONPATH=py sphinx-autogen -o $READTHEDOCS_OUTPUT/html py/docs/source/api.rst + - PYTHONPATH=py sphinx-build -b html -d build/docs/doctrees py/docs/source $READTHEDOCS_OUTPUT/html - # build docs - sphinx-build -b html -d build/docs/doctrees py/docs/source ${READTHEDOCS_OUTPUT}/html +sphinx: + configuration: py/docs/source/conf.py From 9fd65b5839cfa089d550d54601f12f75e5ac3456 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 28 Jul 2026 16:32:31 -0500 Subject: [PATCH 05/56] [build] BiDi schema generation cleanup (#17837) * [js] parse BiDi CDDL in generate_bidi.mjs, drop py/private merge_cddl dependency --- javascript/selenium-webdriver/BUILD.bazel | 4 +- .../selenium-webdriver/generate_bidi.mjs | 12 ++-- .../private/generate_bidi.bzl | 71 +++++-------------- .../project_bidi_schema.mjs | 23 +++--- .../project_bidi_schema_test.mjs | 10 +++ 5 files changed, 50 insertions(+), 70 deletions(-) diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel index 1c61e29ca73a5..fc129df4342d5 100644 --- a/javascript/selenium-webdriver/BUILD.bazel +++ b/javascript/selenium-webdriver/BUILD.bazel @@ -84,8 +84,8 @@ mocha_test( ) # Generate WebDriver BiDi TypeScript modules from CDDL specification. -# extra_cddl_files are merged with the primary BiDi spec before generation so that -# adjacent specs (Permissions, Prefetch, UA Client Hints, Web Bluetooth) are included. +# extra_cddl_files are parsed alongside the primary BiDi spec so that adjacent specs +# (Permissions, Prefetch, UA Client Hints, Web Bluetooth) are included. generate_bidi_library( name = "create-bidi-src", cddl_file = "@webdriver_bidi_all_cddl//file:spec.cddl", diff --git a/javascript/selenium-webdriver/generate_bidi.mjs b/javascript/selenium-webdriver/generate_bidi.mjs index ca990d9c73182..e928eff770a0a 100644 --- a/javascript/selenium-webdriver/generate_bidi.mjs +++ b/javascript/selenium-webdriver/generate_bidi.mjs @@ -145,7 +145,7 @@ function resolveInputPath(p) { async function main() { const { values: args } = parseArgs({ options: { - cddl: { type: 'string' }, + cddl: { type: 'string', multiple: true }, ast: { type: 'string' }, model: { type: 'string' }, 'dump-ast': { type: 'string' }, @@ -157,8 +157,12 @@ async function main() { }) // One pipeline stage per invocation; the flags select the stage. - if (args['dump-ast'] && args.cddl) { - writeJson(args['dump-ast'], parseCddl(args.cddl), 'ast') + if (args['dump-ast'] && args.cddl?.length) { + // The base spec is several CDDL files (webdriver-bidi + the adjacent specs); each + // is parsed independently and their definitions concatenated. Top-level CDDL + // productions are position-independent (refs resolve by name later), so this equals + // parsing one merged file — without a separate merge step or tool. + writeJson(args['dump-ast'], args.cddl.flatMap(parseCddl), 'ast') } else if (args['dump-model'] && args.ast) { writeJson(args['dump-model'], buildModel(readJson(args.ast, 'AST')), 'model', true) } else if (args['output-dir'] && args.ast && args.model) { @@ -166,7 +170,7 @@ async function main() { } else { console.error( 'Usage (one stage per invocation):\n' + - ' generate_bidi.mjs --cddl --dump-ast \n' + + ' generate_bidi.mjs --cddl [--cddl ...] --dump-ast \n' + ' generate_bidi.mjs --ast --dump-model \n' + ' generate_bidi.mjs --ast --model --output-dir [--enhancements ] [--spec-version ]', ) diff --git a/javascript/selenium-webdriver/private/generate_bidi.bzl b/javascript/selenium-webdriver/private/generate_bidi.bzl index d98a1907ba114..4b22be9bbdda8 100644 --- a/javascript/selenium-webdriver/private/generate_bidi.bzl +++ b/javascript/selenium-webdriver/private/generate_bidi.bzl @@ -30,36 +30,6 @@ _DOMAIN_TS_FILES = [ "webextension.ts", ] -def _merge_cddl_impl(ctx): - """Merges one or more CDDL files into a single output file.""" - out = ctx.outputs.out - args = ctx.actions.args() - args.add(out) - args.add_all(ctx.files.srcs) - ctx.actions.run( - inputs = ctx.files.srcs, - outputs = [out], - executable = ctx.executable.tool, - arguments = [args], - mnemonic = "MergeCddl", - progress_message = "Merging CDDL files into %s" % out.short_path, - ) - return [DefaultInfo(files = depset([out]))] - -_merge_cddl = rule( - implementation = _merge_cddl_impl, - attrs = { - "srcs": attr.label_list(allow_files = True, mandatory = True), - "out": attr.output(mandatory = True), - "tool": attr.label( - executable = True, - cfg = "exec", - mandatory = True, - ), - }, - doc = "Merges CDDL specification files into a single file using an external merge tool.", -) - def _compile_bidi_ts_impl(ctx): ts_files = ctx.files.srcs output_subdir = ctx.attr.output_subdir @@ -127,15 +97,14 @@ def generate_bidi_library( generator = None, schema_generator = None, anchors_extractor = None, - merge_tool = "//py/private:merge_cddl", spec_version = "1.0", output_path = "bidi/generated"): - """Macro that merges CDDL, generates BiDi TypeScript modules, and compiles them to JS. + """Macro that generates BiDi TypeScript modules from CDDL and compiles them to JS. Args: name: Base name for the targets. cddl_file: Primary CDDL spec label (webdriver-bidi-all.cddl). - extra_cddl_files: Additional CDDL files merged before generation. + extra_cddl_files: Additional CDDL specs parsed alongside the primary one. dfns_files: webref definition-index files (one per merged spec). When given, the schema step joins them by type name to attach a `specHref` spec link to each type. Optional — omitting them yields a schema with no links. @@ -146,7 +115,6 @@ def generate_bidi_library( generator: The generate_bidi.mjs js_binary label. Defaults to :generate_bidi_script. schema_generator: The project_bidi_schema.mjs js_binary label. Defaults to :project_bidi_schema_script. anchors_extractor: The extract_bidi_anchors.mjs js_binary label. Defaults to :extract_bidi_anchors_script. - merge_tool: Python binary that concatenates CDDL files (output first, then inputs). spec_version: Spec version string passed to the generator. output_path: Output path for generated files within the package (default: bidi/generated). """ @@ -160,32 +128,25 @@ def generate_bidi_library( pkg = native.package_name() ts_src_path = output_path + "_src" - # Step 1: merge CDDL files into one. - # merge_cddl signature: [ ...] - # Uses ctx.actions.run so arguments are passed as an argv list rather than - # a shell command string, avoiding quoting/escaping issues with special chars. - merged_name = name + "_merged_cddl" - _merge_cddl( - name = merged_name, - srcs = [cddl_file] + extra_cddl_files, - out = name + "_merged.cddl", - tool = merge_tool, - ) - - # Step 2: parse the merged CDDL once into the reusable AST artifact. Internal - # input to the schema and the JS generator; not consumed by other bindings. + # Step 1: parse the base specs into the reusable AST artifact. generate_bidi.mjs + # parses each `--cddl` file and concatenates their definitions (no separate merge + # tool). Internal input to the schema and the JS generator; not consumed by other + # bindings. js_run_binary copies its srcs to bin and rejects external/cross-package + # files, so stage each spec into the package first (as the dfns/spec_html steps do). + staged_specs = [] + cddl_args = [] + for i, spec in enumerate([cddl_file] + extra_cddl_files): + staged = name + "_cddl_%d.cddl" % i + copy_file(name = name + "_cddl_copy_%d" % i, src = spec, out = staged) + staged_specs.append(":" + staged) + cddl_args += ["--cddl", "$(location :" + staged + ")"] ast_target = name + "_ast" ast_out = name + "_ast.json" js_run_binary( name = ast_target, - srcs = [":" + merged_name], + srcs = staged_specs, outs = [ast_out], - args = [ - "--cddl", - "$(location :" + merged_name + ")", - "--dump-ast", - pkg + "/" + ast_out, - ], + args = cddl_args + ["--dump-ast", pkg + "/" + ast_out], tool = generator, ) diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs index 72e10a93ac7b5..a2fad583326de 100644 --- a/javascript/selenium-webdriver/project_bidi_schema.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -104,6 +104,10 @@ const typeList = (t) => (Array.isArray(t) ? t : t === undefined || t === null ? const isLiteral = (e) => e && typeof e === 'object' && e.Type === 'literal' const isRef = (e) => e && typeof e === 'object' && e.Type === 'group' && typeof e.Value === 'string' +// An occurrence with no upper bound (`*` / `+`). The parser emits Infinity; the AST's +// JSON round-trip renders that as null, so treat both as unbounded. +const isUnbounded = (occ) => !!occ && (occ.m === null || occ.m === Infinity) + // A `null` keyword or a `nil` prelude ref in a union means the value may be null. const isNullAlt = (e) => e === 'null' || (e && typeof e === 'object' && e.Type === 'group' && PRELUDE[e.Value] === 'null') @@ -242,20 +246,21 @@ function projectType(def) { } /** - * Project a CDDL group into a record. A property with `Occurrence.m === null` is - * an unbounded entry (`* key => value`), not a scalar field: `* text => any` marks - * the record extensible, `* text => T` becomes a typed map, and an unbounded group - * spread is folded in. Everything else is a normal field. + * Project a CDDL group into a record. A property with an unbounded occurrence (`*`/`+`) + * is a map/spread entry, not a scalar field: `* text => any` marks the record extensible, + * `* text => T` becomes a typed map, and an unbounded group spread is folded in. Everything + * else is a normal field. */ function projectRecord(def) { const record = { kind: 'record', fields: [] } for (const prop of (def.Properties ?? []).flat()) { if (!prop || typeof prop !== 'object') continue - // `m === null` is overloaded in this parser: a key-typed entry is a map - // (`* text => value`); an anonymous entry is a structural spread; everything - // else is just an optional field (the `?` quantifier). Only the first two - // are not real fields. - if (prop.Occurrence?.m === null && (!prop.Name || prop.Name in PRIMITIVES || prop.Name in PRELUDE)) { + // An unbounded upper bound is overloaded in this parser: a key-typed entry is a map + // (`* text => value`); an anonymous entry is a structural spread; everything else is + // just an optional field (the `?` quantifier). Only the first two are not real fields. + // The parser emits the bound as Infinity; the AST's JSON round-trip turns it into null, + // so accept either rather than depending on that coercion. + if (isUnbounded(prop.Occurrence) && (!prop.Name || prop.Name in PRIMITIVES || prop.Name in PRELUDE)) { if (prop.Name in PRIMITIVES || prop.Name in PRELUDE) { const value = projectRef(prop.Type) if (value.primitive === 'any') record.extensible = true diff --git a/javascript/selenium-webdriver/project_bidi_schema_test.mjs b/javascript/selenium-webdriver/project_bidi_schema_test.mjs index 1fc7aeddeeea6..6420031d65fa8 100644 --- a/javascript/selenium-webdriver/project_bidi_schema_test.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema_test.mjs @@ -88,6 +88,16 @@ describe('projectSchema', () => { assert.equal(open.fields.length, 0) }) + it('treats an unbounded occurrence as extensible whether m is null or Infinity', () => { + // The cddl parser emits the `*` upper bound as Infinity; only the AST's JSON + // round-trip renders it as null. Projecting an AST directly (no round-trip) must + // still recognize it, not emit a `text` field. + const ast = [group('x.RawOpenMap', [field('text', ['any'], { n: 0, m: Infinity })])] + const open = projectSchema(ast, {}).types['x.RawOpenMap'] + assert.equal(open.extensible, true) + assert.equal(open.fields.length, 0) + }) + it('passes both validators on a well-formed schema', () => { assert.deepEqual(checkSchema(schema), []) assert.deepEqual(checkCompleteness(AST, schema), []) From 247f4695de8f3d6396fc2809cf416481650feb19 Mon Sep 17 00:00:00 2001 From: Puja Jagani Date: Wed, 29 Jul 2026 17:24:00 +0530 Subject: [PATCH 06/56] [java][bidi] Remove subscription scope (#17842) --- java/src/org/openqa/selenium/bidi/BiDi.java | 13 ---- java/src/org/openqa/selenium/bidi/Handle.java | 4 - java/src/org/openqa/selenium/bidi/Module.java | 22 ++++-- .../selenium/bidi/SubscriptionScope.java | 74 ------------------- 4 files changed, 15 insertions(+), 98 deletions(-) delete mode 100644 java/src/org/openqa/selenium/bidi/SubscriptionScope.java diff --git a/java/src/org/openqa/selenium/bidi/BiDi.java b/java/src/org/openqa/selenium/bidi/BiDi.java index 75ed8b5867d59..5343718c25a1e 100644 --- a/java/src/org/openqa/selenium/bidi/BiDi.java +++ b/java/src/org/openqa/selenium/bidi/BiDi.java @@ -21,7 +21,6 @@ import java.io.Closeable; import java.time.Duration; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -117,18 +116,6 @@ public String addListener( return subscriptionId; } - String addListener(Event event, Consumer handler, SubscriptionScope scope) { - Require.nonNull("Event to listen for", event); - Require.nonNull("Handler to call", handler); - Require.nonNull("Subscription scope", scope); - - Map params = new HashMap<>(scope.toMap()); - params.put("events", List.of(event.getMethod())); - String subscriptionId = subscribe(params); - connection.addListener(subscriptionId, event, handler); - return subscriptionId; - } - // The subscription id returned by the browser is the sole identifier we need to unsubscribe // later: it is unique regardless of whether the subscription was scoped to events, contexts, or // user contexts, so there is no need to separately track how a listener was subscribed. diff --git a/java/src/org/openqa/selenium/bidi/Handle.java b/java/src/org/openqa/selenium/bidi/Handle.java index 9c40f16860d80..3363648221fe6 100644 --- a/java/src/org/openqa/selenium/bidi/Handle.java +++ b/java/src/org/openqa/selenium/bidi/Handle.java @@ -45,10 +45,6 @@ String subscribe(Event event, Consumer handler) { return bidi.addListener(event, handler); } - String subscribe(Event event, Consumer handler, SubscriptionScope scope) { - return bidi.addListener(event, handler, scope); - } - void unsubscribe(String subscriptionId) { bidi.removeListener(subscriptionId); } diff --git a/java/src/org/openqa/selenium/bidi/Module.java b/java/src/org/openqa/selenium/bidi/Module.java index 00f43272c2c43..83e00f3a5267b 100644 --- a/java/src/org/openqa/selenium/bidi/Module.java +++ b/java/src/org/openqa/selenium/bidi/Module.java @@ -45,16 +45,24 @@ protected final X send(Command command) { return handle.send(command); } - protected final String subscribe(Event event, Consumer handler) { + /** + * Subscribes to a BiDi event, globally across all browsing contexts. + * + * @param event the event to subscribe to + * @param handler invoked with the event's parameters each time it fires + * @param the event's parameter type + * @return a subscription id that can be passed to {@link #unsubscribe(String)} + */ + public final String subscribe(Event event, Consumer handler) { return handle.subscribe(event, handler); } - protected final String subscribe( - Event event, Consumer handler, SubscriptionScope scope) { - return handle.subscribe(event, handler, scope); - } - - protected final void unsubscribe(String subscriptionId) { + /** + * Cancels a previously registered event subscription. + * + * @param subscriptionId a subscription id previously returned by {@link #subscribe} + */ + public final void unsubscribe(String subscriptionId) { handle.unsubscribe(subscriptionId); } } diff --git a/java/src/org/openqa/selenium/bidi/SubscriptionScope.java b/java/src/org/openqa/selenium/bidi/SubscriptionScope.java deleted file mode 100644 index e6c15212ba9fc..0000000000000 --- a/java/src/org/openqa/selenium/bidi/SubscriptionScope.java +++ /dev/null @@ -1,74 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package org.openqa.selenium.bidi; - -import java.util.HashMap; -import java.util.Map; -import java.util.Set; -import org.openqa.selenium.Beta; -import org.openqa.selenium.internal.Require; - -/** - * Where a subscription applies: globally, or scoped to browsing contexts and/or user contexts. Part - * of the transport layer, not generated — the remote end decides which combinations are valid. - * - *

This class is intentionally limited to the scope of a subscription, not the full set of - * subscribe parameters. - * - * @see - * session.SubscriptionParameters - */ -@Beta -public final class SubscriptionScope { - - private Set contexts = Set.of(); - private Set userContexts = Set.of(); - - /** - * Scopes the subscription to the given browsing contexts. - * - * @param contexts the browsing context ids to scope the subscription to - * @return this scope, for chaining - */ - public SubscriptionScope contexts(Set contexts) { - this.contexts = Set.copyOf(Require.nonNull("Browsing context ids", contexts)); - return this; - } - - /** - * Scopes the subscription to the given user contexts. - * - * @param userContexts the user context ids to scope the subscription to - * @return this scope, for chaining - */ - public SubscriptionScope userContexts(Set userContexts) { - this.userContexts = Set.copyOf(Require.nonNull("User context ids", userContexts)); - return this; - } - - Map toMap() { - Map params = new HashMap<>(); - if (!contexts.isEmpty()) { - params.put("contexts", contexts); - } - if (!userContexts.isEmpty()) { - params.put("userContexts", userContexts); - } - return params; - } -} From e21617c28799f4e4bd53c98159c30123326cdaad Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 29 Jul 2026 09:35:41 -0500 Subject: [PATCH 07/56] [build] Merge vendor cddl files into shared BiDi schema and implement custom Firefox webExtension options (#17840) * [build] add local CDDL overlays for webExtension.install #1140 extension point and moz vendor params * [js] implement in schema not in merged cddl file that is sent to cddl2ts * [rb] generate a browser-scoped webExtension vendor subclass (WebExtension::Moz) --- common/bidi/BUILD.bazel | 4 + common/bidi/webdriver-bidi-1140.cddl | 8 + .../bidi/webextension-install-extensions.cddl | 7 + javascript/selenium-webdriver/BUILD.bazel | 10 + .../bidi_schema_diff_test.mjs | 7 +- .../selenium-webdriver/generate_bidi.mjs | 191 ++++++++++++++---- .../selenium-webdriver/normalize_bidi_ast.mjs | 33 ++- .../normalize_bidi_ast_test.mjs | 18 ++ .../private/generate_bidi.bzl | 62 +++++- .../project_bidi_schema.mjs | 62 +++++- .../webdriver/bidi/protocol/web_extension.rb | 32 ++- .../webdriver/bidi/support/bidi_generate.rb | 141 ++++++++++++- .../bidi/support/templates/module.rb.erb | 4 + .../bidi/support/templates/module.rbs.erb | 8 + .../webdriver/bidi/protocol/script.rbs | 4 +- .../webdriver/bidi/protocol/session.rbs | 12 +- .../webdriver/bidi/protocol/storage.rbs | 6 +- .../webdriver/bidi/protocol/web_extension.rbs | 15 +- .../webdriver/bidi/serialization_spec.rb | 48 +++++ 19 files changed, 583 insertions(+), 89 deletions(-) create mode 100644 common/bidi/BUILD.bazel create mode 100644 common/bidi/webdriver-bidi-1140.cddl create mode 100644 common/bidi/webextension-install-extensions.cddl diff --git a/common/bidi/BUILD.bazel b/common/bidi/BUILD.bazel new file mode 100644 index 0000000000000..9e5f23866a048 --- /dev/null +++ b/common/bidi/BUILD.bazel @@ -0,0 +1,4 @@ +exports_files( + glob(["*.cddl"]), + visibility = ["//javascript/selenium-webdriver:__pkg__"], +) diff --git a/common/bidi/webdriver-bidi-1140.cddl b/common/bidi/webdriver-bidi-1140.cddl new file mode 100644 index 0000000000000..566c971f34c33 --- /dev/null +++ b/common/bidi/webdriver-bidi-1140.cddl @@ -0,0 +1,8 @@ +; Local copy of w3c/webdriver-bidi#1140: adds the webExtension.install extension point. +; Overrides the upstream closed InstallParameters. Delete once #1140 merges and the +; pinned webref carries it. https://github.com/w3c/webdriver-bidi/pull/1140 +webExtension.InstallParameters = { + extensionData: webExtension.ExtensionData, + webExtension.InstallParametersExtension, +} +webExtension.InstallParametersExtension = ( Extensible ) diff --git a/common/bidi/webextension-install-extensions.cddl b/common/bidi/webextension-install-extensions.cddl new file mode 100644 index 0000000000000..da001787d257d --- /dev/null +++ b/common/bidi/webextension-install-extensions.cddl @@ -0,0 +1,7 @@ +; Firefox vendor fields for webExtension.install, matching Mozilla's agreed CDDL (bug 2057588, +; pending merge). Once it lands, repoint vendor_cddl_files at Mozilla's file and delete this. +; https://bugzilla.mozilla.org/show_bug.cgi?id=2057588 +webExtension.InstallParametersExtension //= ( + ? "moz:allowPrivateBrowsing": bool .default false, + ? "moz:permanent": bool .default false, +) diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel index fc129df4342d5..f9c5e106fe5b3 100644 --- a/javascript/selenium-webdriver/BUILD.bazel +++ b/javascript/selenium-webdriver/BUILD.bazel @@ -105,11 +105,21 @@ generate_bidi_library( "@ua_client_hints_all_cddl//file:spec.cddl", "@web_bluetooth_all_cddl//file:spec.cddl", ], + # The local #1140 extension point (supersedes the upstream closed InstallParameters): spec- + # shaped and browser-neutral, it only opens the map. Stays in the shared schema. + override_cddl_files = [ + "//common/bidi:webdriver-bidi-1140.cddl", + ], # The pinned rendered core spec. The schema step extracts its prose section anchors # (#type-/#command-/#event-/#module-) to upgrade type links and add command/event/ # domain links; adjacent specs keep the webref CDDL fallback. spec_html = "@webdriver_bidi_spec_html//file:index.html", spec_version = "1.0", + # Firefox's vendor fields plugged into that extension point. Provenance-tagged and routed into + # the schema's separate `vendor` section, so the shared schema stays exactly what upstream emits. + vendor_cddl_files = [ + "//common/bidi:webextension-install-extensions.cddl", + ], ) VERSION = "4.47.0-nightly202607110055" diff --git a/javascript/selenium-webdriver/bidi_schema_diff_test.mjs b/javascript/selenium-webdriver/bidi_schema_diff_test.mjs index 24e184e9f0ffe..1d1b7e47b19ce 100644 --- a/javascript/selenium-webdriver/bidi_schema_diff_test.mjs +++ b/javascript/selenium-webdriver/bidi_schema_diff_test.mjs @@ -123,11 +123,14 @@ function topLevelFields(body) { i++ continue } - const m = depth === 0 ? /^(\w+)(\??):\s*/.exec(body.slice(i)) : null + // A property key is a bare identifier or a quoted string (cddl2ts quotes keys + // that are not valid identifiers, e.g. the vendor-prefixed `"moz:permanent"`). + const m = depth === 0 ? /^(?:"([^"]+)"|(\w+))(\??):\s*/.exec(body.slice(i)) : null if (!m) { i++ continue } + const name = m[1] ?? m[2] let j = i + m[0].length let d = 0 while (j < body.length && !(d === 0 && body[j] === ';')) { @@ -140,7 +143,7 @@ function topLevelFields(body) { // object bodies removed, so `null`/`[]` belonging to nested fields (e.g. an // inline `{ x: T | null }`) are not attributed to this field. const shallow = stripObjectBodies(type) - fields[m[1]] = { optional: m[2] === '?', nullable: /\bnull\b/.test(shallow), array: /\[\]/.test(shallow) } + fields[name] = { optional: m[3] === '?', nullable: /\bnull\b/.test(shallow), array: /\[\]/.test(shallow) } i = j + 1 } return fields diff --git a/javascript/selenium-webdriver/generate_bidi.mjs b/javascript/selenium-webdriver/generate_bidi.mjs index e928eff770a0a..05ea31aad0ec7 100644 --- a/javascript/selenium-webdriver/generate_bidi.mjs +++ b/javascript/selenium-webdriver/generate_bidi.mjs @@ -28,7 +28,7 @@ import { parse } from 'cddl' import { transform } from 'cddl2ts' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' @@ -146,6 +146,8 @@ async function main() { const { values: args } = parseArgs({ options: { cddl: { type: 'string', multiple: true }, + 'override-cddl': { type: 'string', multiple: true }, + 'vendor-cddl': { type: 'string', multiple: true }, ast: { type: 'string' }, model: { type: 'string' }, 'dump-ast': { type: 'string' }, @@ -161,8 +163,16 @@ async function main() { // The base spec is several CDDL files (webdriver-bidi + the adjacent specs); each // is parsed independently and their definitions concatenated. Top-level CDDL // productions are position-independent (refs resolve by name later), so this equals - // parsing one merged file — without a separate merge step or tool. - writeJson(args['dump-ast'], args.cddl.flatMap(parseCddl), 'ast') + // parsing one merged file — without a separate merge step or tool. Spec-shaped Selenium + // overrides (e.g. #1140) are applied here; vendor overlays are NOT, so this base AST — + // which feeds the model and the browser-neutral TypeScript binding — stays vendor-free. + const baseAst = args.cddl.flatMap(parseCddl) + writeJson(args['dump-ast'], applyOverrides(baseAst, args['override-cddl'] ?? []), 'ast') + } else if (args['dump-ast'] && args.ast && args['vendor-cddl']?.length) { + // The base AST plus vendor overlays, consumed ONLY by the schema projector. Applying vendor + // on this separate path (rather than into the shared base AST) is what keeps the tagged + // vendor fields out of cddl2ts and the model — the schema step segregates them into `vendor`. + writeJson(args['dump-ast'], applyVendor(readJson(args.ast, 'AST'), args['vendor-cddl']), 'ast') } else if (args['dump-model'] && args.ast) { writeJson(args['dump-model'], buildModel(readJson(args.ast, 'AST')), 'model', true) } else if (args['output-dir'] && args.ast && args.model) { @@ -190,6 +200,74 @@ function parseCddl(cddlArg) { return ast } +/** + * Apply Selenium overlay CDDL (see common/bidi/) to the parsed upstream AST: any + * production an overlay defines replaces the identically named upstream one, which is + * dropped. Kept here rather than in the shared CDDL merge so the overlay is a + * schema-generation concern only — the upstream grammars other bindings consume are + * untouched. Overlay defs are appended so downstream normalization treats them like + * any other definition. + */ +function applyOverrides(ast, overrideArgs) { + if (!overrideArgs.length) return ast + const overrides = overrideArgs.flatMap((arg) => parseCddl(arg)) + const names = new Set(overrides.filter((d) => d?.Name).map((d) => d.Name)) + return [...ast.filter((d) => !(d?.Name && names.has(d.Name))), ...overrides] +} + +/** + * Apply Selenium vendor overlay CDDL (see common/bidi/*-extensions.cddl) to the AST. + * A vendor overlay extends a spec extension point (e.g. `webExtension.InstallParametersExtension + * //= (...)`) with typed browser-specific fields. Unlike a plain override, every field a vendor + * overlay contributes is tagged with its provenance — the vendor namespace (from the field's wire + * key prefix, e.g. `moz:` → `moz`) and the extension point it flows through — so the projector can + * resolve it against the real extension point (the merge genuinely happens) yet route it out of the + * shared, browser-neutral schema into a separate `vendor` section. Vendor defs are appended after + * overrides so the extension point they extend is already present for the `//=` fold. + */ +function applyVendor(ast, vendorArgs) { + if (!vendorArgs.length) return ast + const vendorDefs = vendorArgs.flatMap((arg) => tagVendorDefs(parseCddl(arg), vendorFileStem(arg))) + return [...ast, ...vendorDefs] +} + +function vendorFileStem(cddlArg) { + return basename(resolveInputPath(cddlArg)).replace(/\.cddl$/, '') +} + +// The vendor namespace is intrinsic to the field: a `moz:permanent` wire key belongs to `moz`. +// Fields without a namespaced key fall back to the overlay file's stem. +function vendorNamespaceOf(wireKey, fallback) { + const i = typeof wireKey === 'string' ? wireKey.indexOf(':') : -1 + return i > 0 ? wireKey.slice(0, i) : fallback +} + +// Stamp `x-selenium-vendor` (namespace) and `x-selenium-vendor-via` (the extension-point def the +// field extends) onto every named field a vendor overlay def declares. The tags ride through the +// AST JSON round-trip and normalization (the `//=` fold and group flatten preserve them) so the +// projector can partition them out of the shared schema by provenance. +function tagVendorDefs(defs, fileStem) { + for (const def of defs) { + const via = def.Name + const walk = (props) => { + for (const p of props ?? []) { + if (Array.isArray(p)) { + walk(p) + continue + } + if (!p || typeof p !== 'object') continue + if (p.Name) { + p['x-selenium-vendor'] = vendorNamespaceOf(p.Name, fileStem) + p['x-selenium-vendor-via'] = via + } + if (Array.isArray(p.Properties)) walk(p.Properties) + } + } + walk(def.Properties) + } + return defs +} + function readJson(fileArg, label) { const path = resolveInputPath(fileArg) if (!existsSync(path)) { @@ -280,54 +358,87 @@ function loadEnhancements(manifestPath) { // ============================================================ /** - * Remove duplicate export declarations (cddl2ts emits them when the - * `*-all.cddl` input concatenates local + remote definitions that both - * define the same shared types) and replace `any` with `unknown`. + * Reconcile cddl2ts's per-name declarations and replace `any` with `unknown`. + * + * cddl2ts emits several declarations for one name in two cases: + * - identical duplicates, when the `*-all.cddl` input concatenates local + remote + * definitions of a shared type — keep the first, drop the rest; and + * - a group that both aliases another (`X = ( Extensible )` → `type X = Extensible`) + * and gains fields via `//=` (→ `interface X { … }`). A type alias and an interface + * of the same name cannot coexist in TS, so fold them into one intersection + * (`type X = Extensible & { … }`) rather than letting the interface's fields drop. */ function postProcessTypes(rawTs) { - const seen = new Set() - const output = [] + const clean = (s) => + s.replace(/Record/g, 'Record').replace(/: any([;,)\s\[])/g, ': unknown$1') + + // Split into ordered items: a declaration block { name, kind, lines } or raw { text }. const lines = rawTs.split('\n') + const items = [] let i = 0 - while (i < lines.length) { - const line = lines[i] - const match = line.match(/^export (?:type|interface) (\w+)/) - - if (match) { - const name = match[1] - - if (seen.has(name)) { - // Determine end of this declaration before skipping it. - if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) { - // Multi-line block: skip until braces balance back to zero. - let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length - i++ - while (i < lines.length && depth > 0) { - depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length - i++ - } - } else { - i++ // single-line declaration - } - // Consume the trailing blank line that follows every declaration. - if (i < lines.length && lines[i] === '') i++ - continue + const m = lines[i].match(/^export (type|interface) (\w+)/) + if (!m) { + items.push({ text: lines[i] }) + i++ + continue + } + const start = i + if (lines[i].includes('{') && !lines[i].endsWith('{}') && !lines[i].endsWith('{};')) { + let depth = (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length + i++ + while (i < lines.length && depth > 0) { + depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length + i++ } - - seen.add(name) + } else { + i++ } + items.push({ name: m[2], kind: m[1], lines: lines.slice(start, i) }) + } - // Replace any → unknown. - const cleaned = line - .replace(/Record/g, 'Record') - .replace(/: any([;,)\s\[])/g, ': unknown$1') + const byName = new Map() + for (const it of items) if (it.name) byName.set(it.name, [...(byName.get(it.name) ?? []), it]) - output.push(cleaned) - i++ + const emitted = new Set() + const output = [] + for (const it of items) { + if (!it.name) { + output.push(it.text) + continue + } + if (emitted.has(it.name)) continue + emitted.add(it.name) + output.push(reconcileDecls(it.name, byName.get(it.name)).join('\n')) } - return output.join('\n') + return clean(output.join('\n')) +} + +/** The lines between an interface's braces, i.e. its member declarations. */ +function interfaceBody(block) { + const text = block.lines.join('\n') + return text.slice(text.indexOf('{') + 1, text.lastIndexOf('}')).replace(/^\n|\n$/g, '') +} + +/** + * Collapse a name's cddl2ts declarations into one. A lone declaration (or identical + * duplicates) keeps the first. A `type X = ` alias plus `interface X { … }` + * bodies fold into `type X = & { … }` so the interface fields survive. + */ +function reconcileDecls(name, blocks) { + const alias = blocks.find((b) => b.kind === 'type') + const bodies = blocks + .filter((b) => b.kind === 'interface') + .map(interfaceBody) + .filter((b) => b.trim()) + if (!alias || !bodies.length) return blocks[0].lines + const rhs = alias.lines + .join('\n') + .replace(/^export type \w+\s*=\s*/, '') + .replace(/;\s*$/, '') + .trim() + return [`export type ${name} = ${[rhs, ...bodies.map((b) => `{\n${b}\n}`)].join(' & ')};`] } // ============================================================ diff --git a/javascript/selenium-webdriver/normalize_bidi_ast.mjs b/javascript/selenium-webdriver/normalize_bidi_ast.mjs index 3df64c136d8b9..a388d62e1ecb8 100644 --- a/javascript/selenium-webdriver/normalize_bidi_ast.mjs +++ b/javascript/selenium-webdriver/normalize_bidi_ast.mjs @@ -450,22 +450,35 @@ export function flattenGroupComposition(ast) { // ============================================================ /** - * Drop duplicate definitions, keeping the first occurrence — the `*-all.cddl` - * input concatenates local + remote specs that both define shared types. This - * matches `buildModel`'s `buildDefMap` ("first wins") so the normalized artifact - * carries one def per name. + * Collapse same-named definitions into one. A plain duplicate keeps the first + * occurrence — the `*-all.cddl` input concatenates local + remote specs that both + * define shared types, matching `buildModel`'s `buildDefMap` ("first wins"). A + * choice-addition (`//=` group socket or `/=` type socket, `IsChoiceAddition: true`) + * instead folds its members into the retained base, so a group extension point like + * `webExtension.InstallParametersExtension //= (...)` is resolved before + * `flattenGroupComposition` splices that group into its referents. + * Pure — folds into a fresh clone rather than mutating the input def. * @param {object[]} ast The AST to dedupe. - * @returns {object[]} A new AST array with duplicate-named defs removed (first wins). + * @returns {object[]} A new AST array with duplicate-named defs collapsed. */ export function dedupeDefs(ast) { - const seen = new Set() + const indexByName = new Map() const out = [] for (const def of ast) { - if (def && typeof def === 'object' && typeof def.Name === 'string') { - if (seen.has(def.Name)) continue - seen.add(def.Name) + const name = def && typeof def === 'object' && typeof def.Name === 'string' ? def.Name : null + if (name === null || !indexByName.has(name)) { + if (name !== null) indexByName.set(name, out.length) + out.push(def) + continue } - out.push(def) + if (!def.IsChoiceAddition) continue // plain duplicate: first wins + const i = indexByName.get(name) + const base = out[i] + const merged = { ...base } + if (base.Properties || def.Properties) merged.Properties = [...(base.Properties ?? []), ...(def.Properties ?? [])] + if (base.PropertyType || def.PropertyType) + merged.PropertyType = [...(base.PropertyType ?? []), ...(def.PropertyType ?? [])] + out[i] = merged } return out } diff --git a/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs b/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs index 541efb59921d3..e90329db1166b 100644 --- a/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs +++ b/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs @@ -290,6 +290,24 @@ describe('dedupeDefs', () => { assert.equal(out.length, 1) assert.equal(out[0].Properties[0].Name, 'a') }) + + it('folds a //= choice addition into the retained base group', () => { + const addition = { ...def('x.Ext', [field('b', ['text'])]), IsChoiceAddition: true } + const ast = [def('x.Ext', [field('a', ['text'])]), addition] + const out = dedupeDefs(ast) + assert.equal(out.length, 1) + assert.deepEqual( + out[0].Properties.map((p) => p.Name), + ['a', 'b'], + ) + }) + + it('does not mutate the base def when folding', () => { + const base = def('x.Ext', [field('a', ['text'])]) + const addition = { ...def('x.Ext', [field('b', ['text'])]), IsChoiceAddition: true } + dedupeDefs([base, addition]) + assert.equal(base.Properties.length, 1) + }) }) describe('normalizeAst', () => { diff --git a/javascript/selenium-webdriver/private/generate_bidi.bzl b/javascript/selenium-webdriver/private/generate_bidi.bzl index 4b22be9bbdda8..f7344c04bdf4d 100644 --- a/javascript/selenium-webdriver/private/generate_bidi.bzl +++ b/javascript/selenium-webdriver/private/generate_bidi.bzl @@ -91,6 +91,8 @@ def generate_bidi_library( name, cddl_file, extra_cddl_files = [], + override_cddl_files = [], + vendor_cddl_files = [], dfns_files = [], spec_html = None, enhancements_manifest = None, @@ -105,6 +107,13 @@ def generate_bidi_library( name: Base name for the targets. cddl_file: Primary CDDL spec label (webdriver-bidi-all.cddl). extra_cddl_files: Additional CDDL specs parsed alongside the primary one. + override_cddl_files: Selenium overlay CDDL files applied to the parsed AST: any + production they define supersedes the identically named upstream one. Overlays + are a schema-gen concern only and do not touch other bindings. + vendor_cddl_files: Selenium vendor overlay CDDL files (e.g. `moz:` webextension fields). + Their fields extend a spec extension point and are tagged with provenance, so they + resolve against the real extension point but are routed out of the shared schema into + a separate `vendor` section. Bindings that read only the spec sections never see them. dfns_files: webref definition-index files (one per merged spec). When given, the schema step joins them by type name to attach a `specHref` spec link to each type. Optional — omitting them yields a schema with no links. @@ -128,11 +137,13 @@ def generate_bidi_library( pkg = native.package_name() ts_src_path = output_path + "_src" - # Step 1: parse the base specs into the reusable AST artifact. generate_bidi.mjs - # parses each `--cddl` file and concatenates their definitions (no separate merge - # tool). Internal input to the schema and the JS generator; not consumed by other - # bindings. js_run_binary copies its srcs to bin and rejects external/cross-package - # files, so stage each spec into the package first (as the dfns/spec_html steps do). + # Step 1: parse the base specs and spec-shaped overrides into the reusable base AST. + # generate_bidi.mjs parses each `--cddl` file and concatenates their definitions (no + # separate merge tool), then applies each `--override-cddl` overlay (supersede by name). + # Vendor overlays are deliberately NOT applied here: this base AST feeds the model and the + # browser-neutral TypeScript binding, which must stay vendor-free (see Step 1b). + # js_run_binary copies its srcs to bin and rejects external/cross-package files, so stage + # each spec and overlay into the package first (as the dfns/spec_html steps do). staged_specs = [] cddl_args = [] for i, spec in enumerate([cddl_file] + extra_cddl_files): @@ -140,16 +151,46 @@ def generate_bidi_library( copy_file(name = name + "_cddl_copy_%d" % i, src = spec, out = staged) staged_specs.append(":" + staged) cddl_args += ["--cddl", "$(location :" + staged + ")"] + staged_overrides = [] + override_args = [] + for i, override in enumerate(override_cddl_files): + staged = name + "_override_%d.cddl" % i + copy_file(name = name + "_override_copy_%d" % i, src = override, out = staged) + staged_overrides.append(":" + staged) + override_args += ["--override-cddl", "$(location :" + staged + ")"] ast_target = name + "_ast" ast_out = name + "_ast.json" js_run_binary( name = ast_target, - srcs = staged_specs, + srcs = staged_specs + staged_overrides, outs = [ast_out], - args = cddl_args + ["--dump-ast", pkg + "/" + ast_out], + args = cddl_args + ["--dump-ast", pkg + "/" + ast_out] + override_args, tool = generator, ) + # Step 1b: the schema-only AST — the base AST plus vendor overlays applied onto it. Kept + # separate from the base AST so provenance-tagged vendor fields reach only the schema + # projector (which segregates them into `vendor`), never cddl2ts or the model. Only the small + # vendor files are (re)parsed here; the base is read back from Step 1. No vendors → reuse base. + schema_ast_target = ast_target + if vendor_cddl_files: + staged_vendors = [] + vendor_args = [] + for i, vendor in enumerate(vendor_cddl_files): + staged = name + "_vendor_%d.cddl" % i + copy_file(name = name + "_vendor_copy_%d" % i, src = vendor, out = staged) + staged_vendors.append(":" + staged) + vendor_args += ["--vendor-cddl", "$(location :" + staged + ")"] + schema_ast_target = name + "_ast_vendor" + schema_ast_out = name + "_ast_vendor.json" + js_run_binary( + name = schema_ast_target, + srcs = [":" + ast_target] + staged_vendors, + outs = [schema_ast_out], + args = ["--ast", "$(location :" + ast_target + ")", "--dump-ast", pkg + "/" + schema_ast_out] + vendor_args, + tool = generator, + ) + # Step 3: extract the binding-neutral command/event model from the AST. Folded # into the schema below; still consumed directly by the JS generator in-package. json_target = name + "_json" @@ -202,10 +243,13 @@ def generate_bidi_library( schema_target = name + "_schema" schema_out = name + "_schema.json" - schema_srcs = [":" + ast_target, ":" + json_target] + staged_dfns + + # The schema is projected from the vendor AST (Step 1b); the model comes from the base AST + # (vendor fields are command params, not commands, so they do not affect the model). + schema_srcs = [":" + schema_ast_target, ":" + json_target] + staged_dfns schema_args = [ "--ast", - "$(location :" + ast_target + ")", + "$(location :" + schema_ast_target + ")", "--model", "$(location :" + json_target + ")", "--dump-schema", diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs index a2fad583326de..53b99dd205776 100644 --- a/javascript/selenium-webdriver/project_bidi_schema.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -202,7 +202,19 @@ function projectEntry(e) { } function projectField(prop) { - return { name: prop.Name, wire: prop.Name, required: (prop.Occurrence?.n ?? 1) >= 1, type: projectRef(prop.Type) } + const field = { + name: prop.Name, + wire: prop.Name, + required: (prop.Occurrence?.n ?? 1) >= 1, + type: projectRef(prop.Type), + } + // Provenance stamped by a vendor overlay (generate_bidi.mjs). Carried on the field so + // extractVendor can route it out of the shared schema; stripped there before it ships. + if (prop['x-selenium-vendor']) { + field.vendor = prop['x-selenium-vendor'] + field.via = prop['x-selenium-vendor-via'] + } + return field } // A group whose members are all anonymous refs (a top-level `a // b // c` @@ -637,7 +649,53 @@ export function projectSchema(ast, model, links = {}) { if (href) domains[domain] = { specHref: href } } - return { schemaVersion: 1, commands, events, types, domains } + // Partition vendor-tagged fields out of the shared, browser-neutral schema into a namespaced + // `vendor` section. The shared `types` are then exactly what upstream emits (spec-only); a + // binding that reads only `types`/`commands`/`events` never sees vendor fields. + const vendor = extractVendor(types) + const schema = { schemaVersion: 1, commands, events, types, domains } + if (Object.keys(vendor).length) schema.vendor = vendor + return schema +} + +/** + * Move every vendor-tagged field out of the shared `types` and into a `{ : { extends: + * { : { via, fields } } } }` structure. A field's `via` names the spec extension point + * it flowed through; the field having resolved into a real shared record (via the `//=` fold and + * group flatten) is what proves the merge happened — this only re-routes the output. The pure + * extension-point anchor type (e.g. `webExtension.InstallParametersExtension`), left with no + * spec fields once its vendor fields move out, is dropped from the shared schema. + * With no vendor tags present this returns `{}` and mutates nothing, so output is unchanged. + * @param {object} types The projected `types` map (mutated in place). + * @returns {object} The vendor section, empty when there are no vendor fields. + */ +function extractVendor(types) { + const vendor = {} + const anchors = new Set() + for (const [typeName, node] of Object.entries(types)) { + if (node.kind !== 'record' || !Array.isArray(node.fields)) continue + const kept = [] + for (const field of node.fields) { + if (!field.vendor) { + kept.push(field) + continue + } + anchors.add(field.via) + // The extension point's own type carries a copy of its fields; drop that copy (the anchor + // itself is removed below) and route only the copy that resolved into a real target type. + if (field.via === typeName) continue + const { vendor: ns, via, ...clean } = field + const bucket = (vendor[ns] ??= { extends: {} }) + const entry = (bucket.extends[typeName] ??= { via, fields: [] }) + entry.fields.push(clean) + } + node.fields = kept + } + for (const name of anchors) { + const anchor = types[name] + if (anchor && anchor.kind === 'record' && (anchor.fields?.length ?? 0) === 0) delete types[name] + } + return vendor } /** diff --git a/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb b/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb index e29a68c452eed..825dcd6424308 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb @@ -28,13 +28,6 @@ module Protocol # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#module-webExtension class WebExtension < Domain - # @api private - # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ - # @see https://w3c.github.io/webdriver-bidi/#cddl-type-webextensioninstallparameters - InstallParameters = Serialization::Record.define( - extension_data: {wire_key: 'extensionData', ref: 'WebExtension::ExtensionData'} - ) - # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#cddl-type-webextensionextensiondata @@ -82,6 +75,14 @@ class ExtensionData < Serialization::Union # @see https://w3c.github.io/webdriver-bidi/#cddl-type-webextensionuninstallparameters UninstallParameters = Serialization::Record.define(extension: {wire_key: 'extension', primitive: 'string'}) + # @api private + # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ + # @see https://w3c.github.io/webdriver-bidi/#cddl-type-webextensioninstallparameters + InstallParameters = Serialization::Record.define( + extension_data: {wire_key: 'extensionData', ref: 'WebExtension::ExtensionData'}, + extensible: true + ) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-webExtension-install @@ -97,6 +98,23 @@ def uninstall(extension:) params = UninstallParameters.new(extension: extension) execute(cmd: 'webExtension.uninstall', params: params) end + + # @api private + # moz: vendor variant of WebExtension, overriding commands with browser-specific params. + # Construct Moz.new(source) for a matching session; other sessions use WebExtension. + class Moz < WebExtension + # @api private + # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ + # @see https://w3c.github.io/webdriver-bidi/#command-webExtension-install + def install(extension_data:, allow_private_browsing: Serialization::UNSET, permanent: Serialization::UNSET) + extensions = { + 'moz:allowPrivateBrowsing' => allow_private_browsing, + 'moz:permanent' => permanent + }.reject { |_, value| Serialization::UNSET.equal?(value) } + params = InstallParameters.new(extension_data: extension_data, extensions: extensions) + execute(cmd: 'webExtension.install', params: params, result: WebExtension::InstallResult) + end + end end # WebExtension end # Protocol end # BiDi diff --git a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb index dff92a18506a5..402152f2a0e55 100644 --- a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb +++ b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb @@ -105,6 +105,9 @@ def self.safe_method_name(name) # Append underscore to a field name that would shadow a core method; the wire # name is unaffected, only the Ruby reader is renamed. def self.safe_field_name(name) + # A vendor-prefixed wire name carries a colon (moz:allowPrivateBrowsing); swap it + # for an underscore so the Ruby reader is a legal identifier. The wire key is kept. + name = name.tr(':', '_') RESERVED_FIELD_NAMES.include?(name) ? "#{name}_" : name end @@ -223,6 +226,81 @@ def execute_call(indent) end end + # A browser-specific extension to a command, kept out of the shared class so a + # non-matching browser never sees it. shared_params are the base command's own + # (required) params, forwarded verbatim; vendor_params are the typed extra fields, + # composed into the extensible params record's passthrough bag under their exact + # wire keys. params_class/result_ref/wire_name mirror the base command. + VendorCommand = Struct.new(:method_name, :wire_name, :result_ref, :params_class, + :shared_params, :vendor_params, :spec_href, keyword_init: true) do + def def_header(indent) + BiDiGenerate.wrap_call("def #{method_name}", shared_params.map(&:sig_part) + vendor_params.map(&:sig_part), + indent) + end + + # The full `def … end` method block, fully indented from `indent`. Optional vendor + # fields are placed into the passthrough bag only when set (UNSET stays omitted), so + # they serialize exactly like a field on the extensible record. + def render_lines(indent) + body = ' ' * (indent + 2) + [*doc_lines(' ' * indent), "#{' ' * indent}#{def_header(indent)}", *extensions_lines(body, indent), + params_line(body, indent), execute_line(body, indent), "#{' ' * indent}end"] + end + + def doc_lines(pad) + lines = ["#{pad}# @api private", "#{pad}# @see #{BiDiGenerate::BIDI_DOC_URL}"] + lines << "#{pad}# @see #{spec_href}" if spec_href + lines + end + + # The extensible passthrough bag, carrying each set vendor field under its exact wire key. + def extensions_lines(body, indent) + inner = ' ' * (indent + 4) + entries = vendor_params.map { |p| "#{inner}'#{p.wire_name}' => #{p.ruby_name}" }.join(",\n") + ["#{body}extensions = {", entries, "#{body}}.reject { |_, value| Serialization::UNSET.equal?(value) }"] + end + + def params_line(body, indent) + kwargs = shared_params.map { |p| "#{p.ruby_name}: #{p.ruby_name}" } + ['extensions: extensions'] + "#{body}#{BiDiGenerate.wrap_call("params = #{params_class}.new", kwargs, indent + 2)}" + end + + def execute_line(body, indent) + args = ["cmd: '#{wire_name}'", 'params: params'] + args << "result: #{result_ref}" if result_ref + "#{body}#{BiDiGenerate.wrap_call('execute', args, indent + 2)}" + end + + def rbs_signature + params = (shared_params.map(&:rbs_part) + vendor_params.map(&:rbs_part)).join(', ') + ret = result_ref ? "::Selenium::WebDriver::BiDi::Protocol::#{result_ref}" : 'untyped' + "(#{params}) -> #{ret}" + end + end + + # A namespaced group of browser-specific command extensions (e.g. Firefox's `moz:` + # fields), emitted as a subclass of the domain that overrides the extended commands. + # A subclass (rather than a runtime-mixed module) keeps the vendor signatures statically + # visible to type checkers, and is constructed directly (`.new(source)`) for a + # matching session — no factory or runtime mix-in. + VendorModule = Struct.new(:name, :namespace, :parent, :commands, keyword_init: true) do + def render(indent) + pad = ' ' * indent + lines = [ + "#{pad}# @api private", + "#{pad}# #{namespace}: vendor variant of #{parent}, overriding commands with browser-specific params.", + "#{pad}# Construct #{name}.new(source) for a matching session; other sessions use #{parent}.", + "#{pad}class #{name} < #{parent}" + ] + commands.each_with_index do |cmd, index| + lines << '' unless index.zero? + lines.concat(cmd.render_lines(indent + 2)) + end + lines << "#{pad}end" + lines.join("\n") + end + end + # payload_ref is the Protocol-relative class the event's params parse into (nil when # non-structured, dispatched raw) — the inbound counterpart to a command's result_ref. Event = Struct.new(:wire_name, :event_name, :payload_ref, keyword_init: true) do @@ -342,7 +420,9 @@ def rbs_new_args parts = [] parts << "?#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator parts.concat(fields.map(&:rbs_arg)) - parts << '?extensions: untyped' if extensible + # Match the reader type and the extensible Record impl (which calls `merge!`/`empty?` on it), + # so a type checker rejects a non-Hash before it crashes at serialization. + parts << '?extensions: Hash[String, untyped]' if extensible parts.join(', ') end end @@ -390,8 +470,8 @@ def discriminator_decl(indent) end # spec_href links the domain's module section in the live spec (nil when unknown). - Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, :spec_href, - keyword_init: true) + Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, :vendor_modules, + :spec_href, keyword_init: true) class Schema def initialize(schema) @@ -399,6 +479,7 @@ def initialize(schema) @commands = schema['commands'] @events = schema['events'] @domains = schema['domains'] || {} + @vendor = schema['vendor'] || {} promote_command_params_records! end @@ -443,6 +524,59 @@ def commands_for(domain) @commands.select { |c| c['domain'] == domain } end + # The vendor modules a domain carries, one per namespace (`moz` → module `Moz`). The + # schema's `vendor` section names, per namespace, which shared type each vendor extends; + # we map that type back to the command that sends it, so the vendor method mirrors the + # base command's wire method and result while adding the typed vendor fields. Empty for + # any domain (or schema) with no vendor extensions, so non-vendor output is unaffected. + def vendor_modules_for(domain) + parent = BiDiGenerate.snake_to_class_name(BiDiGenerate.camel_to_snake(domain)) + groups = Hash.new { |h, k| h[k] = [] } + @vendor.each do |namespace, spec| + (spec['extends'] || {}).each do |type_name, entry| + cmd = @commands.find { |c| c.dig('params', 'ref') == type_name } + next unless cmd && cmd['domain'] == domain + + groups[namespace] << build_vendor_command(cmd, type_name, entry, namespace) + end + end + groups.map do |namespace, commands| + VendorModule.new(name: BiDiGenerate.snake_to_class_name(namespace), namespace: namespace, parent: parent, + commands: commands) + end + end + + def build_vendor_command(cmd, type_name, entry, namespace) + shared = record_params(@types[type_name]['fields']) + taken = shared.map(&:ruby_name) + VendorCommand.new( + method_name: BiDiGenerate.safe_method_name(BiDiGenerate.camel_to_snake(cmd['name'])), + wire_name: cmd['method'], + result_ref: cmd['result'] && structured_ref(cmd['result']['ref']), + params_class: BiDiGenerate.type_class_name(type_name), + shared_params: shared, + vendor_params: entry['fields'].map { |field| vendor_param(field, namespace, taken) }, + spec_href: cmd['specHref'] + ) + end + + # A vendor field's ruby name drops its namespace prefix (`moz:permanent` → permanent): the + # module already scopes it, so re-encoding the namespace in every identifier is redundant. The + # wire key is untouched. Falls back to the prefixed name only if stripping would collide with a + # shared param on the same command. + def vendor_param(field, namespace, taken) + stripped = field['name'].sub(/\A#{Regexp.escape(namespace)}:/, '') + ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(stripped)) + ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])) if taken.include?(ruby_name) + Param.new( + ruby_name: ruby_name, + wire_name: field['wire'], + required: field['required'], + enum: enum_const(field['type']), + rbs: rbs_type(field['type']) + ) + end + def type_kind(ref) @types[ref]&.fetch('kind', nil) end @@ -916,6 +1050,7 @@ def self.build_ir(schema) events: schema.events_for(domain).map { |ev| build_event(schema, ev) }, enums: schema.enums_for(domain), types: nest_synthetic(schema.types_for(domain)), + vendor_modules: schema.vendor_modules_for(domain), spec_href: schema.domain_href(domain) ) end diff --git a/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb b/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb index 572bef45643cd..687d44169a015 100644 --- a/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb +++ b/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb @@ -119,6 +119,10 @@ module Selenium <%- end -%> <%= cmd.execute_call(12) %> end +<%- end -%> +<%- mod.vendor_modules.each do |vendor_module| -%> + +<%= vendor_module.render(10) %> <%- end -%> end # <%= mod.ruby_class %> end # Protocol diff --git a/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb b/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb index 4da91cc403e2f..3d4b6711cef44 100644 --- a/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb +++ b/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb @@ -68,6 +68,14 @@ module Selenium <%- end -%> <%- mod.commands.each do |cmd| -%> def <%= cmd.method_name %>: <%= cmd.rbs_signature %> +<%- end -%> +<%- mod.vendor_modules.each do |vendor_module| -%> + + class <%= vendor_module.name %> < ::Selenium::WebDriver::BiDi::Protocol::<%= mod.ruby_class %> +<%- vendor_module.commands.each do |cmd| -%> + def <%= cmd.method_name %>: <%= cmd.rbs_signature %> +<%- end -%> + end <%- end -%> end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs index 89ebfcf0ad97e..7f734e5c521df 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs @@ -232,14 +232,14 @@ module Selenium attr_reader shared_id: String attr_reader handle: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (shared_id: String, ?handle: String, ?extensions: untyped) -> instance + def self.new: (shared_id: String, ?handle: String, ?extensions: Hash[String, untyped]) -> instance end class RemoteObjectReference < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader handle: String attr_reader shared_id: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (handle: String, ?shared_id: String, ?extensions: untyped) -> instance + def self.new: (handle: String, ?shared_id: String, ?extensions: Hash[String, untyped]) -> instance end class RemoteValue < ::Selenium::WebDriver::BiDi::Serialization::Union diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs index b81073cc49179..dbe53a85f68b3 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs @@ -39,7 +39,7 @@ module Selenium attr_reader proxy: untyped attr_reader unhandled_prompt_behavior: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (?accept_insecure_certs: bool, ?browser_name: String, ?browser_version: String, ?platform_name: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?extensions: untyped) -> instance + def self.new: (?accept_insecure_certs: bool, ?browser_name: String, ?browser_version: String, ?platform_name: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?extensions: Hash[String, untyped]) -> instance end class ProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Union @@ -48,13 +48,13 @@ module Selenium class AutodetectProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader proxy_type: String attr_reader extensions: Hash[String, untyped] - def self.new: (?proxy_type: String, ?extensions: untyped) -> instance + def self.new: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> instance end class DirectProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader proxy_type: String attr_reader extensions: Hash[String, untyped] - def self.new: (?proxy_type: String, ?extensions: untyped) -> instance + def self.new: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> instance end class ManualProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -65,7 +65,7 @@ module Selenium attr_reader socks_version: Integer attr_reader no_proxy: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (?proxy_type: String, ?http_proxy: String, ?ssl_proxy: String, socks_proxy: String, socks_version: Integer, ?no_proxy: Array[String], ?extensions: untyped) -> instance + def self.new: (?proxy_type: String, ?http_proxy: String, ?ssl_proxy: String, socks_proxy: String, socks_version: Integer, ?no_proxy: Array[String], ?extensions: Hash[String, untyped]) -> instance end class SocksProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -78,13 +78,13 @@ module Selenium attr_reader proxy_type: String attr_reader proxy_autoconfig_url: String attr_reader extensions: Hash[String, untyped] - def self.new: (?proxy_type: String, proxy_autoconfig_url: String, ?extensions: untyped) -> instance + def self.new: (?proxy_type: String, proxy_autoconfig_url: String, ?extensions: Hash[String, untyped]) -> instance end class SystemProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader proxy_type: String attr_reader extensions: Hash[String, untyped] - def self.new: (?proxy_type: String, ?extensions: untyped) -> instance + def self.new: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> instance end class UserPromptHandler < ::Selenium::WebDriver::BiDi::Serialization::Record diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs index e3840139a0d0c..a81b5c58e216d 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs @@ -40,7 +40,7 @@ module Selenium attr_reader same_site: untyped attr_reader expiry: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (?name: String, ?value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?domain: String, ?path: String, ?size: Integer, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: untyped) -> instance + def self.new: (?name: String, ?value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?domain: String, ?path: String, ?size: Integer, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance end class BrowsingContextPartitionDescriptor < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -54,7 +54,7 @@ module Selenium attr_reader user_context: untyped attr_reader source_origin: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (?type: String, ?user_context: String, ?source_origin: String, ?extensions: untyped) -> instance + def self.new: (?type: String, ?user_context: String, ?source_origin: String, ?extensions: Hash[String, untyped]) -> instance end class PartitionDescriptor < ::Selenium::WebDriver::BiDi::Serialization::Union @@ -82,7 +82,7 @@ module Selenium attr_reader same_site: untyped attr_reader expiry: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, domain: String, ?path: String, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: untyped) -> instance + def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, domain: String, ?path: String, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance end class SetCookieParameters < ::Selenium::WebDriver::BiDi::Serialization::Record diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs index 7cd0719782554..78b4faa84ba19 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs @@ -23,11 +23,6 @@ module Selenium class BiDi module Protocol class WebExtension < ::Selenium::WebDriver::BiDi::Protocol::Domain - class InstallParameters < ::Selenium::WebDriver::BiDi::Serialization::Record - attr_reader extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData - def self.new: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData) -> instance - end - class ExtensionData < ::Selenium::WebDriver::BiDi::Serialization::Union end @@ -59,8 +54,18 @@ module Selenium def self.new: (extension: String) -> instance end + class InstallParameters < ::Selenium::WebDriver::BiDi::Serialization::Record + attr_reader extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData + attr_reader extensions: Hash[String, untyped] + def self.new: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData, ?extensions: Hash[String, untyped]) -> instance + end + def install: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::InstallResult def uninstall: (extension: String) -> untyped + + class Moz < ::Selenium::WebDriver::BiDi::Protocol::WebExtension + def install: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData, ?allow_private_browsing: bool, ?permanent: bool) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::InstallResult + end end end end diff --git a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb index 3cbb110fe79ae..b56ebf618a7c5 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb @@ -243,6 +243,54 @@ def valid_cookie_attrs end end + describe 'webExtension.install Firefox (moz:) vendor extension' do + let(:extension) { WebExtension::ExtensionPath.new(path: '/tmp/ext') } + + # Construct the moz vendor subclass directly, with execute stubbed to capture the + # params the vendor install would send. + def moz_install(**kwargs) + captured = nil + connection = Object.new + connection.define_singleton_method(:send_cmd) { |**| {} } + domain = WebExtension::Moz.new(connection) + domain.define_singleton_method(:execute) { |params:, **| captured = params } + domain.install(extension_data: extension, **kwargs) + captured + end + + it 'composes typed moz: options into the extensible params under their exact wire keys' do + params = moz_install(allow_private_browsing: true, permanent: false) + + expect(params.as_json).to eq( + 'extensionData' => {'type' => 'path', 'path' => '/tmp/ext'}, + 'moz:allowPrivateBrowsing' => true, + 'moz:permanent' => false + ) + end + + it 'omits vendor options left unset' do + params = moz_install(permanent: true) + + expect(params.as_json).to eq( + 'extensionData' => {'type' => 'path', 'path' => '/tmp/ext'}, + 'moz:permanent' => true + ) + end + + it 'keeps moz: off the shared install so non-Firefox sessions never see it' do + shared = WebExtension.instance_method(:install).parameters.map(&:last) + + expect(shared).to eq([:extension_data]) + end + + # #1140 makes InstallParameters extensible, so a not-yet-typed vendor key still rides along. + it 'passes an unknown vendor key through the extensions bag' do + params = WebExtension::InstallParameters.new(extension_data: extension, extensions: {'moz:future' => 1}) + + expect(params.as_json).to include('moz:future' => 1) + end + end + describe 'outbound union command params' do it 'sends explicit null for a nullable union field a flat hash would have dropped' do params = Emulation::SetGeolocationOverrideParameters.build(coordinates: nil) From fc654de397f5afdbf1620f09599575a0bd8be1f8 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 29 Jul 2026 12:15:15 -0500 Subject: [PATCH 08/56] [grid] honor client-advertised se:remoteUrl for reachable BiDi/CDP/VNC URLs (#17790) * [grid] honor client-advertised se:remoteUrl in the Node with grid-url precedence * [grid] advertise se:remoteUrl from the language bindings * [grid] fix se:remoteUrl on URL constructor, https scheme casing, and builder capability conflict * [grid] strip se:remoteUrl from returned session caps * [grid] use only the origin of se:remoteUrl for proxied URLs --- .../webdriver/Remote/HttpCommandExecutor.cs | 5 + dotnet/src/webdriver/WebDriver.cs | 60 ++++- .../selenium/grid/node/local/LocalNode.java | 76 +++++- .../grid/node/local/LocalNodeFactory.java | 1 + .../selenium/remote/RemoteWebDriver.java | 15 +- .../remote/RemoteWebDriverBuilder.java | 18 +- .../openqa/selenium/grid/node/NodeTest.java | 230 ++++++++++++++++++ .../remote/RemoteWebDriverBuilderTest.java | 47 ++++ .../remote/RemoteWebDriverUnitTest.java | 26 ++ javascript/selenium-webdriver/index.js | 5 + py/selenium/webdriver/remote/webdriver.py | 10 + .../webdriver/remote/new_session_tests.py | 34 ++- rb/lib/selenium/webdriver/remote/driver.rb | 4 +- .../selenium/webdriver/remote/driver_spec.rb | 6 +- 14 files changed, 521 insertions(+), 16 deletions(-) diff --git a/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs b/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs index 2066ae14a3f3f..c226e8a943d14 100644 --- a/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs +++ b/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs @@ -111,6 +111,11 @@ public HttpCommandExecutor(Uri addressOfRemoteServer, TimeSpan timeout, bool ena /// public string UserAgent { get; set; } + ///

+ /// Gets the address of the remote end this executor connects to. + /// + internal Uri RemoteServerUri => this.remoteServerUri; + /// /// Gets the repository of objects containing information about commands. /// diff --git a/dotnet/src/webdriver/WebDriver.cs b/dotnet/src/webdriver/WebDriver.cs index 6e01e32d72cc0..a1358d21a5ef9 100644 --- a/dotnet/src/webdriver/WebDriver.cs +++ b/dotnet/src/webdriver/WebDriver.cs @@ -604,6 +604,11 @@ protected void StartSession(ICapabilities capabilities) { Dictionary matchCapabilities = this.GetCapabilitiesDictionary(capabilities); + if (this.CommandExecutor is Remote.HttpCommandExecutor httpExecutor) + { + matchCapabilities["se:remoteUrl"] = httpExecutor.RemoteServerUri.AbsoluteUri; + } + List firstMatchCapabilitiesList = new List(); firstMatchCapabilitiesList.Add(matchCapabilities); @@ -614,7 +619,35 @@ protected void StartSession(ICapabilities capabilities) } else { - parameters.Add("capabilities", remoteSettings.ToDictionary()); + Dictionary remoteSettingsDictionary = remoteSettings.ToDictionary(); + + // Advertise se:remoteUrl on the caller's behalf, as every other binding does. It must be + // nested in alwaysMatch (the Grid drops top-level metadata), built into a fresh copy so + // the caller-owned RemoteSessionSettings is not mutated. Only a matched capability counts + // as explicit here: a se:remoteUrl set via AddMetadataSetting stays top-level, is ignored + // by the Grid, and does not suppress this injection (the executor URL stays authoritative). + // Skip only when se:remoteUrl already lives in alwaysMatch/firstMatch, to preserve that + // value and avoid an alwaysMatch/firstMatch overlap. If only one of several firstMatch + // alternatives sets it explicitly, injection is suppressed for all of them; that + // multi-alternative case is intentionally not supported. + if (this.CommandExecutor is Remote.HttpCommandExecutor remoteHttpExecutor + && !ContainsMatchCapability(remoteSettingsDictionary, "se:remoteUrl")) + { + Dictionary alwaysMatch = new Dictionary(); + if (remoteSettingsDictionary.TryGetValue("alwaysMatch", out object? existingAlwaysMatch) + && existingAlwaysMatch is IDictionary existingCapabilities) + { + foreach (KeyValuePair capability in existingCapabilities) + { + alwaysMatch[capability.Key] = capability.Value; + } + } + + alwaysMatch["se:remoteUrl"] = remoteHttpExecutor.RemoteServerUri.AbsoluteUri; + remoteSettingsDictionary["alwaysMatch"] = alwaysMatch; + } + + parameters.Add("capabilities", remoteSettingsDictionary); } Response response = this.Execute(DriverCommand.NewSession, parameters); @@ -632,6 +665,31 @@ protected void StartSession(ICapabilities capabilities) this.SessionId = new SessionId(sessionId); } + private static bool ContainsMatchCapability(Dictionary capabilitiesDictionary, string capabilityName) + { + if (capabilitiesDictionary.TryGetValue("alwaysMatch", out object? alwaysMatch) + && alwaysMatch is IDictionary alwaysMatchCapabilities + && alwaysMatchCapabilities.ContainsKey(capabilityName)) + { + return true; + } + + if (capabilitiesDictionary.TryGetValue("firstMatch", out object? firstMatch) + && firstMatch is IEnumerable firstMatchCandidates) + { + foreach (object candidate in firstMatchCandidates) + { + if (candidate is IDictionary firstMatchCapabilities + && firstMatchCapabilities.ContainsKey(capabilityName)) + { + return true; + } + } + } + + return false; + } + /// /// Gets the capabilities as a dictionary. /// diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java index 06b78eaa89405..7c15b4b718b9a 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -145,6 +145,7 @@ public class LocalNode extends Node implements Closeable { private final EventBus bus; private final URI externalUri; private final URI gridUri; + private final boolean gridUrlSpecified; private final Duration heartbeatPeriod; private final HealthCheck healthCheck; private final int maxSessionCount; @@ -175,6 +176,7 @@ protected LocalNode( EventBus bus, URI uri, URI gridUri, + boolean gridUrlSpecified, @Nullable HealthCheck healthCheck, int maxSessionCount, int drainAfterSessionCount, @@ -200,6 +202,7 @@ protected LocalNode( this.externalUri = Require.nonNull("Remote node URI", uri); this.gridUri = Require.nonNull("Grid URI", gridUri); + this.gridUrlSpecified = gridUrlSpecified; this.maxSessionCount = Math.min(Require.positive("Max session count", maxSessionCount), factories.size()); this.heartbeatPeriod = heartbeatPeriod; @@ -1221,10 +1224,17 @@ private Session createExternalSession( Capabilities toUse = ImmutableCapabilities.copyOf(requestCapabilities.merge(other.getCapabilities())); + URI baseUri = resolvePublicGridUri(toUse); + + // se:remoteUrl is transport-only: it is consumed above to resolve the public URI, so drop it + // from the returned capabilities rather than echo it (and any embedded credentials) back to the + // client, into session-created events, or into the session-created log line. + toUse = removeCapability(toUse, "se:remoteUrl"); + // Add se:cdp if necessary to send the cdp url back if ((isSupportingCdp || toUse.getCapability("se:cdp") != null) && cdpEnabled) { String cdpPath = String.format("/session/%s/se/cdp", other.getId()); - toUse = new PersistentCapabilities(toUse).setCapability("se:cdp", rewrite(cdpPath)); + toUse = new PersistentCapabilities(toUse).setCapability("se:cdp", rewrite(cdpPath, baseUri)); } else { // Remove any se:cdp* from the response, CDP is not supported nor enabled MutableCapabilities cdpFiltered = new MutableCapabilities(); @@ -1259,7 +1269,7 @@ private Session createExternalSession( toUse = new PersistentCapabilities(toUse) .setCapability("se:gridWebSocketUrl", uri) - .setCapability("webSocketUrl", rewrite(bidiPath)); + .setCapability("webSocketUrl", rewrite(bidiPath, baseUri)); } else { // Remove any "webSocketUrl" from the response, BiDi is not supported nor enabled MutableCapabilities bidiFiltered = new MutableCapabilities(); @@ -1278,23 +1288,68 @@ private Session createExternalSession( boolean isVncEnabled = toUse.getCapability("se:vncLocalAddress") != null; if (isVncEnabled) { String vncPath = String.format("/session/%s/se/vnc", other.getId()); - toUse = new PersistentCapabilities(toUse).setCapability("se:vnc", rewrite(vncPath)); + toUse = new PersistentCapabilities(toUse).setCapability("se:vnc", rewrite(vncPath, baseUri)); } return new Session(other.getId(), externalUri, other.getStereotype(), toUse, Instant.now()); } - private URI rewrite(String path) { + private URI rewrite(String path, URI baseUri) { try { - String scheme = "https".equals(gridUri.getScheme()) ? "wss" : "ws"; - path = NodeOptions.normalizeSubPath(gridUri.getPath()) + path; + String scheme = "https".equalsIgnoreCase(baseUri.getScheme()) ? "wss" : "ws"; + path = NodeOptions.normalizeSubPath(baseUri.getPath()) + path; return new URI( - scheme, gridUri.getUserInfo(), gridUri.getHost(), gridUri.getPort(), path, null, null); + scheme, baseUri.getUserInfo(), baseUri.getHost(), baseUri.getPort(), path, null, null); } catch (URISyntaxException e) { throw new RuntimeException(e); } } + private Capabilities removeCapability(Capabilities caps, String name) { + MutableCapabilities filtered = new MutableCapabilities(); + caps.asMap() + .forEach( + (key, value) -> { + if (!name.equals(key)) { + filtered.setCapability(key, value); + } + }); + return new PersistentCapabilities(filtered); + } + + // A configured grid-url always wins; only when the node falls back to its auto-detected address + // (which may be unreachable behind Docker/proxy) do we use the client-advertised se:remoteUrl. + private URI resolvePublicGridUri(Capabilities caps) { + if (gridUrlSpecified) { + return gridUri; + } + Object raw = caps.getCapability("se:remoteUrl"); + if (raw instanceof String && !((String) raw).isEmpty()) { + String value = (String) raw; + try { + URI uri = new URI(value); + String scheme = uri.getScheme(); + if (uri.getHost() != null + && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { + // Use only the reachable origin (scheme/userinfo/host/port). se:remoteUrl advertises + // where the client reached the Grid so proxied URLs get a reachable host/port; the URL's + // path is the client's HTTP endpoint (e.g. "/wd/hub"), not a Grid sub-path. Folding it in + // would produce websocket URLs the Node's routes (keyed off the configured grid-url + // sub-path) do not match. A real reverse-proxy path prefix is configured via grid-url. + return new URI(scheme, uri.getUserInfo(), uri.getHost(), uri.getPort(), null, null, null); + } + } catch (URISyntaxException e) { + // Fall through to the warning below. + } + LOG.warning( + () -> + String.format( + "Ignoring unusable se:remoteUrl '%s'; using %s for proxied URLs", + value, gridUri)); + } + return gridUri; + } + @Override public NodeStatus getStatus() { Set slots = @@ -1457,6 +1512,7 @@ public static class Builder { private final Secret registrationSecret; private final List factories; private final List interceptors = new ArrayList<>(); + private boolean gridUrlSpecified = false; private int maxSessions = NodeOptions.DEFAULT_MAX_SESSIONS; private int drainAfterSessionCount = NodeOptions.DEFAULT_DRAIN_AFTER_SESSION_COUNT; private boolean cdpEnabled = NodeOptions.DEFAULT_ENABLE_CDP; @@ -1548,12 +1604,18 @@ public Builder addInterceptor(NodeCommandInterceptor interceptor) { return this; } + public Builder gridUrlSpecified(boolean configured) { + this.gridUrlSpecified = configured; + return this; + } + public LocalNode build() { return new LocalNode( tracer, bus, uri, gridUri, + gridUrlSpecified, healthCheck, maxSessions, drainAfterSessionCount, diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java b/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java index 25c7f816159a8..631b77680d717 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java @@ -70,6 +70,7 @@ public static Node create(Config config) { serverOptions.getExternalUri(), nodeOptions.getPublicGridUri().orElseGet(serverOptions::getExternalUri), secretOptions.getRegistrationSecret()) + .gridUrlSpecified(nodeOptions.getPublicGridUri().isPresent()) .maximumConcurrentSessions(nodeOptions.getMaxSessions()) .sessionTimeout(sessionTimeout) .drainAfterSessionCount(nodeOptions.getDrainAfterSessionCount()) diff --git a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java index 4573903e94daf..b80dfd0eb655d 100644 --- a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java +++ b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java @@ -181,7 +181,7 @@ public RemoteWebDriver(URL remoteAddress, Capabilities capabilities, ClientConfi Boolean.parseBoolean(System.getProperty(WEBDRIVER_REMOTE_ENABLE_TRACING, "true")), clientConfig), Require.nonNull("Capabilities", capabilities), - clientConfig); + clientConfig.baseUrl(remoteAddress)); } public RemoteWebDriver(URL remoteAddress, Capabilities capabilities, boolean enableTracing) { @@ -196,7 +196,7 @@ public RemoteWebDriver( this( createExecutor(Require.nonNull("Server URL", remoteAddress), enableTracing, clientConfig), Require.nonNull("Capabilities", capabilities), - clientConfig); + clientConfig.baseUrl(remoteAddress)); } public RemoteWebDriver(CommandExecutor executor, Capabilities capabilities) { @@ -260,9 +260,20 @@ protected void setSessionId(String opaqueKey) { sessionId = new SessionId(opaqueKey); } + private Capabilities addRemoteUrl(Capabilities capabilities) { + URI baseUri = clientConfig.baseUri(); + if (baseUri == null) { + return capabilities; + } + MutableCapabilities withRemoteUrl = new MutableCapabilities(capabilities); + withRemoteUrl.setCapability("se:remoteUrl", baseUri.toString()); + return withRemoteUrl; + } + protected void startSession(Capabilities capabilities) { checkNonW3CCapabilities(capabilities); checkChromeW3CFalse(capabilities); + capabilities = addRemoteUrl(capabilities); try { Response response = execute(DriverCommand.NEW_SESSION(singleton(capabilities))); diff --git a/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java b/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java index 036d441f1d733..b1cf0ecc5a1d6 100644 --- a/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java +++ b/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java @@ -526,11 +526,27 @@ private Set getClobberedCapabilities() { .collect(Collectors.toSet()); } + // If any requested capability sets se:remoteUrl explicitly, auto-injection is suppressed for the + // whole payload to preserve that value and avoid an alwaysMatch/firstMatch overlap. When several + // first-match alternatives are supplied and only some set it, the others do not receive the + // client-reachable URL; that multi-alternative case is intentionally not supported. + private boolean hasExplicitRemoteUrl() { + return additionalCapabilities.containsKey("se:remoteUrl") + || requestedCapabilities.stream() + .anyMatch(caps -> caps.getCapabilityNames().contains("se:remoteUrl")); + } + private NewSessionPayload getPayload() { Map roughPayload = new TreeMap<>(metadata); + Map alwaysMatch = new TreeMap<>(additionalCapabilities); + URI baseUri = getBaseUri(); + if (baseUri != null && driverService == null && !hasExplicitRemoteUrl()) { + alwaysMatch.put("se:remoteUrl", baseUri.toString()); + } + Map w3cCaps = new TreeMap<>(); - w3cCaps.put("alwaysMatch", additionalCapabilities); + w3cCaps.put("alwaysMatch", alwaysMatch); if (!requestedCapabilities.isEmpty()) { w3cCaps.put("firstMatch", requestedCapabilities); } diff --git a/java/test/org/openqa/selenium/grid/node/NodeTest.java b/java/test/org/openqa/selenium/grid/node/NodeTest.java index 92f55004b4e12..0791588976d69 100644 --- a/java/test/org/openqa/selenium/grid/node/NodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/NodeTest.java @@ -209,6 +209,236 @@ void shouldCreateASessionIfTheCorrectCapabilitiesArePassedToIt() { assertThat(sessionResponse.getSession()).isNotNull(); } + @Test + void usesClientReachableAddressForProxiedUrlsWhenRemoteUrlProvided() { + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:9999/session/" + session.getId() + "/se/vnc"); + } + + @Test + void ignoresRemoteUrlPathWhenBuildingProxiedUrls() { + // The client's URL path (e.g. "/wd/hub") is its HTTP endpoint, not a Grid sub-path; folding it + // into the proxied websocket URLs would produce routes the Node does not serve. Only the + // origin (host/port) of se:remoteUrl is used. + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999/wd/hub"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:9999/session/" + session.getId() + "/se/vnc"); + } + + @Test + void doesNotReturnRemoteUrlInSessionCapabilities() { + Capabilities request = + new ImmutableCapabilities( + "browserName", "cheese", "se:remoteUrl", "http://user:secret@localhost:9999"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + // se:remoteUrl is transport-only: it is consumed to build the proxied URLs and must not be + // echoed back in the session capabilities, where its embedded credentials could leak. + Session session = response.right().getSession(); + assertThat(session.getCapabilities().getCapability("se:remoteUrl")).isNull(); + } + + @Test + void preservesCredentialsFromClientAdvertisedRemoteUrl() { + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://user:secret@localhost:9999"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://user:secret@localhost:9999/session/" + session.getId() + "/se/vnc"); + } + + @Test + void ignoresRemoteUrlWhenPublicGridUrlIsConfigured() throws URISyntaxException { + URI configuredGridUri = new URI("http://grid.example:4444"); + + class Handler extends Session implements HttpHandler { + private Handler(Capabilities capabilities) { + super(new SessionId(UUID.randomUUID()), uri, stereotype, capabilities, Instant.now()); + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + return new HttpResponse(); + } + } + + LocalNode node = + LocalNode.builder(tracer, bus, uri, configuredGridUri, registrationSecret) + .gridUrlSpecified(true) + .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) + .build(); + + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + node.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://grid.example:4444/session/" + session.getId() + "/se/vnc"); + } + + @Test + void preservesCredentialsFromConfiguredPublicGridUrl() throws URISyntaxException { + URI configuredGridUri = new URI("http://user:secret@grid.example:4444"); + + class Handler extends Session implements HttpHandler { + private Handler(Capabilities capabilities) { + super(new SessionId(UUID.randomUUID()), uri, stereotype, capabilities, Instant.now()); + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + return new HttpResponse(); + } + } + + LocalNode node = + LocalNode.builder(tracer, bus, uri, configuredGridUri, registrationSecret) + .gridUrlSpecified(true) + .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) + .build(); + + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + node.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://user:secret@grid.example:4444/session/" + session.getId() + "/se/vnc"); + } + + @Test + void ignoresRemoteUrlWhenGridUrlIsConfiguredEvenIfItEqualsNodeAddress() { + // Edge case: grid-url is explicitly configured to the same value as the node's own externalUri. + // The explicit configuration must still win over a client-advertised se:remoteUrl. + class Handler extends Session implements HttpHandler { + private Handler(Capabilities capabilities) { + super(new SessionId(UUID.randomUUID()), uri, stereotype, capabilities, Instant.now()); + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + return new HttpResponse(); + } + } + + LocalNode node = + LocalNode.builder(tracer, bus, uri, uri, registrationSecret) + .gridUrlSpecified(true) + .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) + .build(); + + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + node.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:1234/session/" + session.getId() + "/se/vnc"); + } + + @Test + void ignoresRemoteUrlWithoutHttpScheme() { + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "ftp://evil:21"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:1234/session/" + session.getId() + "/se/vnc"); + } + + @Test + void fallsBackToGridAddressForProxiedUrlsWithoutRemoteUrl() { + Capabilities request = + new ImmutableCapabilities("browserName", "cheese", "se:vncLocalAddress", "localhost:5900"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:1234/session/" + session.getId() + "/se/vnc"); + } + @Test void shouldRetryIfNoMatchingSlotIsAvailable() { Node local = diff --git a/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java b/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java index f0dfdd8bc7ba9..a3c3e64737e3a 100644 --- a/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java +++ b/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java @@ -180,6 +180,24 @@ void shouldAllowMetaDataToBeSet() { assertThat(seen).isTrue(); } + @Test + void advertisesRemoteUrlToTheServer() { + AtomicReference seen = new AtomicReference<>(); + + RemoteWebDriver.builder() + .oneOf(new FirefoxOptions()) + .address("http://localhost:34576") + .connectingWith( + config -> + req -> { + seen.set(listCapabilities(req).get(0).getCapability("se:remoteUrl")); + return CANNED_SESSION_RESPONSE; + }) + .build(); + + assertThat(seen.get()).isEqualTo("http://localhost:34576"); + } + @Test void doesNotAllowFirstMatchToBeUsedAsAMetadataNameAsItIsConfusing() { RemoteWebDriverBuilder builder = RemoteWebDriver.builder(); @@ -265,6 +283,35 @@ public URL getUrl() { assertThat(seen).hasValue(uri); } + @Test + @NullMarked + void doesNotAdvertiseRemoteUrlWhenUsingDriverService() throws IOException { + URI uri = URI.create("http://localhost:9898"); + URL url = uri.toURL(); + + DriverService service = + new FakeDriverService() { + @Override + public URL getUrl() { + return url; + } + }; + + AtomicReference seen = new AtomicReference<>(); + RemoteWebDriver.builder() + .oneOf(new FirefoxOptions()) + .withDriverService(service) + .connectingWith( + config -> + req -> { + seen.set(listCapabilities(req).get(0).getCapability("se:remoteUrl")); + return CANNED_SESSION_RESPONSE; + }) + .build(); + + assertThat(seen.get()).isNull(); + } + @Test void settingBothDriverServiceAndUrlIsAnError() throws IOException { RemoteWebDriverBuilder builder = diff --git a/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java b/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java index 07b72595e5809..2b76b70b8dfe5 100644 --- a/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java +++ b/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java @@ -36,6 +36,7 @@ import static org.openqa.selenium.remote.WebDriverFixture.webDriverExceptionResponder; import java.io.IOException; +import java.net.URI; import java.net.URL; import java.time.Duration; import java.util.ArrayList; @@ -64,6 +65,7 @@ import org.openqa.selenium.WindowType; import org.openqa.selenium.bidi.BiDiException; import org.openqa.selenium.internal.Debug; +import org.openqa.selenium.remote.http.ClientConfig; import org.openqa.selenium.virtualauthenticator.VirtualAuthenticator; import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; @@ -72,6 +74,30 @@ class RemoteWebDriverUnitTest { private static final String ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"; + @Test + void advertisesRemoteUrlWhenStartingRemoteSession() { + RemoteWebDriver driver = + new RemoteWebDriver( + WebDriverFixture.prepareExecutorMock(echoCapabilities), + new ImmutableCapabilities("browserName", "chrome"), + ClientConfig.defaultConfig().baseUri(URI.create("http://grid.example:4444/wd/hub"))); + + assertThat(driver.getCapabilities().getCapability("se:remoteUrl")) + .isEqualTo("http://grid.example:4444/wd/hub"); + } + + @Test + void doesNotAdvertiseRemoteUrlForLocalSession() { + // Local drivers (e.g. ChromeDriver) construct with ClientConfig.defaultConfig(), whose baseUri + // is null, so no se:remoteUrl is added even though the executor targets a driver-service URL. + RemoteWebDriver driver = + new RemoteWebDriver( + WebDriverFixture.prepareExecutorMock(echoCapabilities), + new ImmutableCapabilities("browserName", "chrome")); + + assertThat(driver.getCapabilities().getCapability("se:remoteUrl")).isNull(); + } + @Test void canHandleGetCommand() { WebDriverFixture fixture = new WebDriverFixture(echoCapabilities, nullValueResponder); diff --git a/javascript/selenium-webdriver/index.js b/javascript/selenium-webdriver/index.js index bcc7e3e33d5ba..a79538f350562 100644 --- a/javascript/selenium-webdriver/index.js +++ b/javascript/selenium-webdriver/index.js @@ -658,6 +658,11 @@ class Builder { if (url) { this.log_.fine('Creating session on remote server') + + if (typeof url === 'string') { + capabilities.set('se:remoteUrl', url) + } + let client = Promise.resolve(url).then((url) => new _http.HttpClient(url, this.agent_, this.proxy_)) let executor = new _http.Executor(client) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index b5e7360d844e1..a58395ec00de3 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -391,6 +391,9 @@ def start_session(self, capabilities: dict) -> None: Args: capabilities: A capabilities dict to start the session with. """ + remote_url = self._remote_url() + if remote_url: + capabilities = {**capabilities, "se:remoteUrl": remote_url} caps = _create_caps(capabilities) try: response = self.execute(Command.NEW_SESSION, caps)["value"] @@ -401,6 +404,13 @@ def start_session(self, capabilities: dict) -> None: self.service.stop() raise + def _remote_url(self) -> str | None: + """The address used to reach the Grid, advertised as ``se:remoteUrl`` (None for local drivers).""" + if getattr(self, "service", None) is not None: + return None + client_config = getattr(self.command_executor, "client_config", None) + return getattr(client_config, "remote_server_addr", None) or None + def _wrap_value(self, value): if isinstance(value, dict): converted = {} diff --git a/py/test/unit/selenium/webdriver/remote/new_session_tests.py b/py/test/unit/selenium/webdriver/remote/new_session_tests.py index e593bd9dda929..c87bcb042808e 100644 --- a/py/test/unit/selenium/webdriver/remote/new_session_tests.py +++ b/py/test/unit/selenium/webdriver/remote/new_session_tests.py @@ -34,8 +34,38 @@ def test_converts_proxy_type_value_to_lowercase_for_w3c(mocker): proxy = Proxy({"proxyType": ProxyType.MANUAL, "httpProxy": "foo"}) options.proxy = proxy WebDriver(options=options) - expected_params = {"capabilities": {"firstMatch": [{}], "alwaysMatch": w3c_caps}} - mock.assert_called_with(Command.NEW_SESSION, expected_params) + command, params = mock.call_args[0] + assert command == Command.NEW_SESSION + always_match = params["capabilities"]["alwaysMatch"] + always_match.pop("se:remoteUrl", None) + assert params["capabilities"]["firstMatch"] == [{}] + assert always_match == w3c_caps + + +def test_advertises_remote_url_for_remote_session(mocker): + mock = mocker.patch("selenium.webdriver.remote.webdriver.WebDriver.execute") + driver = WebDriver(command_executor="http://remote.example:4444", options=ArgOptions()) + command, params = mock.call_args[0] + assert command == Command.NEW_SESSION + assert driver._remote_url() is not None + assert params["capabilities"]["alwaysMatch"]["se:remoteUrl"] == driver._remote_url() + + +def test_does_not_advertise_remote_url_for_local_driver(mocker): + mock = mocker.patch("selenium.webdriver.remote.webdriver.WebDriver.execute") + + class LocalLikeDriver(WebDriver): + def __init__(self, **kwargs): + # Local drivers (ChromeDriver, etc.) set ``service`` before start_session runs, + # so the actual new-session payload must omit se:remoteUrl. + self.service = object() + super().__init__(**kwargs) + + driver = LocalLikeDriver(command_executor="http://remote.example:4444", options=ArgOptions()) + command, params = mock.call_args[0] + assert command == Command.NEW_SESSION + assert driver._remote_url() is None + assert "se:remoteUrl" not in params["capabilities"]["alwaysMatch"] def test_works_as_context_manager(mocker): diff --git a/rb/lib/selenium/webdriver/remote/driver.rb b/rb/lib/selenium/webdriver/remote/driver.rb index 5e530d624433d..c5c7e5cc26403 100644 --- a/rb/lib/selenium/webdriver/remote/driver.rb +++ b/rb/lib/selenium/webdriver/remote/driver.rb @@ -37,7 +37,9 @@ def initialize(capabilities: nil, options: nil, service: nil, url: nil, http_cli caps = process_options(options, capabilities) http_client ||= Remote::Http::Default.new(client_config: client_config) - http_client.server_url = url || client_config&.server_url || "http://#{Platform.localhost}:4444/wd/hub" + server_url = url || client_config&.server_url || "http://#{Platform.localhost}:4444/wd/hub" + http_client.server_url = server_url + caps['se:remoteUrl'] = server_url.to_s.chomp('/') super(caps: caps, http_client: http_client, **) @bridge.file_detector = ->((filename, *)) { File.exist?(filename) && filename.to_s } command_list = @bridge.command_list diff --git a/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb b/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb index 51d3c17fa357f..3248461204986 100644 --- a/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb +++ b/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb @@ -30,9 +30,11 @@ module Remote end def expect_request(body: nil, endpoint: nil) - body = (body || {capabilities: {alwaysMatch: {browserName: 'chrome', 'goog:chromeOptions': {}}}}).to_json endpoint ||= 'http://127.0.0.1:4444/wd/hub/session' - stub_request(:post, endpoint).with(body: body).to_return(valid_response) + body ||= {capabilities: {alwaysMatch: {browserName: 'chrome', 'goog:chromeOptions': {}}}} + always_match = body.dig(:capabilities, :alwaysMatch) + always_match['se:remoteUrl'] = endpoint.delete_suffix('/session') if always_match + stub_request(:post, endpoint).with(body: body.to_json).to_return(valid_response) end it 'requires parameters' do From 618f12b0061846fa5c9c0c0362ba9db617dfe40a Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 29 Jul 2026 12:15:50 -0500 Subject: [PATCH 09/56] [rb] tolerate and warn on missing required inbound BiDi fields, with SE_BIDI_STRICT to escalate (#17844) --- .../selenium/webdriver/bidi/serialization.rb | 14 +++++- .../webdriver/bidi/serialization/record.rb | 35 ++++++++++++--- .../selenium/webdriver/bidi/serialization.rbs | 6 +++ .../webdriver/bidi/serialization_spec.rb | 43 ++++++++++++++----- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/rb/lib/selenium/webdriver/bidi/serialization.rb b/rb/lib/selenium/webdriver/bidi/serialization.rb index 4b36591c96c49..9e3a05ab3ea65 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization.rb @@ -21,7 +21,8 @@ module Selenium module WebDriver class BiDi # Wire round-trip runtime for the generated protocol layer: the value-type bases - # (Record, Union), the omit sentinel (UNSET), and outbound enum validation. + # (Record, Union), the omit sentinel (UNSET), outbound enum validation, and the + # strict-inbound toggle. # # @api private module Serialization @@ -33,6 +34,17 @@ module Serialization def UNSET.inspect = 'UNSET' UNSET.freeze + # Strict inbound mode. Off by default: a required field missing from a response is + # tolerated as omitted and warned, so a schema ahead of the browser does not block the + # caller. When SE_BIDI_STRICT is set to anything but 0/false, that same case escalates + # to an error for callers who want it. + # + # @api private + def self.strict? + value = ENV.fetch('SE_BIDI_STRICT', '').strip.downcase + !value.empty? && value != '0' && value != 'false' + end + # Validates an outbound enum argument: +value+ is a symbol (or list of symbols) that # must be a key of the enum hash (+{symbol => wire_token}+), so a bad value fails # locally with a clear error instead of a round-trip. Outbound only; inbound wire diff --git a/rb/lib/selenium/webdriver/bidi/serialization/record.rb b/rb/lib/selenium/webdriver/bidi/serialization/record.rb index 05450bc311158..7285c8b5c44a9 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/record.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/record.rb @@ -79,9 +79,10 @@ def new(**kwargs) construct(**attributes) end - # Inbound: builds from the wire. A missing required field raises (in +wire_value+), - # enum tokens are mapped back to symbols and an unrecognized one raises (in +read+), and - # extra keys are captured (extensible) or ignored (closed) — strict on shape, lenient on extras. + # Inbound: builds from the wire. A missing required field is omitted and warned (or + # raised in strict mode, in +wire_value+); enum tokens are mapped back to symbols and an + # unrecognized one raises (in +read+); an undeclared property is warned, then captured + # (extensible) or dropped (closed) — strict on shape, lenient on extras. def from_json(json_payload) unless json_payload.is_a?(::Hash) raise Error::WebDriverError, "#{name} expected an object on the wire, got #{json_payload.inspect}" @@ -90,7 +91,9 @@ def from_json(json_payload) attributes = fields.to_h do |f| [f.name, wire_value(f, json_payload)] end - attributes[:extensions] = extra(json_payload) if extensible? + undeclared = extra(json_payload) + warn_undeclared(undeclared) unless undeclared.empty? + attributes[:extensions] = undeclared if extensible? construct(**attributes) end @@ -143,7 +146,19 @@ def wire_value(field, json_payload) return read(field, json_payload[field.wire_key]) if json_payload.key?(field.wire_key) return UNSET unless field.required - raise Error::WebDriverError, "#{name}##{field.name} is required but was missing from the response" + missing_required(field) + end + + # A required field absent from the response is tolerated as omitted (UNSET) and warned, so a + # schema ahead of the browser does not block the caller; strict mode (SE_BIDI_STRICT) escalates + # to an error for callers who want it. Omitted (UNSET) stays distinct from an explicit null (nil), + # which matters for the required-and-nullable fields the schema flags. + def missing_required(field) + message = "#{name}##{field.name} is required but was missing from the response" + raise Error::WebDriverError, message if Serialization.strict? + + WebDriver.logger.warn(message, id: :bidi_missing_required) + UNSET end def read(field, raw) @@ -252,6 +267,16 @@ def extra(json_payload) known = (@wire_keys ||= fields.map(&:wire_key)) json_payload.except(*known) end + + # Forward-compat signal: a property the type does not model is tolerated (retained on an + # extensible type, dropped on a closed one) and warned so schema drift is visible. Tagged + # +:bidi_undeclared_property+ so a caller can silence it via +logger.ignore+. + def warn_undeclared(undeclared) + undeclared.each_key do |key| + WebDriver.logger.warn("#{name} received an undeclared property: #{key.inspect}", + id: :bidi_undeclared_property) + end + end end # @api private diff --git a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs index 214a9958dd4a6..40837e6740134 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs @@ -28,6 +28,8 @@ module Selenium def self.to_symbol: (String name, untyped value, untyped enum) -> untyped + def self.strict?: () -> bool + class Record < ::Data def self.define: (**untyped spec) -> singleton(Record) @@ -68,6 +70,8 @@ module Selenium def wire_value: (untyped field, Hash[untyped, untyped] json_payload) -> untyped + def missing_required: (untyped field) -> untyped + def read: (untyped field, untyped raw) -> untyped def read_ref: (untyped field, untyped raw) -> untyped @@ -85,6 +89,8 @@ module Selenium def scalar_value: (untyped field, untyped value) -> untyped def extra: (Hash[untyped, untyped] json_payload) -> Hash[untyped, untyped] + + def warn_undeclared: (Hash[untyped, untyped] undeclared) -> void end interface _Serializable diff --git a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb index b56ebf618a7c5..2b0525b33ec7e 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb @@ -210,7 +210,9 @@ def valid_cookie_attrs describe 'extensible records' do it 'captures unknown keys and merges them back on serialization' do - parsed = Script::SharedReference.from_json('sharedId' => 's1', 'webdriverValue' => 42) + parsed = nil + expect { parsed = Script::SharedReference.from_json('sharedId' => 's1', 'webdriverValue' => 42) } + .to have_warning(:bidi_undeclared_property) expect(parsed.shared_id).to eq('s1') expect(parsed.extensions).to eq('webdriverValue' => 42) @@ -220,7 +222,9 @@ def valid_cookie_attrs # A re-sendable type (reachable from a command's params, e.g. a cookie filter) keeps # unknown properties so a received-then-resent payload round-trips them. it 'preserves an unknown key on a re-sendable type across a receive/re-send round trip' do - parsed = Storage::CookieFilter.from_json('name' => 'sid', 'x-vendor' => 'keep-me') + parsed = nil + expect { parsed = Storage::CookieFilter.from_json('name' => 'sid', 'x-vendor' => 'keep-me') } + .to have_warning(:bidi_undeclared_property) expect(parsed.extensions).to eq('x-vendor' => 'keep-me') expect(parsed.as_json).to eq('name' => 'sid', 'x-vendor' => 'keep-me') @@ -230,16 +234,19 @@ def valid_cookie_attrs # params), so preserveExtras is false: unknown keys are ignored, not stored/echoed. it 'drops an unknown key on an extensible-but-received-only type on re-serialize' do wire = Network::Cookie.new(**valid_cookie_attrs).as_json.merge('x-vendor' => 'drop-me') - parsed = Network::Cookie.from_json(wire) + parsed = nil + expect { parsed = Network::Cookie.from_json(wire) }.to have_warning(:bidi_undeclared_property) expect(parsed).not_to respond_to(:extensions) expect(parsed.as_json).not_to include('x-vendor') end - it 'ignores an unknown key without raising on a non-extensible type' do + it 'warns on and drops an unknown key on a non-extensible type' do wire = {'type' => 'password', 'username' => 'u', 'password' => 'p', 'x-vendor' => 'v'} + parsed = nil + expect { parsed = Network::AuthCredentials.from_json(wire) }.to have_warning(:bidi_undeclared_property) - expect { Network::AuthCredentials.from_json(wire) }.not_to raise_error + expect(parsed).not_to respond_to(:extensions) end end @@ -404,11 +411,6 @@ def moz_install(**kwargs) # tripping the required-presence check on the others. let(:cookie_wire) { Network::Cookie.new(**valid_cookie_attrs).as_json } - it 'raises when a required field is missing from the response' do - expect { Network::Cookie.from_json('name' => 'sid') } - .to raise_error(Error::WebDriverError, /Cookie#value is required but was missing/) - end - it 'raises when a non-nullable field arrives as explicit null' do expect { Network::Cookie.from_json(cookie_wire.merge('name' => nil)) } .to raise_error(Error::WebDriverError, /Cookie#name received null but is not nullable/) @@ -469,6 +471,27 @@ def moz_install(**kwargs) .to raise_error(Error::WebDriverError, /size expected integer/) end end + + # RequestDeviceInfo is a minimal record: a required `id` and a required-and-nullable `name`. + describe 'inbound required-field tolerance' do + it 'tolerates a missing required-nullable field as omitted (UNSET, not null) and warns' do + parsed = nil + expect { parsed = Bluetooth::RequestDeviceInfo.from_json('id' => 'dev-1') } + .to have_warning(:bidi_missing_required) + explicit = Bluetooth::RequestDeviceInfo.from_json('id' => 'dev-1', 'name' => nil) + + expect(parsed.name).to equal(Serialization::UNSET) + expect(explicit.name).to be_nil + end + + it 'escalates a missing required field to an error in strict mode (SE_BIDI_STRICT)' do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('SE_BIDI_STRICT', '').and_return('true') + + expect { Bluetooth::RequestDeviceInfo.from_json('id' => 'dev-1') } + .to raise_error(Error::WebDriverError, /RequestDeviceInfo#name is required but was missing/) + end + end end end # Protocol end # BiDi From 06be6249a3054d3dbf62e5a2aaa34566dc11a50f Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 29 Jul 2026 12:30:49 -0500 Subject: [PATCH 10/56] [build] reconcile API-compatibility invariant with the deprecation policy in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bb156032cbf5f..89912a5afadec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ The repository README is aimed at contributors; end-user docs live elsewhere. - If `.local/agent/skills/` exists, inspect its `*/SKILL.md` files and treat them as additional user-defined skills. ## Invariants (don't violate unless explicitly asked) -- Maintain API/ABI compatibility - users upgrade by changing only version number +- Maintain API/ABI compatibility by default (users upgrade by changing only the version number); public functionality may be removed only after it has gone through the [Deprecation policy](#deprecation-policy) below - Avoid repo-wide refactors/formatting; prefer small, reversible diffs ## Toolchain From 8daa0d16539539a48703ad2be7df2d4c2449ede1 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 30 Jul 2026 07:47:39 -0500 Subject: [PATCH 11/56] [java][js][rb] remove deprecated FTP proxy support (#17846) --- java/src/org/openqa/selenium/Proxy.java | 35 ------------------- java/test/org/openqa/selenium/ProxyTest.java | 20 +---------- javascript/selenium-webdriver/lib/proxy.js | 19 ++-------- .../selenium-webdriver/test/proxy_test.js | 2 +- rb/lib/selenium/webdriver/common/proxy.rb | 8 ----- rb/lib/selenium/webdriver/firefox/profile.rb | 1 - rb/sig/interfaces/proxy.rbs | 2 -- .../lib/selenium/webdriver/common/proxy.rbs | 4 --- .../webdriver/firefox/profile_spec.rb | 3 -- rb/spec/unit/selenium/webdriver/proxy_spec.rb | 3 -- .../webdriver/remote/http/default_spec.rb | 2 +- 11 files changed, 6 insertions(+), 93 deletions(-) diff --git a/java/src/org/openqa/selenium/Proxy.java b/java/src/org/openqa/selenium/Proxy.java index 3b4383a0e40e8..51f661a9e3555 100644 --- a/java/src/org/openqa/selenium/Proxy.java +++ b/java/src/org/openqa/selenium/Proxy.java @@ -64,7 +64,6 @@ public String toString() { } private static final String PROXY_TYPE = "proxyType"; - @Deprecated private static final String FTP_PROXY = "ftpProxy"; private static final String HTTP_PROXY = "httpProxy"; private static final String NO_PROXY = "noProxy"; private static final String SSL_PROXY = "sslProxy"; @@ -77,7 +76,6 @@ public String toString() { private ProxyType proxyType = ProxyType.UNSPECIFIED; private boolean autodetect = false; - @Deprecated private @Nullable String ftpProxy; private @Nullable String httpProxy; private @Nullable String noProxy; private @Nullable String sslProxy; @@ -96,7 +94,6 @@ public Proxy(Map raw) { setters.put( PROXY_TYPE, value -> setProxyType(ProxyType.valueOf(((String) value).toUpperCase(Locale.ENGLISH)))); - setters.put(FTP_PROXY, value -> setFtpProxy((String) value)); setters.put(HTTP_PROXY, value -> setHttpProxy((String) value)); setters.put( NO_PROXY, @@ -132,9 +129,6 @@ public Map toJson() { if (proxyType != ProxyType.UNSPECIFIED) { m.put(PROXY_TYPE, proxyType.toString()); } - if (ftpProxy != null) { - m.put(FTP_PROXY, ftpProxy); - } if (httpProxy != null) { m.put(HTTP_PROXY, httpProxy); } @@ -218,32 +212,6 @@ public Proxy setAutodetect(boolean autodetect) { return this; } - /** - * Gets the FTP proxy. - * - * @return the FTP proxy hostname if present, or null if not set - * @deprecated getFtpProxy is deprecated and will be removed in a future release. - */ - @Deprecated - public @Nullable String getFtpProxy() { - return ftpProxy; - } - - /** - * Specify which proxy to use for FTP connections. - * - * @param ftpProxy the proxy host, expected format is hostname.com:1234 - * @return reference to self - * @deprecated setFtpProxy is deprecated and will be removed in a future release. - */ - @Deprecated - public Proxy setFtpProxy(String ftpProxy) { - verifyProxyTypeCompatibility(ProxyType.MANUAL); - this.proxyType = ProxyType.MANUAL; - this.ftpProxy = ftpProxy; - return this; - } - /** * Gets the HTTP proxy. * @@ -466,7 +434,6 @@ public String toString() { break; } - Optional.ofNullable(getFtpProxy()).ifPresent(p -> builder.append(", ftp=").append(p)); Optional.ofNullable(getHttpProxy()).ifPresent(p -> builder.append(", http=").append(p)); Optional.ofNullable(getSocksProxy()).ifPresent(p -> builder.append(", socks=").append(p)); Optional.ofNullable(getSslProxy()).ifPresent(p -> builder.append(", ssl=").append(p)); @@ -486,7 +453,6 @@ public boolean equals(@Nullable Object o) { Proxy proxy = (Proxy) o; return isAutodetect() == proxy.isAutodetect() && getProxyType() == proxy.getProxyType() - && Objects.equals(getFtpProxy(), proxy.getFtpProxy()) && Objects.equals(getHttpProxy(), proxy.getHttpProxy()) && Objects.equals(getNoProxy(), proxy.getNoProxy()) && Objects.equals(getSslProxy(), proxy.getSslProxy()) @@ -502,7 +468,6 @@ public int hashCode() { return Objects.hash( getProxyType(), isAutodetect(), - getFtpProxy(), getHttpProxy(), getNoProxy(), getSslProxy(), diff --git a/java/test/org/openqa/selenium/ProxyTest.java b/java/test/org/openqa/selenium/ProxyTest.java index 976008061ee29..e12a38c16062c 100644 --- a/java/test/org/openqa/selenium/ProxyTest.java +++ b/java/test/org/openqa/selenium/ProxyTest.java @@ -45,7 +45,6 @@ void testNotInitializedProxy() { assertThat(proxy.getProxyType()).isEqualTo(UNSPECIFIED); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.getHttpProxy()).isNull(); assertThat(proxy.getSslProxy()).isNull(); assertThat(proxy.getSocksProxy()).isNull(); @@ -74,8 +73,6 @@ void testCanNotChangeAlreadyInitializedProxyType() { assertThatExceptionOfType(IllegalStateException.class) .isThrownBy(() -> proxy.setSocksProxy("")); - assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> proxy.setFtpProxy("")); - assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> proxy.setHttpProxy("")); assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> proxy.setNoProxy("")); @@ -104,7 +101,6 @@ void testManualProxy() { proxy .setHttpProxy("http.proxy:1234") - .setFtpProxy("ftp.proxy") .setSslProxy("ssl.proxy") .setNoProxy("localhost,127.0.0.*") .setSocksProxy("socks.proxy:65555") @@ -113,7 +109,6 @@ void testManualProxy() { .setSocksPassword("test2"); assertThat(proxy.getProxyType()).isEqualTo(MANUAL); - assertThat(proxy.getFtpProxy()).isEqualTo("ftp.proxy"); assertThat(proxy.getHttpProxy()).isEqualTo("http.proxy:1234"); assertThat(proxy.getSslProxy()).isEqualTo("ssl.proxy"); assertThat(proxy.getSocksProxy()).isEqualTo("socks.proxy:65555"); @@ -134,7 +129,6 @@ void testPACProxy() { assertThat(proxy.getProxyType()).isEqualTo(PAC); assertThat(proxy.getProxyAutoconfigUrl()).isEqualTo("http://aaa/bbb.pac"); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.getHttpProxy()).isNull(); assertThat(proxy.getSslProxy()).isNull(); assertThat(proxy.getSocksProxy()).isNull(); @@ -153,7 +147,6 @@ void testAutodetectProxy() { assertThat(proxy.getProxyType().name()).isEqualTo(AUTODETECT.name()); assertThat(proxy.isAutodetect()).isTrue(); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.getHttpProxy()).isNull(); assertThat(proxy.getSslProxy()).isNull(); assertThat(proxy.getSocksProxy()).isNull(); @@ -169,7 +162,6 @@ void manualProxyFromMap() { Map proxyData = new HashMap<>(); proxyData.put("proxyType", "manual"); proxyData.put("httpProxy", "http.proxy:1234"); - proxyData.put("ftpProxy", "ftp.proxy"); proxyData.put("sslProxy", "ssl.proxy"); proxyData.put("noProxy", "localhost,127.0.0.*"); proxyData.put("socksProxy", "socks.proxy:65555"); @@ -180,7 +172,6 @@ void manualProxyFromMap() { Proxy proxy = new Proxy(proxyData); assertThat(proxy.getProxyType()).isEqualTo(MANUAL); - assertThat(proxy.getFtpProxy()).isEqualTo("ftp.proxy"); assertThat(proxy.getHttpProxy()).isEqualTo("http.proxy:1234"); assertThat(proxy.getSslProxy()).isEqualTo("ssl.proxy"); assertThat(proxy.getSocksProxy()).isEqualTo("socks.proxy:65555"); @@ -199,7 +190,6 @@ void longSocksVersionFromMap() { long l = 5; proxyData.put("proxyType", "manual"); proxyData.put("httpProxy", "http.proxy:1234"); - proxyData.put("ftpProxy", "ftp.proxy"); proxyData.put("sslProxy", "ssl.proxy"); proxyData.put("noProxy", "localhost,127.0.0.*"); proxyData.put("socksProxy", "socks.proxy:65555"); @@ -217,7 +207,6 @@ void manualProxyToJson() { Proxy proxy = new Proxy(); proxy.setProxyType(ProxyType.MANUAL); proxy.setHttpProxy("http.proxy:1234"); - proxy.setFtpProxy("ftp.proxy"); proxy.setSslProxy("ssl.proxy"); proxy.setNoProxy("localhost,127.0.0.*"); proxy.setSocksProxy("socks.proxy:65555"); @@ -228,7 +217,6 @@ void manualProxyToJson() { Map json = proxy.toJson(); assertThat(json.get("proxyType")).isEqualTo("manual"); - assertThat(json.get("ftpProxy")).isEqualTo("ftp.proxy"); assertThat(json.get("httpProxy")).isEqualTo("http.proxy:1234"); assertThat(json.get("sslProxy")).isEqualTo("ssl.proxy"); assertThat(json.get("socksProxy")).isEqualTo("socks.proxy:65555"); @@ -236,7 +224,7 @@ void manualProxyToJson() { assertThat(json.get("socksUsername")).isEqualTo("test1"); assertThat(json.get("socksPassword")).isEqualTo("test2"); assertThat(json.get("noProxy")).asInstanceOf(LIST).containsExactly("localhost", "127.0.0.*"); - assertThat(json.entrySet()).hasSize(9); + assertThat(json.entrySet()).hasSize(8); } @Test @@ -250,7 +238,6 @@ void pacProxyFromMap() { assertThat(proxy.getProxyType()).isEqualTo(PAC); assertThat(proxy.getProxyAutoconfigUrl()).isEqualTo("http://aaa/bbb.pac"); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.getHttpProxy()).isNull(); assertThat(proxy.getSslProxy()).isNull(); assertThat(proxy.getSocksProxy()).isNull(); @@ -285,7 +272,6 @@ void autodetectProxyFromMap() { assertThat(proxy.getProxyType()).isEqualTo(AUTODETECT); assertThat(proxy.isAutodetect()).isTrue(); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.getHttpProxy()).isNull(); assertThat(proxy.getSslProxy()).isNull(); assertThat(proxy.getSocksProxy()).isNull(); @@ -318,7 +304,6 @@ void systemProxyFromMap() { assertThat(proxy.getProxyType()).isEqualTo(SYSTEM); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.getHttpProxy()).isNull(); assertThat(proxy.getSslProxy()).isNull(); assertThat(proxy.getSocksProxy()).isNull(); @@ -350,7 +335,6 @@ void directProxyFromMap() { assertThat(proxy.getProxyType()).isEqualTo(DIRECT); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.getHttpProxy()).isNull(); assertThat(proxy.getSslProxy()).isNull(); assertThat(proxy.getSocksProxy()).isNull(); @@ -376,14 +360,12 @@ void directProxyToJson() { @Test void constructingWithNullKeysWorksAsExpected() { Map rawProxy = new HashMap<>(); - rawProxy.put("ftpProxy", null); rawProxy.put("httpProxy", "http://www.example.com"); rawProxy.put("autodetect", null); Capabilities caps = new ImmutableCapabilities(PROXY, rawProxy); Proxy proxy = Proxy.extractFrom(caps); - assertThat(proxy.getFtpProxy()).isNull(); assertThat(proxy.isAutodetect()).isFalse(); assertThat(proxy.getHttpProxy()).isEqualTo("http://www.example.com"); } diff --git a/javascript/selenium-webdriver/lib/proxy.js b/javascript/selenium-webdriver/lib/proxy.js index ac191c1173001..35b3f38fd3bff 100644 --- a/javascript/selenium-webdriver/lib/proxy.js +++ b/javascript/selenium-webdriver/lib/proxy.js @@ -77,13 +77,6 @@ PacConfig.prototype.proxyAutoconfigUrl */ function ManualConfig() {} -/** - * The proxy host for FTP requests. - * - * @type {(string|undefined)} - */ -ManualConfig.prototype.ftpProxy - /** * The proxy host for HTTP requests. * @@ -138,30 +131,24 @@ function direct() { * Manually configures the browser proxy. The following options are * supported: * - * - `ftp`: Proxy host to use for FTP requests * - `http`: Proxy host to use for HTTP requests * - `https`: Proxy host to use for HTTPS requests * - `bypass`: A list of hosts requests should directly connect to, * bypassing any other proxies for that request. May be specified as a * comma separated string, or a list of strings. * - * Behavior is undefined for FTP, HTTP, and HTTPS requests if the + * Behavior is undefined for HTTP and HTTPS requests if the * corresponding key is omitted from the configuration options. * - * @param {{ftp: (string|undefined), - * http: (string|undefined), + * @param {{http: (string|undefined), * https: (string|undefined), * bypass: (Array|undefined)}} options Proxy * configuration options. * @return {!ManualConfig} A new proxy configuration object. */ -function manual({ ftp, http, https, bypass }) { - if (ftp !== undefined) { - console.warn('ftpProxy is deprecated and will be removed in the future') - } +function manual({ http, https, bypass }) { return { proxyType: Type.MANUAL, - ftpProxy: ftp, httpProxy: http, sslProxy: https, noProxy: bypass, diff --git a/javascript/selenium-webdriver/test/proxy_test.js b/javascript/selenium-webdriver/test/proxy_test.js index 06816194e46f9..b4e92bb060eb2 100644 --- a/javascript/selenium-webdriver/test/proxy_test.js +++ b/javascript/selenium-webdriver/test/proxy_test.js @@ -145,7 +145,7 @@ test.suite(function (env) { assert.strictEqual(await driver.findElement({ tagName: 'h3' }).getText(), 'This is the proxy landing page') }) - // TODO: test ftp and https proxies. + // TODO: test https proxies. }) // PhantomJS does not support PAC file proxy configuration. diff --git a/rb/lib/selenium/webdriver/common/proxy.rb b/rb/lib/selenium/webdriver/common/proxy.rb index 4c804338f15e4..c79b269faebdb 100644 --- a/rb/lib/selenium/webdriver/common/proxy.rb +++ b/rb/lib/selenium/webdriver/common/proxy.rb @@ -29,7 +29,6 @@ class Proxy }.freeze ALLOWED = {type: 'proxyType', - ftp: 'ftpProxy', http: 'httpProxy', no_proxy: 'noProxy', pac: 'proxyAutoconfigUrl', @@ -76,12 +75,6 @@ def ==(other) end alias eql? == - def ftp=(value) - WebDriver.logger.deprecate('FTP proxy support', nil, id: :ftp_proxy) - self.type = :manual - @ftp = value - end - def http=(value) self.type = :manual @http = value @@ -143,7 +136,6 @@ def type=(type) def as_json(*) json_result = { 'proxyType' => TYPES[type].downcase, - 'ftpProxy' => ftp, 'httpProxy' => http, 'noProxy' => no_proxy.is_a?(String) ? no_proxy.split(',').map(&:strip).reject(&:empty?) : no_proxy, 'proxyAutoconfigUrl' => pac, diff --git a/rb/lib/selenium/webdriver/firefox/profile.rb b/rb/lib/selenium/webdriver/firefox/profile.rb index f018c5e2c7541..55d7d7bc1afb6 100644 --- a/rb/lib/selenium/webdriver/firefox/profile.rb +++ b/rb/lib/selenium/webdriver/firefox/profile.rb @@ -133,7 +133,6 @@ def proxy=(proxy) when :manual self['network.proxy.type'] = 1 - set_manual_proxy_preference 'ftp', proxy.ftp set_manual_proxy_preference 'http', proxy.http set_manual_proxy_preference 'ssl', proxy.ssl set_manual_proxy_preference 'socks', proxy.socks diff --git a/rb/sig/interfaces/proxy.rbs b/rb/sig/interfaces/proxy.rbs index c529d88dffde2..4ead81f1c04c7 100644 --- a/rb/sig/interfaces/proxy.rbs +++ b/rb/sig/interfaces/proxy.rbs @@ -21,8 +21,6 @@ interface _Proxy def type: -> untyped - def ftp: -> untyped - def pac: -> untyped def http: -> untyped diff --git a/rb/sig/lib/selenium/webdriver/common/proxy.rbs b/rb/sig/lib/selenium/webdriver/common/proxy.rbs index 86538d01c2b19..319caf514b7bb 100644 --- a/rb/sig/lib/selenium/webdriver/common/proxy.rbs +++ b/rb/sig/lib/selenium/webdriver/common/proxy.rbs @@ -21,8 +21,6 @@ module Selenium class Proxy include _Proxy - @ftp: untyped - @http: untyped @no_proxy: untyped @@ -55,8 +53,6 @@ module Selenium alias eql? == - def ftp=: (untyped value) -> untyped - def http=: (untyped value) -> untyped def no_proxy=: (untyped value) -> untyped diff --git a/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb b/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb index 1d811a3987613..7a460764f227e 100644 --- a/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb +++ b/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb @@ -97,7 +97,6 @@ def read_generated_prefs(from = nil) it 'can configure a manual proxy' do proxy = Proxy.new( http: 'foo:123', - ftp: 'bar:234', ssl: 'baz:345', no_proxy: 'localhost' ) @@ -105,8 +104,6 @@ def read_generated_prefs(from = nil) profile.proxy = proxy expect(read_generated_prefs).to include('user_pref("network.proxy.http", "foo")', 'user_pref("network.proxy.http_port", 123)', - 'user_pref("network.proxy.ftp", "bar")', - 'user_pref("network.proxy.ftp_port", 234)', 'user_pref("network.proxy.ssl", "baz")', 'user_pref("network.proxy.ssl_port", 345)', 'user_pref("network.proxy.no_proxies_on", "localhost")', diff --git a/rb/spec/unit/selenium/webdriver/proxy_spec.rb b/rb/spec/unit/selenium/webdriver/proxy_spec.rb index 4dd76c9e2325b..661ec7fba35ae 100644 --- a/rb/spec/unit/selenium/webdriver/proxy_spec.rb +++ b/rb/spec/unit/selenium/webdriver/proxy_spec.rb @@ -24,7 +24,6 @@ module WebDriver describe Proxy do let :proxy_settings do # manual proxy settings { - ftp: 'mythicalftpproxy:21', http: 'mythicalproxy:80', no_proxy: 'noproxy', ssl: 'mythicalsslproxy', @@ -57,7 +56,6 @@ module WebDriver it 'allows valid options for a manual proxy', :aggregate_failures do proxy = described_class.new(proxy_settings) - expect(proxy.ftp).to eq(proxy_settings[:ftp]) expect(proxy.http).to eq(proxy_settings[:http]) expect(proxy.no_proxy).to eq(proxy_settings[:no_proxy]) expect(proxy.ssl).to eq(proxy_settings[:ssl]) @@ -71,7 +69,6 @@ module WebDriver proxy_json = described_class.new(proxy_settings).as_json expect(proxy_json['proxyType']).to eq('manual') - expect(proxy_json['ftpProxy']).to eq(proxy_settings[:ftp]) expect(proxy_json['httpProxy']).to eq(proxy_settings[:http]) expect(proxy_json['noProxy']).to eq([proxy_settings[:no_proxy]]) expect(proxy_json['sslProxy']).to eq(proxy_settings[:ssl]) diff --git a/rb/spec/unit/selenium/webdriver/remote/http/default_spec.rb b/rb/spec/unit/selenium/webdriver/remote/http/default_spec.rb index d738ce5553f34..35e1e67cbd6c3 100644 --- a/rb/spec/unit/selenium/webdriver/remote/http/default_spec.rb +++ b/rb/spec/unit/selenium/webdriver/remote/http/default_spec.rb @@ -111,7 +111,7 @@ module Http end it 'raises an error if the proxy is not an HTTP proxy' do - client.proxy = Proxy.new(ftp: 'ftp://example.com') + client.proxy = Proxy.new(ssl: 'ssl://example.com') expect { client.send :http }.to raise_error(Error::WebDriverError) end From bf18c110ff2daac46888f12b43b00948dd917fc8 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 30 Jul 2026 07:59:05 -0500 Subject: [PATCH 12/56] [rust] locate Chrome and Edge in known install directories (#17838) * [rust] locate Chrome and Edge in known install directories * [rust] honor --skip-browser-in-path and keep WebView2 out of Edge browser lookup --- rust/src/chrome.rs | 31 +++++++++++++- rust/src/edge.rs | 33 ++++++++++++++- rust/src/files.rs | 14 +++++++ rust/src/lib.rs | 23 +++++++++- rust/tests/browser_tests.rs | 84 +++++++++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 5 deletions(-) diff --git a/rust/src/chrome.rs b/rust/src/chrome.rs index 61224379a30f6..b0ed10af90194 100644 --- a/rust/src/chrome.rs +++ b/rust/src/chrome.rs @@ -19,7 +19,7 @@ use crate::config::ARCH::{ARM64, X32}; use crate::config::ManagerConfig; use crate::config::OS::{LINUX, MACOS, WINDOWS}; use crate::downloads::{parse_json_from_url, read_version_from_link}; -use crate::files::{BrowserPath, compose_driver_path_in_cache}; +use crate::files::{BrowserPath, compose_driver_path_in_cache, first_existing_path}; use crate::logger::Logger; use crate::metadata::{ create_driver_metadata, get_driver_version_from_metadata, get_metadata, write_metadata, @@ -41,6 +41,20 @@ use std::sync::mpsc::{Receiver, Sender}; pub const CHROME_NAME: &str = "chrome"; pub const CHROMEDRIVER_NAME: &str = "chromedriver"; + +// Directories and names chromedriver searches to locate Chrome/Chromium on Linux (chrome_finder.cc). +pub const CHROME_KNOWN_DIRS: &[&str] = &[ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + "/opt/google/chrome", + "/opt/chromium.org/chromium", +]; +pub const CHROME_KNOWN_NAMES: &[&str] = + &["chrome", "google-chrome", "chromium", "chromium-browser"]; const DRIVER_URL: &str = "https://chromedriver.storage.googleapis.com/"; const LATEST_RELEASE: &str = "LATEST_RELEASE"; const CFT_URL: &str = "https://googlechromelabs.github.io/chrome-for-testing/"; @@ -228,7 +242,20 @@ impl SeleniumManager for ChromeManager { } fn get_browser_names_in_path(&self) -> Vec<&str> { - vec![self.get_browser_name(), "chromium-browser", "chromium"] + vec![ + self.get_browser_name(), + "google-chrome", + "chromium", + "chromium-browser", + ] + } + + fn detect_browser_in_known_locations(&self) -> Option { + // chromedriver's fixed directory search (chrome_finder.cc) is Linux-only. + if !LINUX.is(self.get_os()) { + return None; + } + first_existing_path(CHROME_KNOWN_DIRS, CHROME_KNOWN_NAMES) } fn get_http_client(&self) -> &Client { diff --git a/rust/src/edge.rs b/rust/src/edge.rs index 042f805244dd0..32822382af6d8 100644 --- a/rust/src/edge.rs +++ b/rust/src/edge.rs @@ -19,7 +19,7 @@ use crate::config::ARCH::{ARM64, X32}; use crate::config::ManagerConfig; use crate::config::OS::{LINUX, MACOS, WINDOWS}; use crate::downloads::{parse_json_from_url, read_version_from_link}; -use crate::files::{BrowserPath, compose_driver_path_in_cache}; +use crate::files::{BrowserPath, compose_driver_path_in_cache, first_existing_path}; use crate::metadata::{ create_driver_metadata, get_driver_version_from_metadata, get_metadata, write_metadata, }; @@ -45,6 +45,18 @@ pub const EDGE_NAMES: &[&str] = &[ ]; pub const EDGEDRIVER_NAME: &str = "msedgedriver"; pub const WEBVIEW2_NAME: &str = "webview2"; + +// Directories and names msedgedriver (a chromedriver fork) searches to locate Edge on Linux. +pub const EDGE_KNOWN_DIRS: &[&str] = &[ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + "/opt/microsoft/msedge", +]; +pub const EDGE_KNOWN_NAMES: &[&str] = &["msedge", "microsoft-edge", "microsoft-edge-stable"]; const DRIVER_URL: &str = "https://msedgedriver.microsoft.com/"; const LATEST_STABLE: &str = "LATEST_STABLE"; const LATEST_RELEASE: &str = "LATEST_RELEASE"; @@ -102,7 +114,24 @@ impl SeleniumManager for EdgeManager { } fn get_browser_names_in_path(&self) -> Vec<&str> { - vec![self.get_browser_name()] + // WebView2 is not the Edge browser, so it must not inherit Edge's executable names. + if self.is_webview2() { + return vec![self.get_browser_name()]; + } + vec![ + self.get_browser_name(), + "microsoft-edge", + "microsoft-edge-stable", + ] + } + + fn detect_browser_in_known_locations(&self) -> Option { + // msedgedriver is built from chromedriver and searches the same fixed directories (Linux-only); + // WebView2 is a different runtime, so it must not resolve to an Edge browser binary. + if self.is_webview2() || !LINUX.is(self.get_os()) { + return None; + } + first_existing_path(EDGE_KNOWN_DIRS, EDGE_KNOWN_NAMES) } fn get_http_client(&self) -> &Client { diff --git a/rust/src/files.rs b/rust/src/files.rs index 6d0198cf43c66..0072644332dca 100644 --- a/rust/src/files.rs +++ b/rust/src/files.rs @@ -78,6 +78,20 @@ impl BrowserPath { } } +// Returns the first `/` that exists, searched name-major (every dir tried for a +// name before moving to the next name), matching how a browser's own driver walks candidates. +pub fn first_existing_path(dirs: &[&str], names: &[&str]) -> Option { + for name in names { + for dir in dirs { + let candidate = Path::new(dir).join(name); + if candidate.exists() { + return Some(candidate); + } + } + } + None +} + pub fn create_parent_path_if_not_exists(path: &Path) -> Result<(), Error> { if let Some(p) = path.parent() { create_path_if_not_exists(p)?; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 2186054c409b3..6cf8f325278cd 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -421,7 +421,27 @@ pub trait SeleniumManager { .unwrap_or_default() } + fn detect_browser_in_known_locations(&self) -> Option { + None + } + fn detect_browser_path(&mut self) -> Option { + // A driver's binary search is channel-agnostic and finds system browsers, so mirror it only + // for the default channel and when the user hasn't asked to skip browsers in the path. + if !self.is_browser_version_unstable() + && !self.is_skip_browser_in_path() + && let Some(browser_path) = self.detect_browser_in_known_locations() + { + let canon_browser_path = self.canonicalize_path(browser_path); + self.get_logger().debug(format!( + "{} detected at {}", + self.get_browser_name(), + canon_browser_path + )); + self.set_browser_path(canon_browser_path.clone()); + return Some(Path::new(&canon_browser_path).to_path_buf()); + } + let browser_version = self.get_browser_version(); let browser_path = self.get_browser_path_from_version(browser_version); @@ -771,7 +791,8 @@ pub trait SeleniumManager { } fn is_webview2(&self) -> bool { - self.get_browser_name().eq(WEBVIEW2_NAME) + // Browser selection matches case-insensitively but keeps the original casing (e.g. "WebView2"). + self.get_browser_name().eq_ignore_ascii_case(WEBVIEW2_NAME) } fn is_browser_version_beta(&self) -> bool { diff --git a/rust/tests/browser_tests.rs b/rust/tests/browser_tests.rs index b29f57ef5c706..a6d25982a5674 100644 --- a/rust/tests/browser_tests.rs +++ b/rust/tests/browser_tests.rs @@ -19,6 +19,9 @@ use crate::common::{assert_output, get_selenium_manager, get_stdout}; use exitcode::DATAERR; use rstest::rstest; +use selenium_manager::SeleniumManager; +use selenium_manager::chrome::ChromeManager; +use selenium_manager::edge::EdgeManager; use std::env::consts::OS; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -234,3 +237,84 @@ fn browser_path_major_version_mismatch_test() { "Should mention requested version" ); } + +#[test] +fn chrome_matches_chromedriver_binary_names() { + let manager = ChromeManager::new().unwrap(); + assert_eq!( + manager.get_browser_names_in_path(), + vec!["chrome", "google-chrome", "chromium", "chromium-browser"] + ); +} + +#[test] +fn chrome_detect_browser_in_known_locations_is_linux_only() { + let mut manager = ChromeManager::new().unwrap(); + manager.config.os = "macos".to_string(); + assert!(manager.detect_browser_in_known_locations().is_none()); +} + +#[test] +fn edge_matches_msedgedriver_binary_names() { + let manager = EdgeManager::new().unwrap(); + assert_eq!( + manager.get_browser_names_in_path(), + vec!["edge", "microsoft-edge", "microsoft-edge-stable"] + ); +} + +#[test] +fn edge_detect_browser_in_known_locations_is_linux_only() { + let mut manager = EdgeManager::new().unwrap(); + manager.config.os = "macos".to_string(); + assert!(manager.detect_browser_in_known_locations().is_none()); +} + +#[rstest] +#[case("webview2")] +#[case("WebView2")] +fn edge_webview2_is_not_treated_as_edge_browser(#[case] name: String) { + let mut manager = EdgeManager::new_with_name(name.clone()).unwrap(); + assert_eq!(manager.get_browser_names_in_path(), vec![name.as_str()]); + manager.config.os = "linux".to_string(); + assert!(manager.detect_browser_in_known_locations().is_none()); +} + +#[test] +fn chrome_known_locations_include_opt_install_dirs() { + use selenium_manager::chrome::{CHROME_KNOWN_DIRS, CHROME_KNOWN_NAMES}; + assert!(CHROME_KNOWN_DIRS.contains(&"/opt/google/chrome")); + assert!(CHROME_KNOWN_DIRS.contains(&"/opt/chromium.org/chromium")); + assert!(CHROME_KNOWN_NAMES.contains(&"chrome")); +} + +#[test] +fn edge_known_locations_include_opt_install_dir() { + use selenium_manager::edge::{EDGE_KNOWN_DIRS, EDGE_KNOWN_NAMES}; + assert!(EDGE_KNOWN_DIRS.contains(&"/opt/microsoft/msedge")); + assert!(EDGE_KNOWN_NAMES.contains(&"msedge")); +} + +#[test] +fn first_existing_path_searches_name_major() { + use selenium_manager::files::first_existing_path; + use std::fs; + + let base = tempfile::tempdir().unwrap(); + let dir_a = base.path().join("a"); + let dir_b = base.path().join("b"); + fs::create_dir_all(&dir_a).unwrap(); + fs::create_dir_all(&dir_b).unwrap(); + // "wanted" only exists in the later dir; "other" only in the earlier dir. + fs::write(dir_a.join("other"), "").unwrap(); + fs::write(dir_b.join("wanted"), "").unwrap(); + + let dirs = [dir_a.to_str().unwrap(), dir_b.to_str().unwrap()]; + // Name-major: "wanted" is tried across every dir before "other", so it wins despite + // "other" sitting in an earlier directory. + assert_eq!( + first_existing_path(&dirs, &["wanted", "other"]), + Some(dir_b.join("wanted")) + ); + assert!(first_existing_path(&dirs, &["missing"]).is_none()); +} From 144db0c6cb2a3702dd8a99911a6b8b27326b6b06 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 09:14:15 -0500 Subject: [PATCH 13/56] [dotnet][py][rb] prevent CDP access with Firefox (#17849) * [dotnet] throw instead of warn for CDP on Firefox * [py] raise for CDP methods on Firefox * [rb] remove CDP extensions from Firefox so methods are unavailable --- dotnet/src/webdriver/Remote/RemoteWebDriver.cs | 13 ++++++------- py/selenium/webdriver/remote/webdriver.py | 4 ++++ .../common/driver_extensions/has_log_events.rb | 7 ------- .../driver_extensions/has_network_interception.rb | 7 ------- rb/lib/selenium/webdriver/firefox/driver.rb | 2 -- 5 files changed, 10 insertions(+), 23 deletions(-) diff --git a/dotnet/src/webdriver/Remote/RemoteWebDriver.cs b/dotnet/src/webdriver/Remote/RemoteWebDriver.cs index 0ea164972c129..79200ecb4068e 100644 --- a/dotnet/src/webdriver/Remote/RemoteWebDriver.cs +++ b/dotnet/src/webdriver/Remote/RemoteWebDriver.cs @@ -21,7 +21,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Compression; using OpenQA.Selenium.DevTools; -using OpenQA.Selenium.Internal.Logging; namespace OpenQA.Selenium.Remote; @@ -60,8 +59,6 @@ namespace OpenQA.Selenium.Remote; /// public class RemoteWebDriver : WebDriver, IDevTools, IHasDownloads { - private static readonly ILogger _logger = OpenQA.Selenium.Internal.Logging.Log.GetLogger(typeof(RemoteWebDriver)); - /// /// The name of the Selenium grid remote DevTools end point capability. /// @@ -426,10 +423,7 @@ public DevToolsSession GetDevToolsSession() { if (this.Capabilities.GetCapability(CapabilityType.BrowserName) is "firefox") { - if (_logger.IsEnabled(LogEventLevel.Warn)) - { - _logger.Warn("CDP support for Firefox is deprecated and will be removed in future versions. Please switch to WebDriver BiDi."); - } + throw new WebDriverException("CDP support for Firefox has been removed. Please switch to WebDriver BiDi."); } return GetDevToolsSession(new DevToolsOptions() { ProtocolVersion = DevToolsSession.AutoDetectDevToolsProtocolVersion }); @@ -445,6 +439,11 @@ public DevToolsSession GetDevToolsSession(DevToolsOptions options) { ArgumentNullException.ThrowIfNull(options); + if (this.Capabilities.GetCapability(CapabilityType.BrowserName) is "firefox") + { + throw new WebDriverException("CDP support for Firefox has been removed. Please switch to WebDriver BiDi."); + } + if (this.devToolsSession == null) { object? debuggerAddressObject = this.Capabilities.GetCapability(RemoteDevToolsEndPointCapabilityName); diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index a58395ec00de3..4cfb788e26039 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -460,6 +460,8 @@ def execute_cdp_cmd(self, cmd: str, cmd_args: dict): Example: `driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": requestId})` """ + if self.caps["browserName"].lower() == "firefox": + raise RuntimeError("CDP support for Firefox has been removed. Please switch to WebDriver BiDi.") return self.execute("executeCdpCommand", {"cmd": cmd, "params": cmd_args})["value"] def execute( @@ -1181,6 +1183,8 @@ def start_devtools(self) -> tuple[Any, WebSocketConnection]: @asynccontextmanager async def bidi_connection(self): + if self.caps["browserName"].lower() == "firefox": + raise RuntimeError("CDP support for Firefox has been removed. Please switch to WebDriver BiDi.") global cdp import_cdp() if self.caps.get("se:cdp"): diff --git a/rb/lib/selenium/webdriver/common/driver_extensions/has_log_events.rb b/rb/lib/selenium/webdriver/common/driver_extensions/has_log_events.rb index 8acadbe0b8519..1965466633f08 100644 --- a/rb/lib/selenium/webdriver/common/driver_extensions/has_log_events.rb +++ b/rb/lib/selenium/webdriver/common/driver_extensions/has_log_events.rb @@ -57,13 +57,6 @@ module HasLogEvents # def on_log_event(kind, &block) - if browser == :firefox - WebDriver.logger.deprecate( - 'Driver#on_log_event on Firefox', - 'the script.add_console_message_handler or the script.add_javascript_error_handler methods', - id: :on_log_event - ) - end raise Error::WebDriverError, "Don't know how to handle #{kind} events" unless KINDS.include?(kind) enabled = log_listeners[kind].any? diff --git a/rb/lib/selenium/webdriver/common/driver_extensions/has_network_interception.rb b/rb/lib/selenium/webdriver/common/driver_extensions/has_network_interception.rb index e93ae0b3e6e76..0cedd98ba3b52 100644 --- a/rb/lib/selenium/webdriver/common/driver_extensions/has_network_interception.rb +++ b/rb/lib/selenium/webdriver/common/driver_extensions/has_network_interception.rb @@ -60,13 +60,6 @@ module HasNetworkInterception # def intercept(&block) - if browser == :firefox - WebDriver.logger.deprecate( - 'Driver#intercept on Firefox', - 'the new bidi.network.add_intercept method', - id: :intercept - ) - end @interceptor ||= DevTools::NetworkInterceptor.new(devtools) @interceptor.intercept(&block) end diff --git a/rb/lib/selenium/webdriver/firefox/driver.rb b/rb/lib/selenium/webdriver/firefox/driver.rb index 39db8bc58a81e..a810cdbbcf9f8 100644 --- a/rb/lib/selenium/webdriver/firefox/driver.rb +++ b/rb/lib/selenium/webdriver/firefox/driver.rb @@ -29,8 +29,6 @@ class Driver < WebDriver::Driver EXTENSIONS = [DriverExtensions::HasAddons, DriverExtensions::FullPageScreenshot, DriverExtensions::HasContext, - DriverExtensions::HasLogEvents, - DriverExtensions::HasNetworkInterception, DriverExtensions::PrintsPage].freeze include LocalDriver From 7cd29f92803e219256ea9c512b05cd27c68a01bb Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 13:03:51 -0500 Subject: [PATCH 14/56] [rb] test matchers assert log entries by id and optional messages and match severity (#17848) * [rb] match log entries by id and severity, add missing log ids, drop logger stubs * [rb] match message assertions against entries carrying multiple ids --- rb/TESTING.md | 29 +++++++ .../webdriver/common/driver_finder.rb | 7 +- .../webdriver/common/selenium_manager.rb | 3 +- .../webdriver/common/takes_screenshot.rb | 2 +- .../selenium/webdriver/remote/http/default.rb | 2 +- rb/lib/selenium/webdriver/support/guards.rb | 2 +- .../webdriver/takes_screenshot_spec.rb | 15 +--- rb/spec/rspec_matchers.rb | 80 +++++++++++++------ .../webdriver/common/driver_finder_spec.rb | 4 +- .../webdriver/firefox/service_spec.rb | 20 ++--- .../selenium/webdriver/rspec_matchers_spec.rb | 58 ++++++++++++++ 11 files changed, 164 insertions(+), 58 deletions(-) create mode 100644 rb/spec/unit/selenium/webdriver/rspec_matchers_spec.rb diff --git a/rb/TESTING.md b/rb/TESTING.md index dae42d112a659..ef33311cdde1a 100644 --- a/rb/TESTING.md +++ b/rb/TESTING.md @@ -182,6 +182,35 @@ From `spec_support/helpers.rb`: | `wait_for_element(locator)` | Wait for element to appear. | | `wait_for_alert` | Wait for alert presence. | +## Asserting Log Output + +Every `WebDriver.logger` call should include an `id:` symbol (e.g. `logger.warn(msg, id: :safari_bidi)`). +To assert on logging content (and hide it from test logs), do not stub the logger, instead use one of +the [custom matchers](spec/rspec_matchers.rb): `have_error`, `have_warning`, `have_info`, and +`have_deprecated`. + +```ruby +expect { SeleniumManager.binary }.to have_info(:selenium_manager) # id was logged, at info level +expect { save_screenshot(png_path) }.not_to have_warning(:screenshot) # id was not logged +``` + +The match is the exact set of ids at that severity — an unexpected entry fails rather than slipping by +— so assert several entries by passing the full set, e.g. `have_warning(%i[general specific])`. + +The id is provided so you don't have to assert on specific text, but if the message comes from an +external source, you can assert on the contents as well: + +```ruby +expect { navigate }.to have_error(:ws, /This is fine!/) +``` + +Deprecations (`logger.deprecate`) are asserted with `have_deprecated`: + +```ruby +WebDriver.logger.deprecate('Old thing', 'New thing', id: :old_thing) # lib +expect { call_old_thing }.to have_deprecated(:old_thing) # spec +``` + ## Debugging ### Interactive REPL diff --git a/rb/lib/selenium/webdriver/common/driver_finder.rb b/rb/lib/selenium/webdriver/common/driver_finder.rb index b70e57b26ef4c..7981d2b5a2ef1 100644 --- a/rb/lib/selenium/webdriver/common/driver_finder.rb +++ b/rb/lib/selenium/webdriver/common/driver_finder.rb @@ -52,8 +52,8 @@ def paths path = @service.executable_path || env_path || class_path path ? paths_from_service(path) : paths_from_manager rescue StandardError => e - WebDriver.logger.error("Exception occurred: #{e.message}") - WebDriver.logger.error("Backtrace:\n\t#{e.backtrace&.join("\n\t")}") + WebDriver.logger.error("Exception occurred: #{e.message}", id: :driver_finder) + WebDriver.logger.error("Backtrace:\n\t#{e.backtrace&.join("\n\t")}", id: :driver_finder) raise Error::NoSuchDriverError, "Unable to obtain #{@service.class::EXECUTABLE}" end end @@ -69,7 +69,8 @@ def class_path def paths_from_service(path) exe = @service.class::EXECUTABLE - WebDriver.logger.debug("Skipping Selenium Manager; path to #{exe} specified in service class: #{path}") + WebDriver.logger.debug("Skipping Selenium Manager; path to #{exe} specified in service class: #{path}", + id: :driver_finder) Platform.assert_executable(path) {driver_path: path} end diff --git a/rb/lib/selenium/webdriver/common/selenium_manager.rb b/rb/lib/selenium/webdriver/common/selenium_manager.rb index 4a8603b6584b0..c53b384c90190 100644 --- a/rb/lib/selenium/webdriver/common/selenium_manager.rb +++ b/rb/lib/selenium/webdriver/common/selenium_manager.rb @@ -50,7 +50,8 @@ def binary_paths(*arguments) def binary @binary ||= begin if (location = ENV.fetch('SE_MANAGER_PATH', nil)) - WebDriver.logger.debug("Selenium Manager set by ENV['SE_MANAGER_PATH']: #{location}") + WebDriver.logger.debug("Selenium Manager set by ENV['SE_MANAGER_PATH']: #{location}", + id: :selenium_manager) end location ||= platform_location diff --git a/rb/lib/selenium/webdriver/common/takes_screenshot.rb b/rb/lib/selenium/webdriver/common/takes_screenshot.rb index 8e155eb87b4aa..86b40629f4968 100644 --- a/rb/lib/selenium/webdriver/common/takes_screenshot.rb +++ b/rb/lib/selenium/webdriver/common/takes_screenshot.rb @@ -36,7 +36,7 @@ def save_screenshot(png_path, full_page: false) 'It should end with .png extension', id: :screenshot end - WebDriver.logger.debug("Saving screenshot to #{Dir.pwd}/#{png_path}") + WebDriver.logger.debug("Saving screenshot to #{Dir.pwd}/#{png_path}", id: :screenshot) File.open(png_path, 'wb') { |f| f << screenshot_as(:png, full_page: full_page) } end diff --git a/rb/lib/selenium/webdriver/remote/http/default.rb b/rb/lib/selenium/webdriver/remote/http/default.rb index 78e4cfbed3db8..d861ff96062d7 100644 --- a/rb/lib/selenium/webdriver/remote/http/default.rb +++ b/rb/lib/selenium/webdriver/remote/http/default.rb @@ -124,7 +124,7 @@ def request(verb, url, headers, payload, redirects = 0) end def follow_redirect(response, redirects) - WebDriver.logger.debug("Redirect to #{response['Location']}; times: #{redirects}") + WebDriver.logger.debug("Redirect to #{response['Location']}; times: #{redirects}", id: :redirect) raise Error::WebDriverError, 'too many redirects' if redirects >= client_config.max_redirects request(:get, URI.parse(response['Location']), DEFAULT_HEADERS.dup, nil, redirects + 1) diff --git a/rb/lib/selenium/webdriver/support/guards.rb b/rb/lib/selenium/webdriver/support/guards.rb index e57dab9a5bbce..694b078f4eda0 100644 --- a/rb/lib/selenium/webdriver/support/guards.rb +++ b/rb/lib/selenium/webdriver/support/guards.rb @@ -41,7 +41,7 @@ def initialize(example, bug_tracker: '', conditions: nil) def add_condition(name, condition = false, &block) condition = false if condition.nil? @guard_conditions << GuardCondition.new(name, condition, &block) - WebDriver.logger.debug "Running with Guard '#{name}' set to: #{condition}" + WebDriver.logger.debug "Running with Guard '#{name}' set to: #{condition}", id: :guard end def add_message(name, message) diff --git a/rb/spec/integration/selenium/webdriver/takes_screenshot_spec.rb b/rb/spec/integration/selenium/webdriver/takes_screenshot_spec.rb index 921c94089ddcc..5a1dfb36b3cdd 100644 --- a/rb/spec/integration/selenium/webdriver/takes_screenshot_spec.rb +++ b/rb/spec/integration/selenium/webdriver/takes_screenshot_spec.rb @@ -35,21 +35,14 @@ module WebDriver it 'warns if extension of provided path is not png' do jpg_path = "#{Dir.tmpdir}/test#{SecureRandom.urlsafe_base64}.jpg" - message = 'name used for saved screenshot does not match file type. ' \ - 'It should end with .png extension' - allow(WebDriver.logger).to receive(:warn) - save_screenshots_and_assert(jpg_path) - - expect(WebDriver.logger).to have_received(:warn).with(message, id: :screenshot).twice + expect { save_screenshot_and_assert(driver, jpg_path) }.to have_warning(:screenshot) + expect { save_screenshot_and_assert(element, jpg_path) }.to have_warning(:screenshot) end it 'does not warn if extension of provided path is png' do - allow(WebDriver.logger).to receive(:warn) - - save_screenshots_and_assert(path) - - expect(WebDriver.logger).not_to have_received(:warn) + expect { save_screenshot_and_assert(driver, path) }.not_to have_warning(:screenshot) + expect { save_screenshot_and_assert(element, path) }.not_to have_warning(:screenshot) end it 'returns in the specified format' do diff --git a/rb/spec/rspec_matchers.rb b/rb/spec/rspec_matchers.rb index e668368475beb..4b805a6fa5a65 100644 --- a/rb/spec/rspec_matchers.rb +++ b/rb/spec/rspec_matchers.rb @@ -17,44 +17,74 @@ # specific language governing permissions and limitations # under the License. -LEVELS = %w[error warning info deprecated].freeze +LEVELS = {'error' => 'ERROR', 'warning' => 'WARN', 'info' => 'INFO', 'deprecated' => 'WARN'}.freeze -LEVELS.each do |level| - RSpec::Matchers.define "have_#{level}" do |entry| - match do |actual| - # Suppresses logging output to stderr while ensuring that it is still happening - default_output = Selenium::WebDriver.logger.io - io = StringIO.new - Selenium::WebDriver.logger.output = io +# Block matchers to capture logger output in memory and assert on contents +# +# expect { do_thing }.to have_deprecated(:some_id) # exact set of ids logged +# expect { do_thing }.to have_deprecated(%i[id_a id_b]) # several ids at once +# expect { do_thing }.not_to have_deprecated(:some_id) # id was not logged +# +# When the logged message is from an external source, its content can be asserted with String or Regexp: +# +# expect { do_thing }.to have_warning(:some_id, 'exact text') +LEVELS.each do |level, severity| + # *args (not |ids, message = nil|) so a lone Array of ids isn't auto-splatted into (ids, message). + RSpec::Matchers.define "have_#{level}" do |*args| + ids, message = args + match do |block| + lines = capture_log_lines(&block).grep(/\A\S+ \S+ #{severity}\b/) + lines = lines.grep(/\[DEPRECATION\]/) if level == 'deprecated' + @found = lines.flat_map { |line| ids_in(line) } + @expected = Array(ids).map(&:to_sym) - begin - actual.call - rescue StandardError => e - raise e, 'Can not evaluate output when statement raises an exception' - ensure - Selenium::WebDriver.logger.output = default_output - end + next false unless @found.uniq.sort == @expected.uniq.sort + next true if message.nil? - @entries_found = (io.rewind && io.read).scan(/\[:([^\]]*)\]/).flatten.map(&:to_sym) - expect(Array(entry).sort).to eq(@entries_found.sort) + @matching_lines = lines.select { |line| @expected.intersect?(ids_in(line)) } + @matching_lines.any? { |line| message.is_a?(Regexp) ? line.match?(message) : line.include?(message) } end failure_message do - but_message = if @entries_found.nil? || @entries_found.empty? - "no #{entry} entries were reported" - else - "instead these entries were found: [#{@entries_found.join(', ')}]" - end - "expected :#{entry} to have been logged, but #{but_message}" + if @found.uniq.sort == @expected.uniq.sort + "expected a #{@expected} entry matching #{message.inspect}, but logged: #{@matching_lines.map(&:strip)}" + else + found = @found.empty? ? 'nothing was logged' : "these ids were logged: #{@found.uniq}" + "expected #{@expected} to have been logged, but #{found}" + end end failure_message_when_negated do - but_message = "it was found among these entries: [#{@entries_found.join(', ')}]" - "expected :#{entry} not to have been logged, but #{but_message}" + "expected #{@expected} not to have been logged, but it was found among: #{@found.uniq}" end def supports_block_expectations? true end + + # Ids logged on a single line, whether tagged singly (`[:foo]`) or with several (`[:foo, :bar]`), + # including ids whose Symbol#inspect renders quoted (`[:"needs-quoting"]`). + def ids_in(line) + (line[/\[:[^\]]*\]/] || '').scan(/:"([^"]+)"|:(\w+)/).flatten.compact.map(&:to_sym) + end + + # Suppresses logging output to stderr while capturing it, so an expected entry does not pollute + # test output and an unexpected one still fails the assertion. + def capture_log_lines + default_output = Selenium::WebDriver.logger.io + io = StringIO.new + Selenium::WebDriver.logger.output = io + + begin + yield + rescue StandardError => e + raise e, 'Can not evaluate output when statement raises an exception' + ensure + Selenium::WebDriver.logger.output = default_output + end + + io.rewind + io.read.split("\n") + end end end diff --git a/rb/spec/unit/selenium/webdriver/common/driver_finder_spec.rb b/rb/spec/unit/selenium/webdriver/common/driver_finder_spec.rb index dc91aaabe4397..0ec953403afc6 100644 --- a/rb/spec/unit/selenium/webdriver/common/driver_finder_spec.rb +++ b/rb/spec/unit/selenium/webdriver/common/driver_finder_spec.rb @@ -88,8 +88,8 @@ module WebDriver expect { expect { described_class.new(Options.chrome, Service.chrome).driver_path - }.to output(/Exception occurred: this error/).to_stderr_from_any_process - }.to raise_error(WebDriver::Error::NoSuchDriverError, /driver_location/) + }.to raise_error(WebDriver::Error::NoSuchDriverError, /driver_location/) + }.to have_error(:driver_finder, 'this error') end it 'creates arguments' do diff --git a/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb b/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb index fad58519c3153..3e35b02065b32 100644 --- a/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb @@ -37,7 +37,6 @@ module Firefox before do allow(Platform).to receive(:assert_executable) allow(WebDriver.logger).to receive(:debug?).and_return(false) - allow(WebDriver.logger).to receive(:warn) end it 'uses default port and nil path' do @@ -126,26 +125,22 @@ module Firefox end it 'preserves conflicting --log args with value and warns' do - service = described_class.new(args: ['--log', 'info']) + service = nil + + expect { service = described_class.new(args: ['--log', 'info']) }.to have_warning(:se_debug) expect(service.extra_args).not_to include('-v') expect(service.extra_args).to include('--log') expect(service.extra_args).to include('info') - expect(WebDriver.logger).to have_received(:warn).with( - 'SE_DEBUG is set; preserving user-specified geckodriver --log setting instead of adding -v', - id: :se_debug - ) end it 'preserves conflicting --log= args and warns' do - service = described_class.new(args: ['--log=info']) + service = nil + + expect { service = described_class.new(args: ['--log=info']) }.to have_warning(:se_debug) expect(service.extra_args).not_to include('-v') expect(service.extra_args).to include('--log=info') - expect(WebDriver.logger).to have_received(:warn).with( - 'SE_DEBUG is set; preserving user-specified geckodriver --log setting instead of adding -v', - id: :se_debug - ) end it 'does not remove next arg if --log has no value' do @@ -163,12 +158,11 @@ module Firefox allow(ServiceManager).to receive(:new).with(service).and_return(manager) - service.launch + expect { service.launch }.to have_warning(:se_debug) expect(service.extra_args).not_to include('-v') expect(service.extra_args).to include('--log') expect(service.extra_args).to include('trace') - expect(WebDriver.logger).to have_received(:warn).once end end end diff --git a/rb/spec/unit/selenium/webdriver/rspec_matchers_spec.rb b/rb/spec/unit/selenium/webdriver/rspec_matchers_spec.rb new file mode 100644 index 0000000000000..a6d42b8ea93af --- /dev/null +++ b/rb/spec/unit/selenium/webdriver/rspec_matchers_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative 'spec_helper' + +module Selenium + module WebDriver + describe 'log matchers (spec/rspec_matchers.rb)' do + it 'matches a single id' do + expect { WebDriver.logger.warn('m', id: :solo) }.to have_warning(:solo) + end + + it 'matches an entry tagged with multiple ids' do + expect { WebDriver.logger.warn('m', id: %i[general specific]) }.to have_warning(%i[general specific]) + end + + it 'matches several ids logged across separate calls' do + expect { + WebDriver.logger.warn('a', id: :first) + WebDriver.logger.warn('b', id: :second) + }.to have_warning(%i[first second]) + end + + it 'asserts message content with a Regexp' do + expect { WebDriver.logger.error('boom: kaboom', id: :err) }.to have_error(:err, /kaboom/) + end + + it 'asserts message content on an entry with multiple ids' do + expect { WebDriver.logger.warn('boom happened', id: %i[general specific]) } + .to have_warning(%i[general specific], /boom/) + end + + it 'matches an id whose inspect form is quoted' do + expect { WebDriver.logger.warn('m', id: :'needs-quoting') }.to have_warning(:'needs-quoting') + end + + it 'only matches at the named severity' do + expect { WebDriver.logger.warn('m', id: :warned) }.not_to have_info(:warned) + end + end + end +end From 2762f55b25d7831da52724803353a3f8a1881c78 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 13:28:47 -0500 Subject: [PATCH 15/56] [rb] ensure ruby tests are properly linted (#17850) * [rb] glob spec sources into //rb/spec:spec for rubocop and clear hidden offenses * [rb] remove unnecessary rb_library from rb_integration_test now that //rb/spec:spec globs sources --- rb/spec/BUILD.bazel | 49 ++++++------------- rb/spec/integration/BUILD.bazel | 7 +++ .../selenium/webdriver/BUILD.bazel | 7 +++ .../selenium/webdriver/bidi/BUILD.bazel | 7 +++ .../selenium/webdriver/chrome/BUILD.bazel | 7 +++ .../selenium/webdriver/driver_finder_spec.rb | 5 +- .../selenium/webdriver/edge/BUILD.bazel | 7 +++ .../selenium/webdriver/fedcm_spec.rb | 5 +- .../selenium/webdriver/firefox/BUILD.bazel | 7 +++ .../selenium/webdriver/network_spec.rb | 8 +-- .../selenium/webdriver/remote/BUILD.bazel | 7 +++ .../selenium/webdriver/safari/BUILD.bazel | 7 +++ rb/spec/tests.bzl | 9 +--- 13 files changed, 81 insertions(+), 51 deletions(-) diff --git a/rb/spec/BUILD.bazel b/rb/spec/BUILD.bazel index 767e410f54425..aa42929e07a1d 100644 --- a/rb/spec/BUILD.bazel +++ b/rb/spec/BUILD.bazel @@ -7,47 +7,26 @@ rb_library( visibility = ["//rb/spec:__subpackages__"], ) -# List of dependencies can be gathered by running: -# bazel query 'kind("rb_.* rule", //rb/spec/...) except attr(tags, "browser-test", //rb/spec/...) except //rb/spec:spec' | xargs -I{} echo '"{}",' - +# RuboCop lints only what Bazel stages for it, so this bundles every spec's +# sources. glob() can't cross package boundaries, so each spec package exposes an +# :all_srcs filegroup; new files in a listed package are covered automatically, +# a new package needs a line added here. rb_library( name = "spec", testonly = True, + data = [ + "//rb/spec/integration:all_srcs", + "//rb/spec/integration/selenium/webdriver:all_srcs", + "//rb/spec/integration/selenium/webdriver/bidi:all_srcs", + "//rb/spec/integration/selenium/webdriver/chrome:all_srcs", + "//rb/spec/integration/selenium/webdriver/edge:all_srcs", + "//rb/spec/integration/selenium/webdriver/firefox:all_srcs", + "//rb/spec/integration/selenium/webdriver/remote:all_srcs", + "//rb/spec/integration/selenium/webdriver/safari:all_srcs", + ], visibility = ["//rb:__pkg__"], deps = [ "//rb/spec:rspec_matchers", - "//rb/spec/integration/selenium/webdriver:action_builder", - "//rb/spec/integration/selenium/webdriver:bidi", - "//rb/spec/integration/selenium/webdriver:devtools", - "//rb/spec/integration/selenium/webdriver:driver", - "//rb/spec/integration/selenium/webdriver:element", - "//rb/spec/integration/selenium/webdriver:error", - "//rb/spec/integration/selenium/webdriver:listener", - "//rb/spec/integration/selenium/webdriver:manager", - "//rb/spec/integration/selenium/webdriver:navigation", - "//rb/spec/integration/selenium/webdriver:select", - "//rb/spec/integration/selenium/webdriver:shadow_root", - "//rb/spec/integration/selenium/webdriver:spec_helper", - "//rb/spec/integration/selenium/webdriver:takes_screenshot", - "//rb/spec/integration/selenium/webdriver:target_locator", - "//rb/spec/integration/selenium/webdriver:timeout", - "//rb/spec/integration/selenium/webdriver:virtual_authenticator", - "//rb/spec/integration/selenium/webdriver:window", - "//rb/spec/integration/selenium/webdriver/bidi:browsing_context", - "//rb/spec/integration/selenium/webdriver/bidi:network", - "//rb/spec/integration/selenium/webdriver/bidi:script", - "//rb/spec/integration/selenium/webdriver/chrome:driver", - "//rb/spec/integration/selenium/webdriver/chrome:options", - "//rb/spec/integration/selenium/webdriver/chrome:service", - "//rb/spec/integration/selenium/webdriver/edge:driver", - "//rb/spec/integration/selenium/webdriver/edge:options", - "//rb/spec/integration/selenium/webdriver/edge:service", - "//rb/spec/integration/selenium/webdriver/firefox:driver", - "//rb/spec/integration/selenium/webdriver/firefox:profile", - "//rb/spec/integration/selenium/webdriver/firefox:service", - "//rb/spec/integration/selenium/webdriver/remote:driver", - "//rb/spec/integration/selenium/webdriver/remote:element", - "//rb/spec/integration/selenium/webdriver/safari:driver", "//rb/spec/unit", "//rb/spec/unit:spec_helper", ], diff --git a/rb/spec/integration/BUILD.bazel b/rb/spec/integration/BUILD.bazel index c13fa56313f5c..fad81488aa66b 100644 --- a/rb/spec/integration/BUILD.bazel +++ b/rb/spec/integration/BUILD.bazel @@ -1,5 +1,12 @@ package(default_visibility = ["//rb/spec/integration:__subpackages__"]) +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + BROWSERS = [ "chrome", "chrome-beta", diff --git a/rb/spec/integration/selenium/webdriver/BUILD.bazel b/rb/spec/integration/selenium/webdriver/BUILD.bazel index 62e565039d868..a06ee3e9e4cdc 100644 --- a/rb/spec/integration/selenium/webdriver/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/BUILD.bazel @@ -1,6 +1,13 @@ load("@rules_ruby//ruby:defs.bzl", "rb_library") load("//rb/spec:tests.bzl", "rb_integration_test") +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + rb_library( name = "spec_helper", testonly = True, diff --git a/rb/spec/integration/selenium/webdriver/bidi/BUILD.bazel b/rb/spec/integration/selenium/webdriver/bidi/BUILD.bazel index 8cef0b2ee03b2..0d124cdb7d204 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/bidi/BUILD.bazel @@ -1,5 +1,12 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + [ rb_integration_test( name = file[:-8], diff --git a/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel b/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel index 3a9b4760eae25..96ce1333d3c8e 100644 --- a/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel @@ -1,5 +1,12 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + [ rb_integration_test( name = file[:-8], diff --git a/rb/spec/integration/selenium/webdriver/driver_finder_spec.rb b/rb/spec/integration/selenium/webdriver/driver_finder_spec.rb index 2f1f1e16e3590..a20db8637e6ff 100644 --- a/rb/spec/integration/selenium/webdriver/driver_finder_spec.rb +++ b/rb/spec/integration/selenium/webdriver/driver_finder_spec.rb @@ -53,7 +53,8 @@ module WebDriver it 'downloads the browser into the Selenium cache', pending_if: [{browser: :safari, reason: 'browser ships with OS'}, - {browser: :edge, platform: :windows, reason: 'Edge MSI installer always writes to system path'}] do + {browser: :edge, platform: :windows, + reason: 'Edge MSI installer always writes to system path'}] do Dir.mktmpdir('se-cache') do |cache_dir| originals = {'SE_CACHE_PATH' => ENV.fetch('SE_CACHE_PATH', nil), 'SE_FORCE_BROWSER_DOWNLOAD' => ENV.fetch('SE_FORCE_BROWSER_DOWNLOAD', nil)} @@ -68,7 +69,7 @@ module WebDriver it 'resolves the browser to its system install location', skip_unless: [{browser: :safari}, - {browser: :edge, platform: :windows}] do + {browser: :edge, platform: :windows}] do Dir.mktmpdir('se-cache') do |cache_dir| originals = {'SE_CACHE_PATH' => ENV.fetch('SE_CACHE_PATH', nil), 'SE_FORCE_BROWSER_DOWNLOAD' => ENV.fetch('SE_FORCE_BROWSER_DOWNLOAD', nil)} diff --git a/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel b/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel index a6269de503d6d..59b72ae83ec78 100644 --- a/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel @@ -1,5 +1,12 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + [ rb_integration_test( name = file[:-8], diff --git a/rb/spec/integration/selenium/webdriver/fedcm_spec.rb b/rb/spec/integration/selenium/webdriver/fedcm_spec.rb index cd9a340833de5..1046803cb38e9 100644 --- a/rb/spec/integration/selenium/webdriver/fedcm_spec.rb +++ b/rb/spec/integration/selenium/webdriver/fedcm_spec.rb @@ -22,7 +22,8 @@ module Selenium module WebDriver module FedCM - describe FedCM, skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, {browser: %i[chrome edge]}] do + describe FedCM, + skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, {browser: %i[chrome edge]}] do let(:dialog) { driver.fedcm_dialog } before { driver.get url_for('fedcm/fedcm.html') } @@ -67,7 +68,7 @@ module FedCM end it 'clicks the dialog', pending_if: {browser: %i[chrome edge], - reason: "error: 'Use another account' not supported for this IDP"} do + reason: "error: 'Use another account' not supported for this IDP"} do expect(dialog.click).to be_nil end diff --git a/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel b/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel index ccddab6d7c900..f5782d96949f9 100644 --- a/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel @@ -1,5 +1,12 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + [ rb_integration_test( name = file[:-8], diff --git a/rb/spec/integration/selenium/webdriver/network_spec.rb b/rb/spec/integration/selenium/webdriver/network_spec.rb index c0afeb3c39a2a..dfe09fc85232f 100644 --- a/rb/spec/integration/selenium/webdriver/network_spec.rb +++ b/rb/spec/integration/selenium/webdriver/network_spec.rb @@ -21,9 +21,9 @@ module Selenium module WebDriver - describe Network, skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'}, - pending_if: {browser: %i[safari safari_preview], - reason: 'Safari does not support the BiDi network domain'} do + describe Network, pending_if: {browser: %i[safari safari_preview], + reason: 'Safari does not support the BiDi network domain'}, + skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do let(:username) { SpecSupport::RackServer::TestApp::BASIC_AUTH_CREDENTIALS.first } let(:password) { SpecSupport::RackServer::TestApp::BASIC_AUTH_CREDENTIALS.last } @@ -265,7 +265,7 @@ module WebDriver it 'adds a response handler that provides a response', pending_if: {browser: :firefox, - reason: 'https://github.com/w3c/webdriver-bidi/issues/747'} do + reason: 'https://github.com/w3c/webdriver-bidi/issues/747'} do reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver) network.add_response_handler do |response| diff --git a/rb/spec/integration/selenium/webdriver/remote/BUILD.bazel b/rb/spec/integration/selenium/webdriver/remote/BUILD.bazel index 7c4b21bda53c0..e21240a583a19 100644 --- a/rb/spec/integration/selenium/webdriver/remote/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/remote/BUILD.bazel @@ -1,5 +1,12 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + [ rb_integration_test( name = file[:-8], diff --git a/rb/spec/integration/selenium/webdriver/safari/BUILD.bazel b/rb/spec/integration/selenium/webdriver/safari/BUILD.bazel index 7c17fbfdd0fd3..1c813ddd73e9e 100644 --- a/rb/spec/integration/selenium/webdriver/safari/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/safari/BUILD.bazel @@ -1,5 +1,12 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +filegroup( + name = "all_srcs", + testonly = True, + srcs = glob(["**/*.rb"]), + visibility = ["//rb/spec:__pkg__"], +) + [ rb_integration_test( name = file[:-8], diff --git a/rb/spec/tests.bzl b/rb/spec/tests.bzl index d2451be1f4d32..33c9c69991aef 100644 --- a/rb/spec/tests.bzl +++ b/rb/spec/tests.bzl @@ -1,4 +1,4 @@ -load("@rules_ruby//ruby:defs.bzl", "rb_library", "rb_test") +load("@rules_ruby//ruby:defs.bzl", "rb_test") load( "//common:browsers.bzl", "COMMON_TAGS", @@ -190,13 +190,6 @@ def rb_integration_test( bidi = False, classic = True, grid = True): - # Generate a library target that is used by //rb/spec:spec to expose all tests to //rb:rubocop. - rb_library( - name = name, - srcs = srcs, - visibility = ["//rb:__subpackages__"], - ) - for browser in browsers: generate_classic = BROWSERS[browser].get("classic", True) generate_bidi = BROWSERS[browser].get("bidi", False) From f47fbb6e17432bdccd851ada9a481c31b6dad03b Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 17:11:47 -0500 Subject: [PATCH 16/56] [bidi] Correct float/enum type fidelity in the shared schema and validate primitives outbound in Ruby (#17852) * [build] bump cddl to 0.21.1 so float BiDi ranges type as number, not integer * [build] hoist nullable inline enums to named enums so their vocabulary is validated * [rb] validate outbound BiDi primitive types at construction --- .../selenium-webdriver/normalize_bidi_ast.mjs | 31 +++++++++++---- .../normalize_bidi_ast_test.mjs | 12 ++++++ javascript/selenium-webdriver/package.json | 2 +- .../project_bidi_schema.mjs | 8 +++- .../project_bidi_schema_test.mjs | 24 ++++++++---- pnpm-lock.yaml | 13 ++++++- .../bidi/protocol/browsing_context.rb | 2 +- .../webdriver/bidi/protocol/emulation.rb | 22 +++++++++-- .../selenium/webdriver/bidi/protocol/input.rb | 12 +++--- .../webdriver/bidi/serialization/record.rb | 32 ++++++++++++--- .../bidi/protocol/browsing_context.rbs | 2 +- .../webdriver/bidi/protocol/emulation.rbs | 14 ++++--- .../webdriver/bidi/protocol/input.rbs | 6 +-- .../selenium/webdriver/bidi/serialization.rbs | 4 ++ .../webdriver/bidi/serialization_spec.rb | 39 +++++++++++++++---- 15 files changed, 168 insertions(+), 55 deletions(-) diff --git a/javascript/selenium-webdriver/normalize_bidi_ast.mjs b/javascript/selenium-webdriver/normalize_bidi_ast.mjs index a388d62e1ecb8..6812d750fb826 100644 --- a/javascript/selenium-webdriver/normalize_bidi_ast.mjs +++ b/javascript/selenium-webdriver/normalize_bidi_ast.mjs @@ -97,6 +97,16 @@ function groupRef(value) { return { Type: 'group', Value: value, Unwrapped: false } } +/** True when `entry` is a string/number/bool literal (`{Type:'literal', Value}`). */ +function isLiteral(entry) { + return entry && typeof entry === 'object' && entry.Type === 'literal' +} + +/** True when `entry` is the CDDL null keyword (bare `'null'`) or a `nil`/`null` prelude ref. */ +function isNullArm(entry) { + return entry === 'null' || (isGroupRef(entry) && (entry.Value === 'null' || entry.Value === 'nil')) +} + /** * Drop the leading run of `label` that restates `ownerLocal`, backing off to a * camelCase boundary, so `ContinueWithAuthParameters` + `ContinueWithAuthCredentials` @@ -151,9 +161,11 @@ function eachPropertyDeep(properties, fn) { } /** - * Rewrite fields whose type is a union of >= 2 string literals into a reference - * to a synthetic enum def, and append those enum defs. Single-literal fields - * (discriminators) are left untouched. Returns a new AST array. + * Rewrite fields whose type is a choice of >= 2 string literals (optionally with a + * null alternative) into a reference to a synthetic enum def, and append those enum + * defs. A null alternative is kept on the field so the enum stays nullable; the enum + * def itself holds only the literals. Single-literal fields (discriminators) are left + * untouched. Returns a new AST array. * @param {object[]} ast The AST to transform. * @returns {object[]} A new AST array with inline enums hoisted to named defs. */ @@ -167,9 +179,12 @@ export function hoistInlineEnums(ast) { const owner = splitName(def.Name ?? '') eachPropertyDeep(def.Properties, (prop) => { const entries = typeList(prop.Type) - const allLiterals = - entries.length >= 2 && entries.every((e) => e && typeof e === 'object' && e.Type === 'literal') - if (!allLiterals) return + const literals = entries.filter(isLiteral) + const nullArms = entries.filter(isNullArm) + // Hoist a choice of >= 2 string literals, tolerating a null alternative so a nullable inline + // enum (`("a" / "b") / null`) is still named. The null stays on the field (below), never in the + // enum def; anything else in the choice (a ref, a single literal discriminator) is left untouched. + if (literals.length < 2 || literals.length + nullArms.length !== entries.length) return const base = pascal(prop.Name) || `Value${created.length}` const localName = `${owner.local}${base}` @@ -179,13 +194,13 @@ export function hoistInlineEnums(ast) { Type: 'variable', Name: synthName, IsChoiceAddition: false, - PropertyType: entries.map((e) => structuredClone(e)), + PropertyType: literals.map((e) => structuredClone(e)), Comments: prop.Comments ?? [], 'x-selenium-synthetic': true, 'x-selenium-owner': def.Name, 'x-selenium-label': base, }) - prop.Type = [groupRef(synthName)] + prop.Type = [groupRef(synthName), ...nullArms.map((e) => structuredClone(e))] }) } diff --git a/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs b/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs index e90329db1166b..6a6c1262c4fc5 100644 --- a/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs +++ b/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs @@ -56,6 +56,18 @@ describe('hoistInlineEnums', () => { ) }) + it('hoists a nullable literal choice, keeping the null on the field and out of the enum', () => { + const ast = [def('x.T', [field('scrollbarType', [lit('classic'), lit('overlay'), 'null'])])] + const out = hoistInlineEnums(ast) + + const enumName = 'x.TScrollbarType' + assert.deepEqual(byName(out, 'x.T').Properties[0].Type, [ref(enumName), 'null']) + assert.deepEqual( + byName(out, enumName).PropertyType.map((e) => e.Value), + ['classic', 'overlay'], + ) + }) + it('does NOT hoist a single-literal (discriminator) field', () => { const ast = [def('x.T', [field('type', [lit('password')])])] const out = hoistInlineEnums(ast) diff --git a/javascript/selenium-webdriver/package.json b/javascript/selenium-webdriver/package.json index b4b3b05c5ef3e..9689ea5e9facb 100644 --- a/javascript/selenium-webdriver/package.json +++ b/javascript/selenium-webdriver/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "cddl": "^0.21.0", + "cddl": "^0.21.1", "cddl2ts": "^0.10.0", "clean-jsdoc-theme": "^4.3.3", "eslint": "^10.7.0", diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs index 53b99dd205776..1128108bf6412 100644 --- a/javascript/selenium-webdriver/project_bidi_schema.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -195,8 +195,12 @@ function projectEntry(e) { if (e.Type === 'array') return { list: projectRef(e.Values?.[0]?.Type) } if (e.Type === 'map') return { map: projectRef(e.ValueType ?? e.Values?.[0]?.Type), extensible: true } if (e.Type === 'range') { - const intRange = Number.isInteger(e.Value?.Min?.Value) && Number.isInteger(e.Value?.Max?.Value) - return { primitive: intRange ? 'integer' : 'number' } // e.g. js-uint (0..MAX) vs scale (0.1..2) + // A bound written as a float (`1.0`) parses to an integer `Value` carrying an `IsFloat` + // marker; consult it so `(0.0..1.0)` is a number range, not — as its integral bounds alone + // would read — an integer one. A bound with no marker falls back to its value's integralness. + const intBound = (b) => b && !b.IsFloat && Number.isInteger(b.Value) + const intRange = intBound(e.Value?.Min) && intBound(e.Value?.Max) + return { primitive: intRange ? 'integer' : 'number' } // e.g. js-uint (0..MAX) vs latitude (-90.0..90.0) } return { primitive: PRIMITIVES[e.Type] ?? 'unknown' } } diff --git a/javascript/selenium-webdriver/project_bidi_schema_test.mjs b/javascript/selenium-webdriver/project_bidi_schema_test.mjs index 6420031d65fa8..ed0d0d235414c 100644 --- a/javascript/selenium-webdriver/project_bidi_schema_test.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema_test.mjs @@ -182,11 +182,20 @@ describe('projectType (list / union / alias defs)', () => { Name: 'x.F', PropertyType: [{ Type: 'range', Value: { Min: { Value: 0.1 }, Max: { Value: 2 } } }], }, + { + // `(0.0..1.0)` — integral bounds, but the `IsFloat` marker makes it a number range. + Type: 'variable', + Name: 'x.W', + PropertyType: [ + { Type: 'range', Value: { Min: { Value: 0, IsFloat: true }, Max: { Value: 1, IsFloat: true } } }, + ], + }, ], {}, ) assert.deepEqual(s.types['x.U'], { kind: 'alias', type: { primitive: 'integer' } }) assert.deepEqual(s.types['x.F'], { kind: 'alias', type: { primitive: 'number' } }) + assert.deepEqual(s.types['x.W'], { kind: 'alias', type: { primitive: 'number' } }) }) it('unwraps a control-operator (.default / .ge) wrapped field type to its inner type', () => { @@ -446,15 +455,14 @@ describe('schema signals (objectOnly / preserveExtras / enum primitive)', () => assert.deepEqual(checkSchema(s), []) }) - it('types an inline (non-hoisted) literal choice with the primitive its literals share', () => { - // A nullable literal choice (`("classic" / "overlay") / null`) the normalizer leaves - // inline — carry `primitive: string` so the scalar is typed rather than opaque. + it('hoists a nullable literal choice to a named enum, referenced with the null preserved', () => { + // A nullable literal choice (`("classic" / "overlay") / null`) is hoisted (normalize_bidi_ast) + // to a named enum and referenced with the null kept on the field — a nullable enum ref, not an + // inline enum carrying a primitive. const s = projectSchema([group('x.R', [field('kind', [lit('classic'), lit('overlay'), 'null'])])], {}) - assert.deepEqual(s.types['x.R'].fields[0].type, { - enum: ['classic', 'overlay'], - primitive: 'string', - nullable: true, - }) + assert.deepEqual(s.types['x.R'].fields[0].type, { ref: 'x.RKind', nullable: true }) + assert.equal(s.types['x.RKind'].kind, 'enum') + assert.deepEqual(s.types['x.RKind'].values, ['classic', 'overlay']) assert.deepEqual(checkSchema(s), []) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b5b2701edea3..4c75f3f00a2fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,8 +138,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.7.0(supports-color@10.2.2)) cddl: - specifier: ^0.21.0 - version: 0.21.0 + specifier: ^0.21.1 + version: 0.21.1 cddl2ts: specifier: ^0.10.0 version: 0.10.0 @@ -1435,6 +1435,10 @@ packages: resolution: {integrity: sha512-/2lnDcCA/7DRDChH2szAW4tKzZeqYFQKhw2nGqi6WqBr7N9mx2yObATLnc01pqG9pXJrw3auT1X5O58azNBTMQ==} hasBin: true + cddl@0.21.1: + resolution: {integrity: sha512-Sv4ZR4ZDODrcCaOjedZ2dxOIcVBcahQm/z/jbVonBO2hRbUvTpJiCq1syM5h4ZTWst0Ld2wOZP26voYXeZr4Dg==} + hasBin: true + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -5642,6 +5646,11 @@ snapshots: camelcase: 9.0.0 yargs: 18.0.0 + cddl@0.21.1: + dependencies: + camelcase: 9.0.0 + yargs: 18.0.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 diff --git a/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb b/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb index eaf27d8a96acc..859a8d6c71b12 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb @@ -211,7 +211,7 @@ class Locator < Serialization::Union # @see https://w3c.github.io/webdriver-bidi/#cddl-type-browsingcontextimageformat ImageFormat = Serialization::Record.define( type: {wire_key: 'type', primitive: 'string'}, - quality: {wire_key: 'quality', required: false, primitive: 'integer'} + quality: {wire_key: 'quality', required: false, primitive: 'number'} ) # @api private diff --git a/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb b/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb index 3126a4f10b19d..b1680174313f0 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb @@ -48,6 +48,11 @@ class Emulation < Domain landscape_secondary: 'landscape-secondary' }.freeze + SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE = { + classic: 'classic', + overlay: 'overlay' + }.freeze + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#cddl-type-emulationsetforcedcolorsmodethemeoverrideparameters @@ -88,12 +93,12 @@ class SetGeolocationOverrideParameters < Serialization::Union # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#cddl-type-emulationgeolocationcoordinates GeolocationCoordinates = Serialization::Record.define( - latitude: {wire_key: 'latitude', primitive: 'integer'}, - longitude: {wire_key: 'longitude', primitive: 'integer'}, + latitude: {wire_key: 'latitude', primitive: 'number'}, + longitude: {wire_key: 'longitude', primitive: 'number'}, accuracy: {wire_key: 'accuracy', required: false, primitive: 'number'}, altitude: {wire_key: 'altitude', required: false, nullable: true, primitive: 'number'}, altitude_accuracy: {wire_key: 'altitudeAccuracy', required: false, nullable: true, primitive: 'number'}, - heading: {wire_key: 'heading', required: false, nullable: true, primitive: 'integer'}, + heading: {wire_key: 'heading', required: false, nullable: true, primitive: 'number'}, speed: {wire_key: 'speed', required: false, nullable: true, primitive: 'number'} ) @@ -185,7 +190,11 @@ class SetGeolocationOverrideParameters < Serialization::Union # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#cddl-type-emulationsetscrollbartypeoverrideparameters SetScrollbarTypeOverrideParameters = Serialization::Record.define( - scrollbar_type: {wire_key: 'scrollbarType', nullable: true, primitive: 'string'}, + scrollbar_type: { + wire_key: 'scrollbarType', + nullable: true, + enum: 'Emulation::SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE' + }, contexts: {wire_key: 'contexts', required: false, list: true}, user_contexts: {wire_key: 'userContexts', required: false, list: true} ) @@ -319,6 +328,11 @@ def set_scrollbar_type_override( contexts: Serialization::UNSET, user_contexts: Serialization::UNSET ) + Serialization.validate!( + 'scrollbarType', + scrollbar_type, + Emulation::SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE + ) params = SetScrollbarTypeOverrideParameters.new( scrollbar_type: scrollbar_type, contexts: contexts, diff --git a/rb/lib/selenium/webdriver/bidi/protocol/input.rb b/rb/lib/selenium/webdriver/bidi/protocol/input.rb index 42db9c9c51068..4e3e0c1d69bf4 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/input.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/input.rb @@ -197,8 +197,8 @@ class WheelSourceAction < Serialization::Union button: {wire_key: 'button', primitive: 'integer'}, width: {wire_key: 'width', required: false, primitive: 'integer'}, height: {wire_key: 'height', required: false, primitive: 'integer'}, - pressure: {wire_key: 'pressure', required: false, primitive: 'integer'}, - tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'integer'}, + pressure: {wire_key: 'pressure', required: false, primitive: 'number'}, + tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'number'}, twist: {wire_key: 'twist', required: false, primitive: 'integer'}, altitude_angle: {wire_key: 'altitudeAngle', required: false, primitive: 'number'}, azimuth_angle: {wire_key: 'azimuthAngle', required: false, primitive: 'number'} @@ -215,8 +215,8 @@ class WheelSourceAction < Serialization::Union origin: {wire_key: 'origin', required: false, ref: 'Input::Origin'}, width: {wire_key: 'width', required: false, primitive: 'integer'}, height: {wire_key: 'height', required: false, primitive: 'integer'}, - pressure: {wire_key: 'pressure', required: false, primitive: 'integer'}, - tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'integer'}, + pressure: {wire_key: 'pressure', required: false, primitive: 'number'}, + tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'number'}, twist: {wire_key: 'twist', required: false, primitive: 'integer'}, altitude_angle: {wire_key: 'altitudeAngle', required: false, primitive: 'number'}, azimuth_angle: {wire_key: 'azimuthAngle', required: false, primitive: 'number'} @@ -241,8 +241,8 @@ class WheelSourceAction < Serialization::Union PointerCommonProperties = Serialization::Record.define( width: {wire_key: 'width', required: false, primitive: 'integer'}, height: {wire_key: 'height', required: false, primitive: 'integer'}, - pressure: {wire_key: 'pressure', required: false, primitive: 'integer'}, - tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'integer'}, + pressure: {wire_key: 'pressure', required: false, primitive: 'number'}, + tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'number'}, twist: {wire_key: 'twist', required: false, primitive: 'integer'}, altitude_angle: {wire_key: 'altitudeAngle', required: false, primitive: 'number'}, azimuth_angle: {wire_key: 'azimuthAngle', required: false, primitive: 'number'} diff --git a/rb/lib/selenium/webdriver/bidi/serialization/record.rb b/rb/lib/selenium/webdriver/bidi/serialization/record.rb index 7285c8b5c44a9..086b4bc51d15e 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/record.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/record.rb @@ -102,9 +102,10 @@ def from_json(json_payload) # Checks each field's value: a required field cannot be omitted (UNSET), a non-nullable # field cannot be nil (nil is neither a value nor the UNSET omit-sentinel, so it would be # silently dropped on the wire), a nullable-const field must carry its literal (not some - # other value), and an enum field must be in its allowed set. The enum constant is resolved - # lazily so a cross-domain enum need not be loaded first. Outbound only (from +new+); - # inbound presence/enum are checked separately in +wire_value+/+read+. + # other value), a primitive field must be the matching Ruby type, and an enum field must be + # in its allowed set. The enum constant is resolved lazily so a cross-domain enum need not be + # loaded first. Outbound only (from +new+); inbound presence/primitive/enum are checked + # separately in +wire_value+/+read+. def validate_values(attributes) fields.each do |f| value = attributes[f.name] @@ -112,12 +113,20 @@ def validate_values(attributes) raise ::ArgumentError, "#{name}##{f.name} cannot be nil" if value.nil? && !f.nullable next if value.nil? || UNSET.equal?(value) - validate_const(f, value) - check_outbound_shape(f, value) - Serialization.validate!("#{name}##{f.name}", value, Protocol.const_get(f.enum)) if f.enum + validate_present(f, value) end end + # Checks a field that carries an actual value (neither omitted nor nil): a nullable-const + # field against its literal, list/scalar shape, primitive type (lists excepted, as inbound + # does), and enum membership (resolved lazily so a cross-domain enum need not load first). + def validate_present(field, value) + validate_const(field, value) + check_outbound_shape(field, value) + check_outbound_primitive(field, value) unless field.list + Serialization.validate!("#{name}##{field.name}", value, Protocol.const_get(field.enum)) if field.enum + end + # A nullable constant (`literal / null`) is caller-settable but its only non-null value is # the literal, so a value that is neither the literal nor nil (nil is handled above) is a # local error rather than a wire round-trip. A non-const field carries UNSET here and passes. @@ -137,6 +146,17 @@ def check_outbound_shape(field, value) raise ::ArgumentError, "#{name}##{field.name} expected #{kind}, got #{value.inspect}" end + # Outbound mirror of check_primitive: a primitive-typed arg (`string`/`integer`/…) must be + # the matching Ruby type, so a caller mistake (a string width, a float count) is a local + # ArgumentError here rather than a rejection the browser reports a round-trip later. A field + # with no primitive descriptor (enum, ref, opaque) passes; lists are skipped, as inbound does. + def check_outbound_primitive(field, value) + expected = PRIMITIVE_TYPES[field.primitive] + return if expected.nil? || expected.any? { |type| value.is_a?(type) } + + raise ::ArgumentError, "#{name}##{field.name} expected #{field.primitive}, got #{value.inspect}" + end + def fixed?(field) !UNSET.equal?(field.fixed) end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs index 64ea159abaf1c..13213ce399014 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs @@ -129,7 +129,7 @@ module Selenium class ImageFormat < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String attr_reader quality: untyped - def self.new: (type: String, ?quality: Integer) -> instance + def self.new: (type: String, ?quality: Numeric) -> instance end class ClipRectangle < ::Selenium::WebDriver::BiDi::Serialization::Union diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs index 56a5bff727130..d6b01bb0bc8b1 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs @@ -29,6 +29,8 @@ module Selenium SCREEN_ORIENTATION_TYPE: Hash[Symbol, String] + SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE: Hash[Symbol, String] + class SetForcedColorsModeThemeOverrideParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader theme: Symbol? attr_reader contexts: untyped @@ -52,14 +54,14 @@ module Selenium end class GeolocationCoordinates < ::Selenium::WebDriver::BiDi::Serialization::Record - attr_reader latitude: Integer - attr_reader longitude: Integer + attr_reader latitude: Numeric + attr_reader longitude: Numeric attr_reader accuracy: untyped attr_reader altitude: untyped attr_reader altitude_accuracy: untyped attr_reader heading: untyped attr_reader speed: untyped - def self.new: (latitude: Integer, longitude: Integer, ?accuracy: Numeric, ?altitude: Numeric?, ?altitude_accuracy: Numeric?, ?heading: Integer?, ?speed: Numeric?) -> instance + def self.new: (latitude: Numeric, longitude: Numeric, ?accuracy: Numeric, ?altitude: Numeric?, ?altitude_accuracy: Numeric?, ?heading: Numeric?, ?speed: Numeric?) -> instance end class GeolocationPositionError < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -127,10 +129,10 @@ module Selenium end class SetScrollbarTypeOverrideParameters < ::Selenium::WebDriver::BiDi::Serialization::Record - attr_reader scrollbar_type: String? + attr_reader scrollbar_type: Symbol? attr_reader contexts: untyped attr_reader user_contexts: untyped - def self.new: (scrollbar_type: String?, ?contexts: Array[String], ?user_contexts: Array[String]) -> instance + def self.new: (scrollbar_type: Symbol?, ?contexts: Array[String], ?user_contexts: Array[String]) -> instance end class SetTimezoneOverrideParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -154,7 +156,7 @@ module Selenium def set_screen_orientation_override: (screen_orientation: ::Selenium::WebDriver::BiDi::Protocol::Emulation::ScreenOrientation?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped def set_screen_settings_override: (screen_area: ::Selenium::WebDriver::BiDi::Protocol::Emulation::ScreenArea?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped def set_scripting_enabled: (enabled: bool?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped - def set_scrollbar_type_override: (scrollbar_type: String?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped + def set_scrollbar_type_override: (scrollbar_type: Symbol?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped def set_timezone_override: (timezone: String?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped def set_touch_override: (max_touch_points: Integer?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped def set_user_agent_override: (user_agent: String?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs index 720e921bef469..57e242b4ebbe8 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs @@ -119,7 +119,7 @@ module Selenium attr_reader twist: untyped attr_reader altitude_angle: untyped attr_reader azimuth_angle: untyped - def self.new: (?type: String, button: Integer, ?width: Integer, ?height: Integer, ?pressure: Integer, ?tangential_pressure: Integer, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance + def self.new: (?type: String, button: Integer, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance end class PointerMoveAction < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -135,7 +135,7 @@ module Selenium attr_reader twist: untyped attr_reader altitude_angle: untyped attr_reader azimuth_angle: untyped - def self.new: (?type: String, x: Numeric, y: Numeric, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::Origin, ?width: Integer, ?height: Integer, ?pressure: Integer, ?tangential_pressure: Integer, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance + def self.new: (?type: String, x: Numeric, y: Numeric, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::Origin, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance end class WheelScrollAction < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -157,7 +157,7 @@ module Selenium attr_reader twist: untyped attr_reader altitude_angle: untyped attr_reader azimuth_angle: untyped - def self.new: (?width: Integer, ?height: Integer, ?pressure: Integer, ?tangential_pressure: Integer, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance + def self.new: (?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance end class Origin < ::Selenium::WebDriver::BiDi::Serialization::Union diff --git a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs index 40837e6740134..b21395d5ee219 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs @@ -62,10 +62,14 @@ module Selenium def validate_values: (Hash[Symbol, untyped] attributes) -> void + def validate_present: (untyped field, untyped value) -> void + def validate_const: (untyped field, untyped value) -> void def check_outbound_shape: (untyped field, untyped value) -> void + def check_outbound_primitive: (untyped field, untyped value) -> void + def fixed?: (untyped field) -> bool def wire_value: (untyped field, Hash[untyped, untyped] json_payload) -> untyped diff --git a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb index 2b0525b33ec7e..cd3c5e60e1451 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb @@ -378,6 +378,25 @@ def moz_install(**kwargs) end end + describe 'outbound primitive validation' do + it 'rejects a wrong-typed primitive at construction, so an invalid object cannot exist' do + expect { BrowsingContext::NavigateParameters.new(context: 'c', url: 123) } + .to raise_error(ArgumentError, /NavigateParameters#url expected string/) + end + + it 'rejects a float for an integer field, mirroring the wire integer/number split' do + expect { Emulation::ScreenArea.new(width: 5.0, height: 5) } + .to raise_error(ArgumentError, /ScreenArea#width expected integer/) + end + + it 'accepts either an integer or a float for a number field' do + klass = Emulation::GeolocationCoordinates + + expect(klass.new(latitude: 0, longitude: 0, accuracy: 1.5).accuracy).to eq(1.5) + expect(klass.new(latitude: 0, longitude: 0, accuracy: 2).accuracy).to eq(2) + end + end + describe 'enum symbol coercion' do it 'takes an idiomatic symbol and serializes the wire token (kebab included)' do params = Bluetooth::SimulateAdapterParameters.new(context: 'c', state: :powered_off) @@ -404,6 +423,19 @@ def moz_install(**kwargs) expect(Network::AddInterceptParameters.from_json(params.as_json).phases) .to eq(%i[before_request_sent auth_required]) end + + # A nullable inline literal choice (scrollbarType = "classic" / "overlay" / null) is + # hoisted to a named enum, so it validates as a closed vocabulary in both directions + # (and still admits null) rather than passing any string through as it did when opaque. + it 'validates a hoisted nullable inline enum, still admitting null' do + klass = Emulation::SetScrollbarTypeOverrideParameters + + expect(klass.new(scrollbar_type: :overlay).as_json).to eq('scrollbarType' => 'overlay') + expect(klass.new(scrollbar_type: nil).as_json).to eq('scrollbarType' => nil) + expect { klass.new(scrollbar_type: :banana) }.to raise_error(ArgumentError, /must be one of/) + expect { klass.from_json('scrollbarType' => 'banana') } + .to raise_error(Error::WebDriverError, /received an unknown value/) + end end describe 'inbound shape validation' do @@ -457,13 +489,6 @@ def moz_install(**kwargs) expect(parsed.key).to eq(5) end - # Signal 3: an inline literal choice the projector now types as `string` - # (scrollbarType = "classic" / "overlay" / null), previously opaque. - it 'raises when an inline-enum scalar field arrives as the wrong primitive' do - expect { Emulation::SetScrollbarTypeOverrideParameters.from_json('scrollbarType' => 123) } - .to raise_error(Error::WebDriverError, /scrollbar_type expected string/) - end - # Signal 3: a scalar hidden behind an alias (size -> js-uint -> integer) now carries # its leaf primitive, so a wrong-typed value is rejected instead of passing opaque. it 'raises when an alias-typed integer field (js-uint) arrives as a string' do From 6d75941d905740e5502a2bcb7ae76d2b1da934a7 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 20:30:04 -0500 Subject: [PATCH 17/56] [bidi] mark BiDi types extensible per spec and update Ruby handling (#17853) * [build] drop preserveExtras schema signal; extensibility alone gates wire extras * [rb] retain wire extras on every extensible BiDi type, not only re-sendable ones * [rb] keep extensible extras without warning and reject extras shadowing declared fields --- .../project_bidi_schema.mjs | 55 +------------------ .../project_bidi_schema_test.mjs | 13 +++-- .../webdriver/bidi/protocol/network.rb | 3 +- .../webdriver/bidi/protocol/session.rb | 3 +- .../webdriver/bidi/protocol/storage.rb | 3 +- .../webdriver/bidi/serialization/record.rb | 36 +++++++++--- .../webdriver/bidi/support/bidi_generate.rb | 8 +-- .../webdriver/bidi/protocol/network.rbs | 3 +- .../webdriver/bidi/protocol/session.rbs | 3 +- .../webdriver/bidi/protocol/storage.rbs | 3 +- .../selenium/webdriver/bidi/serialization.rbs | 4 ++ .../webdriver/bidi/serialization_spec.rb | 44 +++++++++++---- 12 files changed, 89 insertions(+), 89 deletions(-) diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs index 1128108bf6412..8c97b4548f87b 100644 --- a/javascript/selenium-webdriver/project_bidi_schema.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -21,7 +21,7 @@ * The normalizer has already removed the awkward CDDL shapes, so this is a * straight mapping into a small vocabulary: * - * type node: { kind: 'record', fields: [field], map?, extensible?, preserveExtras?, specHref? } + * type node: { kind: 'record', fields: [field], map?, extensible?, specHref? } * | { kind: 'enum', values: [string], specHref? } * | { kind: 'union', variants: [ref], selector, objectOnly?, specHref? } * | { kind: 'alias', type, specHref? } @@ -43,13 +43,10 @@ * points at the editor's draft, so for an older generated artifact the target drifts * from the pinned source; synthetic types (and anything neither source covers) omit it. * - * Three derived signals let a binding validate the wire boundary without re-deriving + * Two derived signals let a binding validate the wire boundary without re-deriving * anything itself: * `objectOnly: true` — a union all of whose arms are object (record) types, so a * non-object payload is a schema violation, not a scalar arm. - * `preserveExtras: true` — an `extensible` type that can also be *sent* (reachable - * from a command's params), so unknown properties received on - * the wire must be stored and echoed back rather than dropped. * an inline `enum` ref carries the `primitive` its literals share, so even a scalar * the normalizer did not hoist to a named enum is typed rather than opaque. * `scalar` on an inline `union` ref marks a union with a bare-scalar arm (a map entry's @@ -339,47 +336,6 @@ function variantIsObject(ref, types, seen = new Set()) { return false // enum } -// The type-name refs a projected ref node points at, recursing through list / map / -// inline union / inline record. (checkSchema has an equivalent local walk for its own -// referential checks; this module-level one feeds the reachability closure below.) -function refNames(node) { - if (!node) return [] - if (node.ref) return [node.ref] - if (node.list) return refNames(node.list) - if (node.map) return refNames(node.map) - if (node.union) return node.union.flatMap(refNames) - if (node.record) return node.record.flatMap((f) => refNames(f.type)) - return [] -} - -// The type-name refs a type *node* (record / union / alias) points at: a record's -// field and map value types, a union's variants, an alias's target. -function typeRefNames(node) { - if (node.kind === 'record') { - const refs = node.fields.flatMap((f) => refNames(f.type)) - if (node.map) refs.push(...refNames(node.map)) - return refs - } - if (node.kind === 'union') return node.variants - if (node.kind === 'alias') return refNames(node.type) - return [] -} - -// The set of types that can be *sent*: reachable from some command's params, through -// fields, lists, unions, maps, and nested records/aliases. Results and events are not -// roots — a type reached only through them is received-only. `preserveExtras` gates the -// extras store on this, so only a type you can hand back keeps unknown wire properties. -function reSendableTypes(commands, types) { - const reachable = new Set() - const visit = (name) => { - if (!name || reachable.has(name) || !types[name]) return - reachable.add(name) - for (const r of typeRefNames(types[name])) visit(r) - } - for (const c of commands) if (c.params?.ref) visit(c.params.ref) - return reachable -} - // The constant value a record pins on wire key `k`, as `{ value }` (a string or // `null`), or `{ open: true }` when the field exists but is not constant (a base // type acting as the catch-all, e.g. log.GenericLogEntry.type), or null when the @@ -639,13 +595,6 @@ export function projectSchema(ast, model, links = {}) { } } - // An extensible type keeps unknown wire properties only when it is also re-sendable - // (reachable from a command's params) — a type you receive and can hand back, so its - // extras must round-trip. A received-only extensible type drops them. - const reSendable = reSendableTypes(commands, types) - for (const [name, node] of Object.entries(types)) - if (node.extensible && reSendable.has(name)) node.preserveExtras = true - // Per-domain module links, for a binding that emits one class/namespace per domain. const domains = {} for (const domain of Object.keys(model)) { diff --git a/javascript/selenium-webdriver/project_bidi_schema_test.mjs b/javascript/selenium-webdriver/project_bidi_schema_test.mjs index ed0d0d235414c..dd7d57d5b3933 100644 --- a/javascript/selenium-webdriver/project_bidi_schema_test.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema_test.mjs @@ -395,7 +395,7 @@ describe('unionSelector', () => { }) }) -describe('schema signals (objectOnly / preserveExtras / enum primitive)', () => { +describe('schema signals (objectOnly / extensible / enum primitive)', () => { const rec = (name, typeConst) => group(name, [field('type', [lit(typeConst)])]) const union = (name, refs) => ({ Type: 'variable', @@ -439,7 +439,10 @@ describe('schema signals (objectOnly / preserveExtras / enum primitive)', () => assert.equal(s.types['x.Origin'].objectOnly, undefined) }) - it('marks an extensible type reachable from command params as preserveExtras, but not a result-only one', () => { + it('marks every extensible type extensible, regardless of send/receive reachability', () => { + // Extensibility is the whole signal: a type reachable only through a command's result + // keeps its extras store just as one reachable through params does. Send-reachability + // ("retain extras only where they can be sent back") is deliberately not a factor. const ast = [ group('x.SetParams', [field('cfg', [ref('x.Config')])]), group('x.Config', [field('text', ['any'], { n: 0, m: null })]), @@ -448,10 +451,8 @@ describe('schema signals (objectOnly / preserveExtras / enum primitive)', () => ] const model = { x: { commands: [{ method: 'x.set', name: 'set', params: 'x.SetParams', result: 'x.GetResult' }] } } const s = projectSchema(ast, model) - assert.equal(s.types['x.Config'].extensible, true) - assert.equal(s.types['x.Config'].preserveExtras, true) // reachable through the command's params - assert.equal(s.types['x.Info'].extensible, true) - assert.equal(s.types['x.Info'].preserveExtras, undefined) // reachable only through the result + assert.equal(s.types['x.Config'].extensible, true) // reachable through the command's params + assert.equal(s.types['x.Info'].extensible, true) // reachable only through the result assert.deepEqual(checkSchema(s), []) }) diff --git a/rb/lib/selenium/webdriver/bidi/protocol/network.rb b/rb/lib/selenium/webdriver/bidi/protocol/network.rb index 8e6dfa0578526..07d6ba14c8abe 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/network.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/network.rb @@ -150,7 +150,8 @@ class BytesValue < Serialization::Union http_only: {wire_key: 'httpOnly', primitive: 'boolean'}, secure: {wire_key: 'secure', primitive: 'boolean'}, same_site: {wire_key: 'sameSite', enum: 'Network::SAME_SITE'}, - expiry: {wire_key: 'expiry', required: false, primitive: 'integer'} + expiry: {wire_key: 'expiry', required: false, primitive: 'integer'}, + extensible: true ) # @api private diff --git a/rb/lib/selenium/webdriver/bidi/protocol/session.rb b/rb/lib/selenium/webdriver/bidi/protocol/session.rb index d6f8110a423b8..ac6021349168a 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/session.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/session.rb @@ -204,7 +204,8 @@ class ProxyConfiguration < Serialization::Union required: false, ref: 'Session::UserPromptHandler' }, - web_socket_url: {wire_key: 'webSocketUrl', required: false, primitive: 'string'} + web_socket_url: {wire_key: 'webSocketUrl', required: false, primitive: 'string'}, + extensible: true ) # @api private diff --git a/rb/lib/selenium/webdriver/bidi/protocol/storage.rb b/rb/lib/selenium/webdriver/bidi/protocol/storage.rb index 0b86396a3be29..35ecb16a384b1 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/storage.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/storage.rb @@ -33,7 +33,8 @@ class Storage < Domain # @see https://w3c.github.io/webdriver-bidi/#type-storage-PartitionKey PartitionKey = Serialization::Record.define( user_context: {wire_key: 'userContext', required: false, primitive: 'string'}, - source_origin: {wire_key: 'sourceOrigin', required: false, primitive: 'string'} + source_origin: {wire_key: 'sourceOrigin', required: false, primitive: 'string'}, + extensible: true ) # @api private diff --git a/rb/lib/selenium/webdriver/bidi/serialization/record.rb b/rb/lib/selenium/webdriver/bidi/serialization/record.rb index 086b4bc51d15e..2c07af77f2f57 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/record.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/record.rb @@ -81,8 +81,8 @@ def new(**kwargs) # Inbound: builds from the wire. A missing required field is omitted and warned (or # raised in strict mode, in +wire_value+); enum tokens are mapped back to symbols and an - # unrecognized one raises (in +read+); an undeclared property is warned, then captured - # (extensible) or dropped (closed) — strict on shape, lenient on extras. + # unrecognized one raises (in +read+); an undeclared property is captured silently + # (extensible) or warned and dropped (closed) — strict on shape, lenient on extras. def from_json(json_payload) unless json_payload.is_a?(::Hash) raise Error::WebDriverError, "#{name} expected an object on the wire, got #{json_payload.inspect}" @@ -92,8 +92,11 @@ def from_json(json_payload) [f.name, wire_value(f, json_payload)] end undeclared = extra(json_payload) - warn_undeclared(undeclared) unless undeclared.empty? - attributes[:extensions] = undeclared if extensible? + if extensible? + attributes[:extensions] = undeclared # the spec sanctions these extras; preserve them silently + else + warn_undeclared(undeclared) unless undeclared.empty? + end construct(**attributes) end @@ -288,9 +291,9 @@ def extra(json_payload) json_payload.except(*known) end - # Forward-compat signal: a property the type does not model is tolerated (retained on an - # extensible type, dropped on a closed one) and warned so schema drift is visible. Tagged - # +:bidi_undeclared_property+ so a caller can silence it via +logger.ignore+. + # Forward-compat signal: a property a closed type does not model is dropped and warned so + # schema drift is visible (an extensible type keeps its extras silently — the spec sanctions + # them). Tagged +:bidi_undeclared_property+ so a caller can silence it via +logger.ignore+. def warn_undeclared(undeclared) undeclared.each_key do |key| WebDriver.logger.warn("#{name} received an undeclared property: #{key.inspect}", @@ -321,9 +324,26 @@ def as_json(*) value = Serialization.to_wire(value, Protocol.const_get(f.enum)) if f.enum payload[f.wire_key] = Serializable.as_json(value) end - payload.merge!(extensions) if self.class.extensible? && !extensions.empty? + merge_extensions!(payload) if self.class.extensible? && !extensions.empty? payload end + + private + + # Merge the passthrough extras onto the wire, erroring rather than letting an extra whose key + # is a declared field's wire key silently clobber that typed value; an extra is by definition + # a field the spec does not declare. Keys are stringified first so a symbol key (e.g. `name:`) + # cannot slip past the guard and then reappear as a duplicate wire key once serialized. The + # single gate every outbound path funnels through: +new+, +with+, and in-place mutation. + def merge_extensions!(payload) + extras = extensions.transform_keys(&:to_s) + collisions = extras.keys & self.class.fields.map(&:wire_key) + unless collisions.empty? + raise ::ArgumentError, "#{self.class.name} extensions shadow declared fields: #{collisions.join(', ')}" + end + + payload.merge!(extras) + end end end end diff --git a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb index 402152f2a0e55..85f9a75e89a06 100644 --- a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb +++ b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb @@ -775,11 +775,11 @@ def record_class(name, type) wire: const['wire'], value: const['type']['const'], rbs: rbs_const(const['type']['const'])} fields = type['fields'].reject { |f| baked_discriminator?(f) }.map { |f| field_ir(f) } - # Gate the extensions store on `preserveExtras` (extensible AND re-sendable), not raw - # `extensible`: only a type you receive and can hand back keeps unknown wire keys. A - # received-only extensible type gets no store, so its unknown keys are silently ignored. + # Every extensible type gets the extensions store: an undeclared wire key is preserved + # and echoed back on any type the spec marks extensible, whether or not it is re-sendable. + # Extensibility alone is the signal; send-reachability does not enter into it. TypeClass.new(ruby_name: BiDiGenerate.type_class_name(name), fields: fields, - discriminator: discriminator, extensible: type['preserveExtras'] ? true : false, + discriminator: discriminator, extensible: type['extensible'] ? true : false, schema_name: name, synthetic: type['synthetic'] ? true : false, owner: type['owner'], label: type['label'], spec_href: type['specHref']) end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs index ea56d1a8ca2c2..ce3035c4807bd 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs @@ -89,7 +89,8 @@ module Selenium attr_reader secure: bool attr_reader same_site: Symbol attr_reader expiry: untyped - def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, domain: String, path: String, size: Integer, http_only: bool, secure: bool, same_site: Symbol, ?expiry: Integer) -> instance + attr_reader extensions: Hash[String, untyped] + def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, domain: String, path: String, size: Integer, http_only: bool, secure: bool, same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance end class CookieHeader < ::Selenium::WebDriver::BiDi::Serialization::Record diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs index dbe53a85f68b3..a0ec23ebdc033 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs @@ -140,7 +140,8 @@ module Selenium attr_reader proxy: untyped attr_reader unhandled_prompt_behavior: untyped attr_reader web_socket_url: untyped - def self.new: (accept_insecure_certs: bool, browser_name: String, browser_version: String, platform_name: String, set_window_rect: bool, user_agent: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?web_socket_url: String) -> instance + attr_reader extensions: Hash[String, untyped] + def self.new: (accept_insecure_certs: bool, browser_name: String, browser_version: String, platform_name: String, set_window_rect: bool, user_agent: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?web_socket_url: String, ?extensions: Hash[String, untyped]) -> instance end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs index a81b5c58e216d..a0270089446d4 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs @@ -26,7 +26,8 @@ module Selenium class PartitionKey < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader user_context: untyped attr_reader source_origin: untyped - def self.new: (?user_context: String, ?source_origin: String) -> instance + attr_reader extensions: Hash[String, untyped] + def self.new: (?user_context: String, ?source_origin: String, ?extensions: Hash[String, untyped]) -> instance end class CookieFilter < ::Selenium::WebDriver::BiDi::Serialization::Record diff --git a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs index b21395d5ee219..85cea3f491db9 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs @@ -107,6 +107,10 @@ module Selenium def self.as_json: (untyped value) -> untyped def as_json: (*untyped) -> Hash[String, untyped] + + private + + def merge_extensions!: (Hash[String, untyped] payload) -> void end end diff --git a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb index cd3c5e60e1451..c220fec5eb808 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb @@ -209,36 +209,56 @@ def valid_cookie_attrs end describe 'extensible records' do - it 'captures unknown keys and merges them back on serialization' do + # The spec sanctions extras on an extensible type, so they are captured silently — no + # undeclared-property warning, unlike a closed type. + it 'captures unknown keys silently and merges them back on serialization' do parsed = nil expect { parsed = Script::SharedReference.from_json('sharedId' => 's1', 'webdriverValue' => 42) } - .to have_warning(:bidi_undeclared_property) + .not_to have_warning(:bidi_undeclared_property) expect(parsed.shared_id).to eq('s1') expect(parsed.extensions).to eq('webdriverValue' => 42) expect(parsed.as_json).to eq('sharedId' => 's1', 'webdriverValue' => 42) end - # A re-sendable type (reachable from a command's params, e.g. a cookie filter) keeps - # unknown properties so a received-then-resent payload round-trips them. - it 'preserves an unknown key on a re-sendable type across a receive/re-send round trip' do + # An extensible type keeps unknown properties so a received-then-resent payload + # round-trips them. Extensibility alone is the trigger. + it 'preserves an unknown key on an extensible type across a receive/re-send round trip' do parsed = nil expect { parsed = Storage::CookieFilter.from_json('name' => 'sid', 'x-vendor' => 'keep-me') } - .to have_warning(:bidi_undeclared_property) + .not_to have_warning(:bidi_undeclared_property) expect(parsed.extensions).to eq('x-vendor' => 'keep-me') expect(parsed.as_json).to eq('name' => 'sid', 'x-vendor' => 'keep-me') end # network.Cookie is extensible but received-only (not reachable from any command's - # params), so preserveExtras is false: unknown keys are ignored, not stored/echoed. - it 'drops an unknown key on an extensible-but-received-only type on re-serialize' do - wire = Network::Cookie.new(**valid_cookie_attrs).as_json.merge('x-vendor' => 'drop-me') + # params); it still preserves and echoes an unknown key, because extensibility — not + # send-reachability — is what sanctions the extra field. + it 'preserves an unknown key on an extensible received-only type across re-serialize' do + wire = Network::Cookie.new(**valid_cookie_attrs).as_json.merge('x-vendor' => 'keep-me') parsed = nil - expect { parsed = Network::Cookie.from_json(wire) }.to have_warning(:bidi_undeclared_property) + expect { parsed = Network::Cookie.from_json(wire) }.not_to have_warning(:bidi_undeclared_property) - expect(parsed).not_to respond_to(:extensions) - expect(parsed.as_json).not_to include('x-vendor') + expect(parsed.extensions).to eq('x-vendor' => 'keep-me') + expect(parsed.as_json).to include('x-vendor' => 'keep-me') + end + + # An extra is by definition a field the spec does not declare, so an extensions key that + # collides with a declared wire key would silently clobber a typed, validated value on the + # wire. Reject it at the merge instead — the single gate every outbound path funnels through. + it 'rejects an extension that shadows a declared field on serialize' do + cookie = Network::Cookie.new(**valid_cookie_attrs, extensions: {'name' => 'clobber'}) + + expect { cookie.as_json }.to raise_error(ArgumentError, /extensions shadow declared fields: name/) + end + + # A symbol key stringifies to a declared wire key on serialization, so it must trip the same + # guard rather than ride onto the wire as a duplicate of the typed field. + it 'rejects a symbol-keyed extension that shadows a declared field on serialize' do + cookie = Network::Cookie.new(**valid_cookie_attrs, extensions: {name: 'clobber'}) + + expect { cookie.as_json }.to raise_error(ArgumentError, /extensions shadow declared fields: name/) end it 'warns on and drops an unknown key on a non-extensible type' do From 83f26a2bd8b7b0cc5b980ed2813a2e912710daa9 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 31 Jul 2026 22:00:12 -0500 Subject: [PATCH 18/56] [rb] add browser_family test guard and use it for chromium/safari families (#17854) --- rb/TESTING.md | 10 +++++++++ .../selenium/webdriver/action_builder_spec.rb | 20 ++++++++--------- .../selenium/webdriver/bidi/browser_spec.rb | 10 ++++----- .../webdriver/bidi/browsing_context_spec.rb | 8 +++---- .../selenium/webdriver/bidi/network_spec.rb | 22 +++++++++---------- .../webdriver/bidi/protocol_browser_spec.rb | 12 +++++----- .../bidi/protocol_browsing_context_spec.rb | 8 +++---- .../selenium/webdriver/bidi/script_spec.rb | 8 +++---- .../selenium/webdriver/bidi_spec.rb | 4 ++-- .../selenium/webdriver/devtools_spec.rb | 2 +- .../selenium/webdriver/driver_spec.rb | 10 ++++----- .../selenium/webdriver/element_spec.rb | 14 ++++++------ .../selenium/webdriver/fedcm_spec.rb | 4 ++-- .../selenium/webdriver/manager_spec.rb | 4 ++-- .../selenium/webdriver/navigation_spec.rb | 2 +- .../selenium/webdriver/network_spec.rb | 2 +- .../selenium/webdriver/safari/driver_spec.rb | 2 +- .../selenium/webdriver/select_spec.rb | 14 ++++++------ .../selenium/webdriver/shadow_root_spec.rb | 6 ++--- .../selenium/webdriver/spec_helper.rb | 1 + .../spec_support/test_environment.rb | 11 ++++++++++ .../selenium/webdriver/target_locator_spec.rb | 2 +- .../selenium/webdriver/timeout_spec.rb | 2 +- .../webdriver/virtual_authenticator_spec.rb | 2 +- .../selenium/webdriver/window_spec.rb | 8 +++---- 25 files changed, 105 insertions(+), 83 deletions(-) diff --git a/rb/TESTING.md b/rb/TESTING.md index ef33311cdde1a..710f9bc0b2583 100644 --- a/rb/TESTING.md +++ b/rb/TESTING.md @@ -130,6 +130,7 @@ Conditions are registered in [`spec/integration/selenium/webdriver/spec_helper.r | Condition | Values | | --- | --- | | `browser` | `:chrome`, `:firefox`, `:edge`, `:safari`, `:safari_preview`, `:ie` | +| `browser_family` | `:chromium` (Chrome/Edge), `:safari` (Safari/Safari Preview), otherwise the `browser` value (e.g. `:firefox`) | | `driver` | `:remote` | | `platform` | `:linux`, `:macosx`, `:windows` | | `headless` | `true`, `false` | @@ -138,6 +139,11 @@ Conditions are registered in [`spec/integration/selenium/webdriver/spec_helper.r | `rbe` | `true`, `false` (running on Remote Build Execution) | | `ci` | `:github`, `:jenkins`, `:appveyor` | +Prefer `browser_family` over listing every member browser when a guard applies to a whole engine +(e.g. `browser_family: :chromium` instead of `browser: %i[chrome edge]`). Use the exact `browser` +condition when a guard is specific to one channel, such as `browser: :safari_preview` or +`browser: :chrome, version: 'beta'`. + ### Guard Examples ```ruby @@ -149,6 +155,10 @@ end it 'does something', pending_unless: {browser: %i[chrome firefox], reason: 'Only implemented in Chrome/Firefox'} do end +# Pending on any Chromium-based browser (Chrome and Edge) +it 'does something', pending_if: {browser_family: :chromium, reason: 'Chromium bug'} do +end + # Skip on the stable Firefox channel it 'does something', skip_if: {browser: :firefox, version: 'stable', reason: 'https://bugzil.la/123'} do end diff --git a/rb/spec/integration/selenium/webdriver/action_builder_spec.rb b/rb/spec/integration/selenium/webdriver/action_builder_spec.rb index cfb107ddd9e3e..e36397f5fee5a 100644 --- a/rb/spec/integration/selenium/webdriver/action_builder_spec.rb +++ b/rb/spec/integration/selenium/webdriver/action_builder_spec.rb @@ -25,7 +25,7 @@ module WebDriver after { driver.action.clear_all_actions } describe '#send_keys' do - it 'sends keys to the active element', pending_if: {browser: %i[safari safari_preview]} do + it 'sends keys to the active element', pending_if: {browser_family: :safari} do driver.navigate.to url_for('bodyTypingTest.html') keylogger = driver.find_element(id: 'body_result') @@ -75,7 +75,7 @@ module WebDriver end describe 'multiple key presses' do - it 'sends keys with shift pressed', pending_if: {browser: %i[safari safari_preview]} do + it 'sends keys with shift pressed', pending_if: {browser_family: :safari} do driver.navigate.to url_for('javascriptPage.html') event_input = driver.find_element(id: 'theworks') @@ -127,7 +127,7 @@ module WebDriver expect(keylogger.text).to match(/keyup *$/) end - it 'releases pressed buttons', pending_if: [{browser: %i[safari safari_preview]}, + it 'releases pressed buttons', pending_if: [{browser_family: :safari}, {driver: :remote, browser: :ie}] do driver.navigate.to url_for('javascriptPage.html') @@ -157,7 +157,7 @@ module WebDriver end end - describe '#double_click', skip_if: {browser: %i[safari safari_preview]} do + describe '#double_click', skip_if: {browser_family: :safari} do # https://issues.chromium.org/issues/400087471 before { reset_driver! if GlobalTestEnv.rbe? && GlobalTestEnv.browser == :chrome } @@ -269,7 +269,7 @@ module WebDriver end describe 'pen stylus', pending_if: [{browser: :firefox, reason: 'Unknown pointerType'}, - {browser: :safari, reason: 'Some issues with resolution?'}] do + {browser_family: :safari, reason: 'Some issues with resolution?'}] do it 'sets pointer event properties' do driver.navigate.to url_for('pointerActionsPage.html') pointer_area = driver.find_element(id: 'pointerArea') @@ -312,7 +312,7 @@ module WebDriver describe '#scroll_to' do it 'scrolls to element', - skip_unless: {browser: %i[chrome edge], reason: 'incorrect MoveTargetOutOfBoundsError'} do + skip_unless: {browser_family: :chromium, reason: 'incorrect MoveTargetOutOfBoundsError'} do driver.navigate.to url_for('scrolling_tests/frame_with_nested_scrolling_frame_out_of_view.html') iframe = driver.find_element(tag_name: 'iframe') @@ -326,7 +326,7 @@ module WebDriver describe '#scroll_by' do it 'scrolls by given amount', - skip_unless: {browser: %i[chrome edge], reason: 'inconsistent behavior between versions'} do + skip_unless: {browser_family: :chromium, reason: 'inconsistent behavior between versions'} do driver.navigate.to url_for('scrolling_tests/frame_with_nested_scrolling_frame_out_of_view.html') footer = driver.find_element(tag_name: 'footer') delta_y = footer.rect.y.round @@ -340,7 +340,7 @@ module WebDriver describe '#scroll_from' do it 'scrolls from element by given amount', - skip_unless: {browser: %i[chrome edge], reason: 'incorrect MoveTargetOutOfBoundsError in Firefox'} do + skip_unless: {browser_family: :chromium, reason: 'incorrect MoveTargetOutOfBoundsError in Firefox'} do driver.navigate.to url_for('scrolling_tests/frame_with_nested_scrolling_frame_out_of_view.html') iframe = driver.find_element(tag_name: 'iframe') scroll_origin = WheelActions::ScrollOrigin.element(iframe) @@ -354,7 +354,7 @@ module WebDriver end it 'scrolls from element by given amount with offset', - skip_unless: {browser: %i[chrome edge], reason: 'incorrect MoveTargetOutOfBoundsError in Firefox'} do + skip_unless: {browser_family: :chromium, reason: 'incorrect MoveTargetOutOfBoundsError in Firefox'} do driver.navigate.to url_for('scrolling_tests/frame_with_nested_scrolling_frame_out_of_view.html') footer = driver.find_element(tag_name: 'footer') scroll_origin = WheelActions::ScrollOrigin.element(footer, 0, -50) @@ -378,7 +378,7 @@ module WebDriver }.to raise_error(Error::MoveTargetOutOfBoundsError) end - it 'scrolls by given amount with offset', flaky: {browser: %i[safari safari_preview], ci: :github} do + it 'scrolls by given amount with offset', flaky: {browser_family: :safari, ci: :github} do driver.navigate.to url_for('scrolling_tests/frame_with_nested_scrolling_frame.html') scroll_origin = WheelActions::ScrollOrigin.viewport(10, 10) diff --git a/rb/spec/integration/selenium/webdriver/bidi/browser_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/browser_spec.rb index 0af1eff8cdb4e..80d19e7b261af 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/browser_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/browser_spec.rb @@ -28,7 +28,7 @@ class BiDi let(:bidi) { driver.bidi } it 'creates a user context', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do browser = described_class.new(bidi) user_context = browser.create_user_context @@ -37,7 +37,7 @@ class BiDi end it 'gets user contexts', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do browser = described_class.new(bidi) created_context_id = browser.create_user_context['userContext'] @@ -56,7 +56,7 @@ class BiDi end it 'throws an error when removing the default user context', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do browser = described_class.new(bidi) expect { @@ -65,7 +65,7 @@ class BiDi end it 'throws an error when removing a non-existent user context', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do browser = described_class.new(bidi) expect { @@ -74,7 +74,7 @@ class BiDi end it 'gets windows', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do browser = described_class.new(bidi) windows = browser.windows diff --git a/rb/spec/integration/selenium/webdriver/bidi/browsing_context_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/browsing_context_spec.rb index 5d235667a48e4..85eeac3cc5713 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/browsing_context_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/browsing_context_spec.rb @@ -81,7 +81,7 @@ class BiDi end it 'accepts users prompts without text', - pending_if: {browser: %i[edge chrome], + pending_if: {browser_family: :chromium, reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do browsing_context = described_class.new(bridge) @@ -96,7 +96,7 @@ class BiDi end it 'accepts users prompts with text', - pending_if: {browser: %i[edge chrome], + pending_if: {browser_family: :chromium, reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do browsing_context = described_class.new(bridge) driver.navigate.to url_for('alerts.html') @@ -110,7 +110,7 @@ class BiDi end it 'rejects users prompts', - pending_if: {browser: %i[edge chrome], + pending_if: {browser_family: :chromium, reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do browsing_context = described_class.new(bridge) driver.navigate.to url_for('alerts.html') @@ -125,7 +125,7 @@ class BiDi end it 'activates a browser context', - pending_if: {browser: %i[safari safari_preview], reason: 'Safari does not focus the activated context'} do + pending_if: {browser_family: :safari, reason: 'Safari does not focus the activated context'} do browsing_context = described_class.new(bridge) browsing_context.create diff --git a/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb index ec793e56ded79..691c0b4504a9e 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb @@ -26,7 +26,7 @@ class BiDi after { |example| reset_driver!(example: example) } it 'adds an intercept', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) intercept = network.add_intercept(phases: [described_class::PHASES[:before_request]]) @@ -34,7 +34,7 @@ class BiDi end it 'adds an intercept with a default pattern type', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) pattern = 'http://localhost:4444/formPage.html' @@ -43,7 +43,7 @@ class BiDi end it 'adds an intercept with a url pattern', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) pattern = 'http://localhost:4444/formPage.html' @@ -54,7 +54,7 @@ class BiDi end it 'removes an intercept', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) intercept = network.add_intercept(phases: [described_class::PHASES[:before_request]]) @@ -62,7 +62,7 @@ class BiDi end it 'continues with auth', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do username = SpecSupport::RackServer::TestApp::BASIC_AUTH_CREDENTIALS.first password = SpecSupport::RackServer::TestApp::BASIC_AUTH_CREDENTIALS.last @@ -79,7 +79,7 @@ class BiDi end it 'continues without auth', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) network.add_intercept(phases: [described_class::PHASES[:auth_required]]) @@ -92,7 +92,7 @@ class BiDi end it 'cancels auth', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) network.add_intercept(phases: [described_class::PHASES[:auth_required]]) @@ -106,7 +106,7 @@ class BiDi end it 'continues request', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) network.add_intercept(phases: [described_class::PHASES[:before_request]]) @@ -120,7 +120,7 @@ class BiDi end it 'fails request', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) network.add_intercept(phases: [described_class::PHASES[:before_request]]) @@ -133,7 +133,7 @@ class BiDi end it 'continues response', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'} do network = described_class.new(driver.bidi) network.add_intercept(phases: [described_class::PHASES[:response_started]]) @@ -148,7 +148,7 @@ class BiDi it 'provides response', pending_if: [{browser: :firefox, reason: 'https://github.com/w3c/webdriver-bidi/issues/747'}, - {browser: %i[safari safari_preview], + {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'}] do network = described_class.new(driver.bidi) network.add_intercept(phases: [described_class::PHASES[:response_started]]) diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol_browser_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol_browser_spec.rb index 31bfdbb75b77d..782bc2a5cc2f2 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/protocol_browser_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol_browser_spec.rb @@ -30,7 +30,7 @@ module Protocol let(:browser) { described_class.new(driver) } it 'creates a user context', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do user_context = browser.create_user_context @@ -38,7 +38,7 @@ module Protocol end it 'gets user contexts', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do created = browser.create_user_context.user_context all_ids = browser.get_user_contexts.user_contexts.map(&:user_context) @@ -47,7 +47,7 @@ module Protocol end it 'removes a user context', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do to_remove = browser.create_user_context.user_context browser.remove_user_context(user_context: to_remove) @@ -57,7 +57,7 @@ module Protocol end it 'throws an error when removing the default user context', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do expect { browser.remove_user_context(user_context: 'default') @@ -65,7 +65,7 @@ module Protocol end it 'throws an error when removing a non-existent user context', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do expect { browser.remove_user_context(user_context: 'fake_context') @@ -73,7 +73,7 @@ module Protocol end it 'gets client windows', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not support BiDi user contexts or getClientWindows'} do windows = browser.get_client_windows.client_windows diff --git a/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb index 29bfec7de3f5a..879bb0511ff1e 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/protocol_browsing_context_spec.rb @@ -85,7 +85,7 @@ module Protocol end it 'accepts user prompts without text', - pending_if: {browser: %i[edge chrome], + pending_if: {browser_family: :chromium, reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do driver.navigate.to url_for('alerts.html') driver.find_element(id: 'alert').click @@ -97,7 +97,7 @@ module Protocol end it 'accepts user prompts with text', - pending_if: {browser: %i[edge chrome], + pending_if: {browser_family: :chromium, reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do driver.navigate.to url_for('alerts.html') driver.find_element(id: 'prompt').click @@ -109,7 +109,7 @@ module Protocol end it 'rejects user prompts', - pending_if: {browser: %i[edge chrome], + pending_if: {browser_family: :chromium, reason: 'https://github.com/GoogleChromeLabs/chromium-bidi/issues/3281'} do driver.navigate.to url_for('alerts.html') driver.find_element(id: 'alert').click @@ -121,7 +121,7 @@ module Protocol end it 'activates a browser context', - pending_if: {browser: %i[safari safari_preview], reason: 'Safari does not focus the activated context'} do + pending_if: {browser_family: :safari, reason: 'Safari does not focus the activated context'} do window = driver.window_handle browsing_context.create(type: :tab) diff --git a/rb/spec/integration/selenium/webdriver/bidi/script_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/script_spec.rb index 2cb8a498514d6..8f3942a40b750 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/script_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/script_spec.rb @@ -46,7 +46,7 @@ def a_stack_frame(**options) end it 'logs console messages', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not deliver BiDi log entries for console messages'} do driver.navigate.to url_for('bidi/logEntryAdded.html') @@ -83,7 +83,7 @@ def a_stack_frame(**options) end it 'logs multiple console messages', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not deliver BiDi log entries for console messages'} do driver.navigate.to url_for('bidi/logEntryAdded.html') @@ -99,7 +99,7 @@ def a_stack_frame(**options) end it 'removes console message handler', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not deliver BiDi log entries for console messages'} do driver.navigate.to url_for('bidi/logEntryAdded.html') @@ -120,7 +120,7 @@ def a_stack_frame(**options) end it 'logs javascript errors', - pending_if: {browser: %i[safari safari_preview], + pending_if: {browser_family: :safari, reason: 'Safari does not deliver BiDi log entries for console messages'} do driver.navigate.to url_for('bidi/logEntryAdded.html') diff --git a/rb/spec/integration/selenium/webdriver/bidi_spec.rb b/rb/spec/integration/selenium/webdriver/bidi_spec.rb index f19e3496f1b99..9c65ad12e2491 100644 --- a/rb/spec/integration/selenium/webdriver/bidi_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi_spec.rb @@ -37,7 +37,7 @@ module WebDriver end it 'does not close BiDi session if at least one window is opened', - pending_if: {browser: %i[safari safari_preview], reason: 'Safari always reports session.status ready: true'} do + pending_if: {browser_family: :safari, reason: 'Safari always reports session.status ready: true'} do status = driver.bidi.session.status expect(status.ready).to be false expect(status.message).to be_a String @@ -54,7 +54,7 @@ module WebDriver end it 'closes BiDi session if last window is closed', - pending_if: {browser: %i[safari safari_preview], reason: 'Safari always reports session.status ready: true'} do + pending_if: {browser_family: :safari, reason: 'Safari always reports session.status ready: true'} do status = driver.bidi.session.status expect(status.ready).to be false expect(status.message).to be_a String diff --git a/rb/spec/integration/selenium/webdriver/devtools_spec.rb b/rb/spec/integration/selenium/webdriver/devtools_spec.rb index ad9c6b3bf6958..dee5b5b1833ce 100644 --- a/rb/spec/integration/selenium/webdriver/devtools_spec.rb +++ b/rb/spec/integration/selenium/webdriver/devtools_spec.rb @@ -22,7 +22,7 @@ module Selenium module WebDriver describe DevTools, skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, - {browser: %i[chrome edge]}] do + {browser_family: :chromium}] do after { |example| reset_driver!(example: example) } it 'sends commands' do diff --git a/rb/spec/integration/selenium/webdriver/driver_spec.rb b/rb/spec/integration/selenium/webdriver/driver_spec.rb index 2e08d6d85eb7e..5e2562d708c7e 100644 --- a/rb/spec/integration/selenium/webdriver/driver_spec.rb +++ b/rb/spec/integration/selenium/webdriver/driver_spec.rb @@ -25,12 +25,12 @@ module WebDriver after { reset_driver! if GlobalTestEnv.rbe? && GlobalTestEnv.browser == :chrome } it_behaves_like 'driver that can be started concurrently', skip_if: [ - {browser: %i[safari safari_preview]}, + {browser_family: :safari}, {browser: :firefox, reason: 'https://github.com/SeleniumHQ/selenium/issues/15451'}, {driver: :remote, rbe: true, reason: 'Cannot start 2+ drivers at once.'} ] - it 'creates default capabilities', skip_if: {browser: %i[safari safari_preview]} do + it 'creates default capabilities', skip_if: {browser_family: :safari} do reset_driver! do |driver| caps = driver.capabilities expect(caps.proxy).to be_nil @@ -147,7 +147,7 @@ module WebDriver end it 'raises if invalid locator', - skip_if: {browser: %i[safari safari_preview], reason: 'Safari TimeoutError'} do + skip_if: {browser_family: :safari, reason: 'Safari TimeoutError'} do driver.navigate.to url_for('xhtmlTest.html') expect { driver.find_element(xpath: '*?//-') @@ -196,7 +196,7 @@ module WebDriver expect(near.map { |e| e.attribute('id') }).to eq(%w[topRight bottomRight center top bottom]) end - it 'finds near another within custom distance', pending_if: {browser: %i[safari safari_preview]} do + it 'finds near another within custom distance', pending_if: {browser_family: :safari} do driver.navigate.to url_for('relative_locators.html') near = driver.find_elements(relative: {tag_name: 'td', near: {id: 'right', distance: 100}}) @@ -353,7 +353,7 @@ module WebDriver end # Safari raises TimeoutError instead - it 'times out if the callback is not invoked', pending_if: {browser: %i[safari safari_preview]} do + it 'times out if the callback is not invoked', pending_if: {browser_family: :safari} do expect { # Script is expected to be async and explicitly callback, so this should timeout. driver.execute_async_script 'return 1 + 2;' diff --git a/rb/spec/integration/selenium/webdriver/element_spec.rb b/rb/spec/integration/selenium/webdriver/element_spec.rb index 5704ff48426fa..28e6112546c7b 100644 --- a/rb/spec/integration/selenium/webdriver/element_spec.rb +++ b/rb/spec/integration/selenium/webdriver/element_spec.rb @@ -29,14 +29,14 @@ module WebDriver end # Safari returns "click intercepted" error instead of "element click intercepted" - it 'raises if different element receives click', pending_if: {browser: %i[safari safari_preview]} do + it 'raises if different element receives click', pending_if: {browser_family: :safari} do open_file 'click_tests/overlapping_elements.html' element = wait_for_element(id: 'contents', timeout: 10) expect { element.click }.to raise_error(Error::ElementClickInterceptedError) end # Safari returns "click intercepted" error instead of "element click intercepted" - it 'raises if element is partially covered', pending_if: {browser: %i[safari safari_preview]} do + it 'raises if element is partially covered', pending_if: {browser_family: :safari} do open_file 'click_tests/overlapping_elements.html' element = wait_for_element(id: 'other_contents') expect { element.click }.to raise_error(Error::ElementClickInterceptedError) @@ -183,7 +183,7 @@ module WebDriver let(:element) { wait_for_element(id: 'checkedchecky') } let(:prop_or_attr) { 'checked' } - it '#dom_attribute returns String', pending_if: {browser: :safari} do + it '#dom_attribute returns String', pending_if: {browser_family: :safari} do expect(element.dom_attribute(prop_or_attr)).to eq 'true' end @@ -195,7 +195,7 @@ module WebDriver expect(element.attribute(prop_or_attr)).to eq 'true' end - it '#dom_attribute does not update after click', pending_if: {browser: :safari} do + it '#dom_attribute does not update after click', pending_if: {browser_family: :safari} do element.click expect(element.dom_attribute(prop_or_attr)).to eq 'true' end @@ -318,7 +318,7 @@ module WebDriver it '#property returns object', pending_if: [{browser: :firefox, reason: 'https://github.com/mozilla/geckodriver/issues/1846'}, - {browser: :safari}] do + {browser_family: :safari}] do expect(element.property(prop_or_attr)).to eq %w[width height] end @@ -348,7 +348,7 @@ module WebDriver let(:element) { wait_for_element(name: 'readonly') } let(:prop_or_attr) { 'readonly' } - it '#dom_attribute returns a String', pending_if: {browser: :safari} do + it '#dom_attribute returns a String', pending_if: {browser_family: :safari} do expect(element.dom_attribute(prop_or_attr)).to eq 'true' end @@ -368,7 +368,7 @@ module WebDriver it '#dom_attribute returns a String', pending_if: [{browser: :firefox, reason: 'https://github.com/mozilla/geckodriver/issues/1850'}, - {browser: :safari}] do + {browser_family: :safari}] do expect(element.dom_attribute(prop_or_attr)).to eq 'true' end diff --git a/rb/spec/integration/selenium/webdriver/fedcm_spec.rb b/rb/spec/integration/selenium/webdriver/fedcm_spec.rb index 1046803cb38e9..2a89d82f66cb7 100644 --- a/rb/spec/integration/selenium/webdriver/fedcm_spec.rb +++ b/rb/spec/integration/selenium/webdriver/fedcm_spec.rb @@ -23,7 +23,7 @@ module Selenium module WebDriver module FedCM describe FedCM, - skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, {browser: %i[chrome edge]}] do + skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, {browser_family: :chromium}] do let(:dialog) { driver.fedcm_dialog } before { driver.get url_for('fedcm/fedcm.html') } @@ -67,7 +67,7 @@ module FedCM expect(dialog.select_account(1)).to be_nil end - it 'clicks the dialog', pending_if: {browser: %i[chrome edge], + it 'clicks the dialog', pending_if: {browser_family: :chromium, reason: "error: 'Use another account' not supported for this IDP"} do expect(dialog.click).to be_nil end diff --git a/rb/spec/integration/selenium/webdriver/manager_spec.rb b/rb/spec/integration/selenium/webdriver/manager_spec.rb index 6ef26b4c7b833..d3f44749e60a8 100644 --- a/rb/spec/integration/selenium/webdriver/manager_spec.rb +++ b/rb/spec/integration/selenium/webdriver/manager_spec.rb @@ -79,7 +79,7 @@ module WebDriver expect(driver.manage.cookie_named('domain')[:domain]).to eq('.saucelabs.com') end - it 'does not allow setting on a different domain', pending_if: {browser: %i[safari safari_preview]} do + it 'does not allow setting on a different domain', pending_if: {browser_family: :safari} do expect { driver.manage.add_cookie name: 'domain', value: 'different', @@ -163,7 +163,7 @@ module WebDriver end it 'does not allow adding with value None when secure is false', - pending_if: [{browser: %i[safari safari_preview]}] do + pending_if: [{browser_family: :safari}] do expect { driver.manage.add_cookie name: 'samesite', value: 'none-insecure', diff --git a/rb/spec/integration/selenium/webdriver/navigation_spec.rb b/rb/spec/integration/selenium/webdriver/navigation_spec.rb index 1b53c0c443988..f68aa66294116 100644 --- a/rb/spec/integration/selenium/webdriver/navigation_spec.rb +++ b/rb/spec/integration/selenium/webdriver/navigation_spec.rb @@ -22,7 +22,7 @@ module Selenium module WebDriver describe Navigation do it 'navigates back and forward', - pending_if: {browser: %i[safari safari_preview], bidi: true, + pending_if: {browser_family: :safari, bidi: true, reason: 'Safari does not support BiDi browsingContext.traverseHistory'} do form_title = 'We Leave From Here' result_title = 'We Arrive Here' diff --git a/rb/spec/integration/selenium/webdriver/network_spec.rb b/rb/spec/integration/selenium/webdriver/network_spec.rb index dfe09fc85232f..14feb11a75815 100644 --- a/rb/spec/integration/selenium/webdriver/network_spec.rb +++ b/rb/spec/integration/selenium/webdriver/network_spec.rb @@ -21,7 +21,7 @@ module Selenium module WebDriver - describe Network, pending_if: {browser: %i[safari safari_preview], + describe Network, pending_if: {browser_family: :safari, reason: 'Safari does not support the BiDi network domain'}, skip_unless: {bidi: true, reason: 'only executed when bidi is enabled'} do let(:username) { SpecSupport::RackServer::TestApp::BASIC_AUTH_CREDENTIALS.first } diff --git a/rb/spec/integration/selenium/webdriver/safari/driver_spec.rb b/rb/spec/integration/selenium/webdriver/safari/driver_spec.rb index 8de4dc6eb561b..321915333d975 100644 --- a/rb/spec/integration/selenium/webdriver/safari/driver_spec.rb +++ b/rb/spec/integration/selenium/webdriver/safari/driver_spec.rb @@ -23,7 +23,7 @@ module Selenium module WebDriver module Safari describe Driver, skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, - {browser: %i[safari safari_preview]}] do + {browser_family: :safari}] do it 'gets and sets permissions' do driver.permissions = {'getUserMedia' => false} expect(driver.permissions).to eq('getUserMedia' => false) diff --git a/rb/spec/integration/selenium/webdriver/select_spec.rb b/rb/spec/integration/selenium/webdriver/select_spec.rb index 617efd4a588f2..2d5b9c1097a2d 100644 --- a/rb/spec/integration/selenium/webdriver/select_spec.rb +++ b/rb/spec/integration/selenium/webdriver/select_spec.rb @@ -111,7 +111,7 @@ module Support end it 'errors when option disabled', - skip_if: {browser: :safari, reason: 'Safari raises no exception with disabled'} do + skip_if: {browser_family: :safari, reason: 'Safari raises no exception with disabled'} do expect { multi_disabled.select_by(:text, 'Disabled') }.to raise_exception(Error::UnsupportedOperationError) @@ -140,7 +140,7 @@ module Support end it 'errors when option disabled', - skip_if: {browser: :safari, reason: 'Safari raises no exception with disabled'} do + skip_if: {browser_family: :safari, reason: 'Safari raises no exception with disabled'} do expect { multi_disabled.select_by(:index, 1) }.to raise_exception(Error::UnsupportedOperationError) end @@ -167,7 +167,7 @@ module Support end it 'errors when option disabled', - skip_if: {browser: :safari, reason: 'Safari raises no exception with disabled'} do + skip_if: {browser_family: :safari, reason: 'Safari raises no exception with disabled'} do expect { multi_disabled.select_by(:value, 'disabled') }.to raise_exception(Error::UnsupportedOperationError) @@ -202,7 +202,7 @@ module Support end it 'errors when option disabled', - skip_if: {browser: :safari, reason: 'Safari raises no exception with disabled'} do + skip_if: {browser_family: :safari, reason: 'Safari raises no exception with disabled'} do expect { single_disabled.select_by(:text, 'Disabled') }.to raise_exception(Error::UnsupportedOperationError) @@ -229,7 +229,7 @@ module Support end it 'errors when option disabled', - skip_if: {browser: :safari, reason: 'Safari raises no exception with disabled'} do + skip_if: {browser_family: :safari, reason: 'Safari raises no exception with disabled'} do expect { single_disabled.select_by(:index, 1) }.to raise_exception(Error::UnsupportedOperationError) end @@ -254,7 +254,7 @@ module Support end it 'errors when option disabled', - skip_if: {browser: :safari, reason: 'Safari raises no exception with disabled'} do + skip_if: {browser_family: :safari, reason: 'Safari raises no exception with disabled'} do expect { single_disabled.select_by(:value, 'disabled') }.to raise_exception(Error::UnsupportedOperationError) @@ -353,7 +353,7 @@ module Support end it 'raises exception if select contains disabled options', - skip_if: {browser: :safari, reason: 'Safari raises no exception with disabled'} do + skip_if: {browser_family: :safari, reason: 'Safari raises no exception with disabled'} do select = described_class.new(driver.find_element(name: 'multi_disabled')) expect { select.select_all }.to raise_exception(Error::UnsupportedOperationError) diff --git a/rb/spec/integration/selenium/webdriver/shadow_root_spec.rb b/rb/spec/integration/selenium/webdriver/shadow_root_spec.rb index 14957fbf1fd7c..560393b9f88d4 100644 --- a/rb/spec/integration/selenium/webdriver/shadow_root_spec.rb +++ b/rb/spec/integration/selenium/webdriver/shadow_root_spec.rb @@ -32,13 +32,13 @@ module WebDriver expect(shadow_root).to be_a described_class end - it 'raises error if no shadow root', skip_if: {browser: :safari, reason: 'NoMethodError'} do + it 'raises error if no shadow root', skip_if: {browser_family: :safari, reason: 'NoMethodError'} do driver.navigate.to url_for('simpleTest.html') div = driver.find_element(css: 'div') expect { div.shadow_root }.to raise_error(Error::NoSuchShadowRootError) end - it 'raises error if the shadow root is detached', skip_if: {browser: :safari, reason: 'NoMethodError'} do + it 'raises error if the shadow root is detached', skip_if: {browser_family: :safari, reason: 'NoMethodError'} do driver.navigate.to url_for('simpleTest.html') div = driver.find_element(css: 'div') driver.execute_script('arguments[0].attachShadow({ mode: "open" });', div) @@ -48,7 +48,7 @@ module WebDriver end it 'gets shadow root from script', - skip_if: {browser: :safari, reason: 'returns correct node, but references shadow root as a element'} do + skip_if: {browser_family: :safari, reason: 'returns correct node, but references shadow root as a element'} do shadow_root = custom_element.shadow_root execute_shadow_root = driver.execute_script('return arguments[0].shadowRoot;', custom_element) expect(execute_shadow_root).to eq shadow_root diff --git a/rb/spec/integration/selenium/webdriver/spec_helper.rb b/rb/spec/integration/selenium/webdriver/spec_helper.rb index 490483fbdc289..f921d2298192d 100644 --- a/rb/spec/integration/selenium/webdriver/spec_helper.rb +++ b/rb/spec/integration/selenium/webdriver/spec_helper.rb @@ -73,6 +73,7 @@ def example_finished(notification) guards = WebDriver::Support::Guards.new(example, bug_tracker: 'https://github.com/SeleniumHQ/selenium/issues') guards.add_condition(:driver, GlobalTestEnv.driver) guards.add_condition(:browser, GlobalTestEnv.browser) + guards.add_condition(:browser_family, GlobalTestEnv.browser_family) guards.add_condition(:ci, WebDriver::Platform.ci) guards.add_condition(:platform, WebDriver::Platform.os) guards.add_condition(:headless, !ENV['HEADLESS'].nil?) diff --git a/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb b/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb index 7ae354e7bf356..6bb521997d4f2 100644 --- a/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb +++ b/rb/spec/integration/selenium/webdriver/spec_support/test_environment.rb @@ -66,6 +66,17 @@ def browser end end + def browser_family + case browser + when :chrome, :edge + :chromium + when :safari, :safari_preview + :safari + else + browser + end + end + def browser_version ENV.fetch('WD_BROWSER_VERSION', 'stable') end diff --git a/rb/spec/integration/selenium/webdriver/target_locator_spec.rb b/rb/spec/integration/selenium/webdriver/target_locator_spec.rb index ea7abb27ce6c7..c9368def6ffaa 100644 --- a/rb/spec/integration/selenium/webdriver/target_locator_spec.rb +++ b/rb/spec/integration/selenium/webdriver/target_locator_spec.rb @@ -168,7 +168,7 @@ module WebDriver end end - context 'with more than two windows', pending_if: [{browser: %i[safari safari_preview]}, + context 'with more than two windows', pending_if: [{browser_family: :safari}, {driver: :remote, browser: :ie}] do it 'closes current window via block' do driver.navigate.to url_for('xhtmlTest.html') diff --git a/rb/spec/integration/selenium/webdriver/timeout_spec.rb b/rb/spec/integration/selenium/webdriver/timeout_spec.rb index af73df69bff19..8435aac0da091 100644 --- a/rb/spec/integration/selenium/webdriver/timeout_spec.rb +++ b/rb/spec/integration/selenium/webdriver/timeout_spec.rb @@ -86,7 +86,7 @@ module WebDriver expect { driver.navigate.to url_for('sleep?time=3') }.to raise_error(WebDriver::Error::TimeoutError) end - it 'times out if page takes too long to load after click', pending_if: {browser: %i[safari safari_preview]} do + it 'times out if page takes too long to load after click', pending_if: {browser_family: :safari} do driver.navigate.to url_for('page_with_link_to_slow_loading_page.html') expect { diff --git a/rb/spec/integration/selenium/webdriver/virtual_authenticator_spec.rb b/rb/spec/integration/selenium/webdriver/virtual_authenticator_spec.rb index 0f748aca08f86..1b8ac71d462bd 100644 --- a/rb/spec/integration/selenium/webdriver/virtual_authenticator_spec.rb +++ b/rb/spec/integration/selenium/webdriver/virtual_authenticator_spec.rb @@ -22,7 +22,7 @@ module Selenium module WebDriver describe VirtualAuthenticator, skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, - {browser: %i[chrome edge]}] do + {browser_family: :chromium}] do # A pkcs#8 encoded unencrypted EC256 private key as a base64url string. let(:pkcs8_private_key) do 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg8_zMDQDYAxlU-Q' \ diff --git a/rb/spec/integration/selenium/webdriver/window_spec.rb b/rb/spec/integration/selenium/webdriver/window_spec.rb index 18660881f544f..bbb6e7688879d 100644 --- a/rb/spec/integration/selenium/webdriver/window_spec.rb +++ b/rb/spec/integration/selenium/webdriver/window_spec.rb @@ -113,8 +113,8 @@ module WebDriver expect(new_size.height).to be > old_size.height end - it 'can make window full screen', pending_if: {browser: %i[chrome edge], headless: true}, - skip_if: {browser: %i[safari safari_preview], ci: :github, + it 'can make window full screen', pending_if: {browser_family: :chromium, headless: true}, + skip_if: {browser_family: :safari, ci: :github, reason: 'Net::ReadTimeout'} do window.size = old_size = Dimension.new(700, 700) @@ -126,8 +126,8 @@ module WebDriver expect(new_size.height).to be > old_size.height end - it 'can minimize the window', flaky: {browser: %i[chrome edge], platform: :macosx, ci: :github}, - pending_if: [{browser: %i[chrome edge], headless: true}] do + it 'can minimize the window', flaky: {browser_family: :chromium, platform: :macosx, ci: :github}, + pending_if: [{browser_family: :chromium, headless: true}] do window.minimize expect { wait.until { driver.execute_script('return document.hidden;') } From 9912e65d9af0d59fc404f73cb6efd5ccda3c258d Mon Sep 17 00:00:00 2001 From: Nikolay Borisenko <22616990+nvborisenko@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:54:08 +0300 Subject: [PATCH 19/56] [dotnet] [bidi] Don't warn if there are no subscribers (#17857) --- dotnet/src/webdriver/BiDi/Broker.cs | 11 +++--- dotnet/src/webdriver/BiDi/EventDispatcher.cs | 36 +++++++++----------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/dotnet/src/webdriver/BiDi/Broker.cs b/dotnet/src/webdriver/BiDi/Broker.cs index 8ab5d7ea66aac..a38c1f881f39a 100644 --- a/dotnet/src/webdriver/BiDi/Broker.cs +++ b/dotnet/src/webdriver/BiDi/Broker.cs @@ -307,12 +307,13 @@ private void ProcessReceivedMessage(ReadOnlySpan data) case TypeEvent: if (method is null) throw new BiDiException($"The remote end responded with 'event' message type, but missed required 'method' property. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}"); - if (!_bidi.EventDispatcher.TryDeserializeAndDispatch(method, ref paramsReader, additionalMessageData)) + try { - if (_logger.IsEnabled(LogEventLevel.Warn)) - { - _logger.Warn($"Received BiDi event with method '{method}', but no event type mapping was found. Event will be ignored. Message content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}"); - } + _bidi.EventDispatcher.DeserializeAndDispatch(method, ref paramsReader, additionalMessageData); + } + catch (Exception ex) + { + _logger.Warn($"Failed to deserialize and dispatch '{method}' event: {ex}.\nMessage content: {System.Text.Encoding.UTF8.GetString(data.ToArray())}"); } break; diff --git a/dotnet/src/webdriver/BiDi/EventDispatcher.cs b/dotnet/src/webdriver/BiDi/EventDispatcher.cs index 25927e21968ad..cabcc7d050a6d 100644 --- a/dotnet/src/webdriver/BiDi/EventDispatcher.cs +++ b/dotnet/src/webdriver/BiDi/EventDispatcher.cs @@ -111,35 +111,31 @@ public async Task> SubscribeReaderAsync( return (EventStream)subscription; } - public bool TryDeserializeAndDispatch(string method, ref Utf8JsonReader paramsReader, Dictionary? additionalMessageData = null) + public void DeserializeAndDispatch(string method, ref Utf8JsonReader paramsReader, Dictionary? additionalMessageData = null) { - if (!_events.TryGetValue(method, out var slot)) + if (_events.TryGetValue(method, out var slot)) { - return false; - } - - var eventArgs = (EventArgs)(JsonSerializer.Deserialize(ref paramsReader, slot.JsonTypeInfo) + var eventArgs = (EventArgs)(JsonSerializer.Deserialize(ref paramsReader, slot.JsonTypeInfo) ?? throw new BiDiException("Remote end returned null event args in the 'params' property.")); - eventArgs.BiDi = _bidi; + eventArgs.BiDi = _bidi; - if (additionalMessageData is not null) - eventArgs.AdditionalMessageData = AdditionalData.FromDictionary(additionalMessageData); + if (additionalMessageData is not null) + eventArgs.AdditionalMessageData = AdditionalData.FromDictionary(additionalMessageData); - foreach (var subscription in slot.GetSnapshot()) - { - try - { - subscription.Deliver(eventArgs); - } - catch (Exception ex) + foreach (var subscription in slot.GetSnapshot()) { - _logger.Error($"Failed to deliver '{method}' event to subscription: {ex.Message}"); - subscription.Complete(ex); + try + { + subscription.Deliver(eventArgs); + } + catch (Exception ex) + { + _logger.Error($"Failed to deliver '{method}' event to subscription: {ex.Message}"); + subscription.Complete(ex); + } } } - - return true; } public async Task CompleteAllAsync(Exception? error) From 89ad5bbf9c9480c48e4b1e6816499068c828cccb Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 14:57:19 -0500 Subject: [PATCH 20/56] [rb] raise typed WebDriver errors for BiDi from a generated error-code map (#17855) * [rb] raise typed WebDriver errors for BiDi from a generated error-code map * [rb] declare RBS only for BiDi-only error classes to avoid redeclaring classic ones --- rb/lib/selenium/webdriver/BUILD.bazel | 4 ++ rb/lib/selenium/webdriver/bidi/error.rb | 44 ++++++++++++ rb/lib/selenium/webdriver/bidi/protocol.rb | 3 + .../webdriver/bidi/protocol/error_code.rb | 66 +++++++++++++++++ .../webdriver/bidi/support/bidi_generate.rb | 46 ++++++++++++ .../webdriver/bidi/support/check_generated.rb | 16 +++-- .../bidi/support/templates/error_code.rb.erb | 36 ++++++++++ .../bidi/support/templates/error_code.rbs.erb | 39 +++++++++++ rb/lib/selenium/webdriver/bidi/transport.rb | 6 +- rb/sig/lib/selenium/webdriver/bidi/error.rbs | 28 ++++++++ .../webdriver/bidi/protocol/error_code.rbs | 70 +++++++++++++++++++ .../lib/selenium/webdriver/bidi/transport.rbs | 2 +- .../selenium/webdriver/bidi/transport_spec.rb | 20 +++++- 13 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 rb/lib/selenium/webdriver/bidi/error.rb create mode 100644 rb/lib/selenium/webdriver/bidi/protocol/error_code.rb create mode 100644 rb/lib/selenium/webdriver/bidi/support/templates/error_code.rb.erb create mode 100644 rb/lib/selenium/webdriver/bidi/support/templates/error_code.rbs.erb create mode 100644 rb/sig/lib/selenium/webdriver/bidi/error.rbs create mode 100644 rb/sig/lib/selenium/webdriver/bidi/protocol/error_code.rbs diff --git a/rb/lib/selenium/webdriver/BUILD.bazel b/rb/lib/selenium/webdriver/BUILD.bazel index 9d2bee24693cc..5873aa68b5336 100644 --- a/rb/lib/selenium/webdriver/BUILD.bazel +++ b/rb/lib/selenium/webdriver/BUILD.bazel @@ -34,8 +34,11 @@ rb_binary( "rb/lib/selenium/webdriver/bidi/protocol", ], data = [ + "bidi/support/templates/error_code.rb.erb", + "bidi/support/templates/error_code.rbs.erb", "bidi/support/templates/module.rb.erb", "bidi/support/templates/module.rbs.erb", + "common/error.rb", "//javascript/selenium-webdriver:create-bidi-src_schema", "//scripts:generated_note_template.txt", ], @@ -53,6 +56,7 @@ rb_test( args = ["$(rootpath //javascript/selenium-webdriver:create-bidi-src_schema)"], data = [ "bidi/support/bidi_generate.rb", + "bidi/support/templates/error_code.rb.erb", "bidi/support/templates/module.rb.erb", "//javascript/selenium-webdriver:create-bidi-src_schema", "//scripts:generated_note_template.txt", diff --git a/rb/lib/selenium/webdriver/bidi/error.rb b/rb/lib/selenium/webdriver/bidi/error.rb new file mode 100644 index 0000000000000..9331efafd2d44 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/error.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'selenium/webdriver/common/error' +require 'selenium/webdriver/bidi/protocol/error_code' + +module Selenium + module WebDriver + module Error + # Register each BiDi-only code as a WebDriverError subclass; shared codes keep their classic class. + BiDi::Protocol::ErrorCode::CLASS_NAMES.each_value do |name| + const_set(name, Class.new(WebDriverError)) unless const_defined?(name, false) + end + end + + class BiDi + module Protocol + module ErrorCode + # The exception class for a wire error code, or WebDriverError for an unknown one. + def self.for(code) + name = code && CLASS_NAMES[code] + name ? Error.const_get(name) : Error::WebDriverError + end + end + end + end + end +end diff --git a/rb/lib/selenium/webdriver/bidi/protocol.rb b/rb/lib/selenium/webdriver/bidi/protocol.rb index 425bccdd4a93e..550e7e2e757ac 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol.rb @@ -20,9 +20,12 @@ # serialization must load first (it defines the Serialization runtime the generated # classes build on), then the Domain base the generated classes subclass. Add a require # below when a new BiDi domain is generated. +require 'selenium/webdriver/common/error' require 'selenium/webdriver/bidi/serialization' require 'selenium/webdriver/bidi/transport' require 'selenium/webdriver/bidi/protocol/domain' +require 'selenium/webdriver/bidi/protocol/error_code' +require 'selenium/webdriver/bidi/error' require 'selenium/webdriver/bidi/protocol/bluetooth' require 'selenium/webdriver/bidi/protocol/browser' require 'selenium/webdriver/bidi/protocol/browsing_context' diff --git a/rb/lib/selenium/webdriver/bidi/protocol/error_code.rb b/rb/lib/selenium/webdriver/bidi/protocol/error_code.rb new file mode 100644 index 0000000000000..58769f25e8779 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/protocol/error_code.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# DO NOT EDIT! This file is generated by rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb. +# Regenerate with: `bazel run //rb/lib/selenium/webdriver:bidi-generate` + +module Selenium + module WebDriver + class BiDi + module Protocol + # The BiDi ErrorCode enum: each wire value mapped to its Ruby exception class name. + # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ + module ErrorCode + CLASS_NAMES = { + 'invalid argument' => 'InvalidArgumentError', + 'invalid selector' => 'InvalidSelectorError', + 'invalid session id' => 'InvalidSessionIdError', + 'invalid web extension' => 'InvalidWebExtensionError', + 'move target out of bounds' => 'MoveTargetOutOfBoundsError', + 'no such alert' => 'NoSuchAlertError', + 'no such network collector' => 'NoSuchNetworkCollectorError', + 'no such element' => 'NoSuchElementError', + 'no such frame' => 'NoSuchFrameError', + 'no such handle' => 'NoSuchHandleError', + 'no such history entry' => 'NoSuchHistoryEntryError', + 'no such intercept' => 'NoSuchInterceptError', + 'no such network data' => 'NoSuchNetworkDataError', + 'no such node' => 'NoSuchNodeError', + 'no such request' => 'NoSuchRequestError', + 'no such screencast' => 'NoSuchScreencastError', + 'no such script' => 'NoSuchScriptError', + 'no such storage partition' => 'NoSuchStoragePartitionError', + 'no such user context' => 'NoSuchUserContextError', + 'no such web extension' => 'NoSuchWebExtensionError', + 'session not created' => 'SessionNotCreatedError', + 'unable to capture screen' => 'UnableToCaptureScreenError', + 'unable to close browser' => 'UnableToCloseBrowserError', + 'unable to set cookie' => 'UnableToSetCookieError', + 'unable to set file input' => 'UnableToSetFileInputError', + 'unavailable network data' => 'UnavailableNetworkDataError', + 'underspecified storage partition' => 'UnderspecifiedStoragePartitionError', + 'unknown command' => 'UnknownCommandError', + 'unknown error' => 'UnknownError', + 'unsupported operation' => 'UnsupportedOperationError' + }.freeze + end # ErrorCode + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb index 85f9a75e89a06..a5df6e59cc5df 100644 --- a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb +++ b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb @@ -312,6 +312,12 @@ def type_entry = "'#{wire_name}' => #{payload_ref || 'nil'}" # spec_href links to the type's definition in the live spec (nil when the schema has none). Enum = Struct.new(:constant_name, :pairs, :spec_href, keyword_init: true) + # The generated Protocol::ErrorCode module (filename 'error_code'): `codes` is the [wire, class_name] + # pairs in schema order (the full map); `new_classes` is the subset of class names the classic + # Error module does not already define (the ones whose RBS this file must declare). Rendered + # through the same emit/render path as the domain modules. + ErrorModule = Struct.new(:filename, :codes, :new_classes, keyword_init: true) + # ref is the Protocol-relative class path for a nested structured field (nil # for a scalar/opaque field); list wraps it in an array. wire_key is the exact # JSON payload key (the schema's `wire` name, baked verbatim). @@ -613,6 +619,12 @@ def enums_for(domain) end end + # The protocol-root ErrorCode enum's wire values (e.g. "no such frame"), in schema order. + # Used to generate the BiDi-specific Error subclasses. [] when the schema has no ErrorCode. + def error_codes + @types.dig('ErrorCode', 'values') || [] + end + # Structured value classes (records + discriminated unions) declared under # "." Empty records are projector artifacts with nothing to carry, so # they stay opaque hashes; only non-empty records and unions become classes. @@ -1113,6 +1125,40 @@ def self.call(schema_path, output_dir) emit(modules, output_dir, 'module.rb.erb', 'rb') emit(modules, sig_dir(output_dir), 'module.rbs.erb', 'rbs') + emit_error_module(schema, output_dir) + end + + # The ErrorCode wire values mapped to their Ruby exception class names (schema order), e.g. + # "no such node" => "NoSuchNodeError". This is the schema->Ruby translation: the generated file + # carries the Ruby names, and a hand-written pass turns them into WebDriverError subclasses under + # the shared Error namespace. Self-contained — no reference to the classic error module. + def self.error_code_map(schema) + schema.error_codes.map { |code| [code, error_class_name(code)] } + end + + # WebDriver error-code string -> exception class name, matching Error.for_error's convention + # ("no such node" -> NoSuchNodeError). The Error suffix is normalized (not doubled) for a code + # already ending in "error" ("unknown error" -> UnknownError). + def self.error_class_name(code) + "#{code.split.map(&:capitalize).join.sub(/Error$/, '')}Error" + end + + # Writes protocol/error_code.rb (+ its .rbs), the Protocol::ErrorCode map, into the same protocol + # dir as the generated domain files. + def self.emit_error_module(schema, output_dir) + codes = error_code_map(schema) + mod = ErrorModule.new(filename: 'error_code', codes: codes, new_classes: bidi_only_classes(codes)) + emit([mod], output_dir, 'error_code.rb.erb', 'rb') + emit([mod], sig_dir(output_dir), 'error_code.rbs.erb', 'rbs') + end + + # Class names among `codes` the classic Error module does not already define — the BiDi-only codes + # bidi/error.rb registers and whose RBS this file must declare. Shared codes already have RBS in + # common/error.rbs, so re-declaring them would duplicate the classic signatures. Only the RBS needs + # this split; the emitted map (error_code.rb) stays the full self-contained set. + def self.bidi_only_classes(codes) + require_relative '../../common/error' + codes.filter_map { |_wire, name| name unless ::Selenium::WebDriver::Error.const_defined?(name, false) } end # Renders every module through one template and writes the result into target, diff --git a/rb/lib/selenium/webdriver/bidi/support/check_generated.rb b/rb/lib/selenium/webdriver/bidi/support/check_generated.rb index 477cd8ea982fd..0185783702f3f 100644 --- a/rb/lib/selenium/webdriver/bidi/support/check_generated.rb +++ b/rb/lib/selenium/webdriver/bidi/support/check_generated.rb @@ -25,21 +25,29 @@ module BiDiGenerate # current schema — catching a hand-edit or a forgotten regeneration. Re-renders each module # in memory (no file writes) and compares. The .rbs are covered by Steep. def self.check!(schema_rootpath) - modules = build_ir(Schema.new(JSON.parse(File.read(schema_path(schema_rootpath))))) + schema = Schema.new(JSON.parse(File.read(schema_path(schema_rootpath)))) protocol_dir = File.expand_path('../protocol', __dir__) template = File.join(__dir__, 'templates', 'module.rb.erb') - stale = modules.reject do |mod| + stale = build_ir(schema).filter_map do |mod| path = File.join(protocol_dir, "#{mod.filename}.rb") - File.exist?(path) && File.read(path) == render(mod, template) + "#{mod.filename}.rb" unless File.exist?(path) && File.read(path) == render(mod, template) end + stale << 'error_code.rb' unless error_module_current?(schema, protocol_dir) return if stale.empty? - warn "Generated BiDi protocol code is stale or hand-edited: #{stale.map { |m| "#{m.filename}.rb" }.sort.join(', ')}" + warn "Generated BiDi protocol code is stale or hand-edited: #{stale.sort.join(', ')}" warn 'Regenerate with: bazel run //rb/lib/selenium/webdriver:bidi-generate' exit 1 end + # Whether the checked-in protocol/error_code.rb matches what the generator would render now. + def self.error_module_current?(schema, protocol_dir) + mod = ErrorModule.new(filename: 'error_code', codes: error_code_map(schema)) + path = File.join(protocol_dir, 'error_code.rb') + File.exist?(path) && File.read(path) == render(mod, File.join(__dir__, 'templates', 'error_code.rb.erb')) + end + # $(rootpath) is relative to the runfiles root; __dir__ anchors us there so it resolves the # same way locally and on RBE (an execpath would not). This file lives at # rb/lib/selenium/webdriver/bidi/support, so expand six levels up to the root — File.expand_path diff --git a/rb/lib/selenium/webdriver/bidi/support/templates/error_code.rb.erb b/rb/lib/selenium/webdriver/bidi/support/templates/error_code.rb.erb new file mode 100644 index 0000000000000..e526912d42a35 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/support/templates/error_code.rb.erb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +<%= generated_note %> + +module Selenium + module WebDriver + class BiDi + module Protocol + # The BiDi ErrorCode enum: each wire value mapped to its Ruby exception class name. + # @see <%= BiDiGenerate::BIDI_DOC_URL %> + module ErrorCode + CLASS_NAMES = { + <%= mod.codes.map { |wire, name| "'#{wire}' => '#{name}'" }.join(",\n ") %> + }.freeze + end # ErrorCode + end # Protocol + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/support/templates/error_code.rbs.erb b/rb/lib/selenium/webdriver/bidi/support/templates/error_code.rbs.erb new file mode 100644 index 0000000000000..38a0e2179484d --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/support/templates/error_code.rbs.erb @@ -0,0 +1,39 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +<%= generated_note %> + +module Selenium + module WebDriver + class BiDi + module Protocol + module ErrorCode + CLASS_NAMES: Hash[String, String] + end + end + end + + # Signatures for the BiDi-only exception classes bidi/error.rb registers; codes shared with the + # classic protocol are already declared in common/error.rbs. + module Error +<%- mod.new_classes.each do |name| -%> + class <%= name %> < WebDriverError + end +<%- end -%> + end + end +end diff --git a/rb/lib/selenium/webdriver/bidi/transport.rb b/rb/lib/selenium/webdriver/bidi/transport.rb index c22ad147032df..ef1d34c14d7f0 100644 --- a/rb/lib/selenium/webdriver/bidi/transport.rb +++ b/rb/lib/selenium/webdriver/bidi/transport.rb @@ -31,7 +31,7 @@ def initialize(connection) def execute(cmd:, params: nil, result: nil) reply = @connection.send_cmd(method: cmd, params: serialize(params)) - raise Error::WebDriverError, error_message(reply) if reply['error'] + raise error_for(reply) if reply['error'] value = reply['result'] result ? result.from_json(value) : value @@ -43,8 +43,8 @@ def serialize(params) params&.as_json || {} end - def error_message(reply) - "#{reply['error']}: #{reply['message']}\n#{reply['stacktrace']}" + def error_for(reply) + Protocol::ErrorCode.for(reply['error']).new("#{reply['message']}\n#{reply['stacktrace']}") end end # Transport end # BiDi diff --git a/rb/sig/lib/selenium/webdriver/bidi/error.rbs b/rb/sig/lib/selenium/webdriver/bidi/error.rbs new file mode 100644 index 0000000000000..489f0b5321459 --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/error.rbs @@ -0,0 +1,28 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Selenium + module WebDriver + class BiDi + module Protocol + module ErrorCode + def self.for: (String? code) -> singleton(::Selenium::WebDriver::Error::WebDriverError) + end + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/error_code.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/error_code.rbs new file mode 100644 index 0000000000000..e893a251f2177 --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/error_code.rbs @@ -0,0 +1,70 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# DO NOT EDIT! This file is generated by rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb. +# Regenerate with: `bazel run //rb/lib/selenium/webdriver:bidi-generate` + +module Selenium + module WebDriver + class BiDi + module Protocol + module ErrorCode + CLASS_NAMES: Hash[String, String] + end + end + end + + # Signatures for the BiDi-only exception classes bidi/error.rb registers; codes shared with the + # classic protocol are already declared in common/error.rbs. + module Error + class InvalidWebExtensionError < WebDriverError + end + class NoSuchNetworkCollectorError < WebDriverError + end + class NoSuchHandleError < WebDriverError + end + class NoSuchHistoryEntryError < WebDriverError + end + class NoSuchInterceptError < WebDriverError + end + class NoSuchNetworkDataError < WebDriverError + end + class NoSuchNodeError < WebDriverError + end + class NoSuchRequestError < WebDriverError + end + class NoSuchScreencastError < WebDriverError + end + class NoSuchScriptError < WebDriverError + end + class NoSuchStoragePartitionError < WebDriverError + end + class NoSuchUserContextError < WebDriverError + end + class NoSuchWebExtensionError < WebDriverError + end + class UnableToCloseBrowserError < WebDriverError + end + class UnableToSetFileInputError < WebDriverError + end + class UnavailableNetworkDataError < WebDriverError + end + class UnderspecifiedStoragePartitionError < WebDriverError + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/transport.rbs b/rb/sig/lib/selenium/webdriver/bidi/transport.rbs index 829fbf723857c..68753987e2cb6 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/transport.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/transport.rbs @@ -30,7 +30,7 @@ module Selenium def serialize: (untyped params) -> untyped - def error_message: (Hash[String, untyped] reply) -> String + def error_for: (Hash[String, untyped] reply) -> Error::WebDriverError end end end diff --git a/rb/spec/unit/selenium/webdriver/bidi/transport_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/transport_spec.rb index a6f83d043df1f..b91cc1e49180a 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/transport_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/transport_spec.rb @@ -68,12 +68,28 @@ def stub_result(result = {}) .with(method: 'browsingContext.setViewport', params: {'context' => 'c', 'viewport' => nil}) end - it 'raises on an error reply' do + it 'raises the shared classic class for a code common to both transports' do allow(connection).to receive(:send_cmd) .and_return('error' => 'no such frame', 'message' => 'gone', 'stacktrace' => '') expect { transport.execute(cmd: 'browsingContext.navigate') } - .to raise_error(Error::WebDriverError, /no such frame: gone/) + .to raise_error(Error::NoSuchFrameError, /gone/) + end + + it 'raises the BiDi-specific class for a code unique to BiDi' do + allow(connection).to receive(:send_cmd) + .and_return('error' => 'no such node', 'message' => 'gone', 'stacktrace' => '') + + expect { transport.execute(cmd: 'script.callFunction') } + .to raise_error(Error::NoSuchNodeError, /gone/) + end + + it 'falls back to WebDriverError for an unrecognized code' do + allow(connection).to receive(:send_cmd) + .and_return('error' => 'unheard of', 'message' => 'gone', 'stacktrace' => '') + + expect { transport.execute(cmd: 'browsingContext.navigate') } + .to raise_error(Error::WebDriverError, /gone/) end end end # BiDi From 47edefb4b4370c4c74f9a93032ce8d5e9fd80290 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Mon, 3 Aug 2026 04:54:06 +0700 Subject: [PATCH 21/56] [grid] Store Dynamic Grid videos in a per-session subfolder via SE_VIDEO_SESSION_SUBFOLDER (#17856) Signed-off-by: Viet Nguyen Duc --- .../node/docker/DockerSessionFactory.java | 51 +++++-- .../node/kubernetes/KubernetesSession.java | 9 +- .../kubernetes/KubernetesSessionFactory.java | 38 +++-- .../node/docker/DockerSessionFactoryTest.java | 138 ++++++++++++++++++ .../KubernetesSessionFactoryTest.java | 89 +++++++++++ .../kubernetes/KubernetesSessionTest.java | 128 ++++++++++++++++ 6 files changed, 429 insertions(+), 24 deletions(-) create mode 100644 java/test/org/openqa/selenium/grid/node/docker/DockerSessionFactoryTest.java create mode 100644 java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java diff --git a/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java b/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java index 028796fde54ed..83d76b4522f9b 100644 --- a/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java +++ b/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java @@ -311,17 +311,44 @@ private Capabilities addForwardCdpEndpoint( .setCapability("se:forwardCdp", forwardCdpPath); } - private Container createBrowserContainer( - int port, Capabilities sessionCapabilities, String sessionIdentifier) { - Map browserContainerEnvVars = new HashMap<>(); + Map createBrowserContainerEnvVars(Capabilities sessionCapabilities) { + Map envVars = new HashMap<>(); + boolean recordsInline = videoImage == null && recordVideoForSession(sessionCapabilities); // Enable env var to trigger video recording if session capabilities request and external video // container is disabled - if (videoImage == null && recordVideoForSession(sessionCapabilities)) { - browserContainerEnvVars.put("SE_RECORD_VIDEO", "true"); - browserContainerEnvVars.put("SE_VIDEO_FILE_NAME", "auto"); - browserContainerEnvVars.put("SE_VIDEO_RECORD_STANDALONE", "true"); + if (recordsInline) { + envVars.put("SE_RECORD_VIDEO", "true"); + envVars.put("SE_VIDEO_RECORD_STANDALONE", "true"); + } + envVars.putAll(getBrowserContainerEnvVars(sessionCapabilities)); + if (recordsInline) { + // The browser container binds the assets root, so the recorder has to create the session + // folder itself, and it only does that while it owns the file name. Both are enforced over + // anything inherited from the Node: a flat layout or a fixed name would scatter every + // session's video into the assets root. + String inheritedFileName = envVars.get("SE_VIDEO_FILE_NAME"); + if (inheritedFileName != null && !"auto".equalsIgnoreCase(inheritedFileName)) { + LOG.warning( + String.format( + "Ignoring SE_VIDEO_FILE_NAME '%s' for inline recording so the recorder can name" + + " videos per session", + inheritedFileName)); + } + envVars.put("SE_VIDEO_SESSION_SUBFOLDER", "true"); + envVars.put("SE_VIDEO_FILE_NAME", "auto"); + if (assetsPath != null) { + LOG.fine( + String.format( + "Inline recording will write to %s/", assetsPath.getHostPath())); + } } - browserContainerEnvVars.putAll(getBrowserContainerEnvVars(sessionCapabilities)); + return envVars; + } + + private Container createBrowserContainer( + int port, Capabilities sessionCapabilities, String sessionIdentifier) { + Map browserContainerEnvVars = + createBrowserContainerEnvVars(sessionCapabilities); long browserContainerShmMemorySize = 2147483648L; // 2GB // Generate container name: browser--- @@ -348,7 +375,7 @@ private Container createBrowserContainer( return docker.create(containerConfig); } - private Map getBrowserContainerEnvVars(Capabilities sessionRequestCapabilities) { + Map getBrowserContainerEnvVars(Capabilities sessionRequestCapabilities) { Map envVars = new HashMap<>(); // Passing env vars set to the child container setEnvVarsToContainer(envVars); @@ -429,7 +456,7 @@ private Container startVideoContainer( return videoContainer; } - private Map getVideoContainerEnvVars( + Map getVideoContainerEnvVars( Capabilities sessionRequestCapabilities, String containerIp) { Map envVars = new HashMap<>(); // Passing env vars set to the child container @@ -441,6 +468,10 @@ private Map getVideoContainerEnvVars( ofNullable(getVideoFileName(sessionRequestCapabilities, "se:videoName")) .or(() -> ofNullable(getVideoFileName(sessionRequestCapabilities, "se:name"))); videoName.ifPresent(name -> envVars.put("SE_VIDEO_FILE_NAME", String.format("%s.mp4", name))); + // The video container's bind mount is already per-session (assets/ -> /videos), so + // the recorder must not nest a second session folder inside it. Blanking the value stops a + // Node-level setting from passing through and leaves the image default in charge. + envVars.put("SE_VIDEO_SESSION_SUBFOLDER", ""); return envVars; } diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java index b466892577412..5d180f5ac8ca1 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java @@ -97,10 +97,8 @@ public void stop() { LOG.log(Level.WARNING, "Failed to close port-forward for session " + getId(), e); } } - // Delete the Job so K8s sends SIGTERM to containers (including video sidecar), - // then wait for the Pod to fully terminate before touching video files. + // Delete the Job so K8s sends SIGTERM to containers (including video sidecar). deleteJob(); - waitForPodTerminated(); relocateVideoFiles(); super.stop(); } @@ -178,8 +176,13 @@ private void saveLogs() { private void relocateVideoFiles() { if (assetsPath == null || videoFileName == null) { + // Either assets are not kept, or the recorder already wrote the video to its final + // per-session location (SE_VIDEO_SESSION_SUBFOLDER / SE_VIDEO_FILE_NAME=auto), so there is + // nothing to move and no reason to wait for the Pod. return; } + // The file is only complete once the recorder has been signalled and the Pod has terminated. + waitForPodTerminated(); Path assetsDir = Paths.get(assetsPath); // The recorder writes using jobName (set via SE_VIDEO_FILE_NAME at Job creation time). // videoFileName is the fully resolved target name (may include caps-derived name + sessionId). diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java index b918eff88bb9c..3b02536e27063 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java @@ -489,7 +489,7 @@ public Either apply(CreateSessionRequest sess String.format("Created session: %s - %s (job: %s)", id, mergedCapabilities, jobName)); String videoFileName = null; if (recordVideoForSession(sessionRequest.getDesiredCapabilities()) - && !isVideoFileNameAuto()) { + && !isRecorderManagedFileName()) { videoFileName = resolveVideoFileName(jobName, sessionRequest.getDesiredCapabilities(), id) + ".mp4"; } @@ -737,13 +737,8 @@ private List buildSessionEnvVars(String jobName, Capabilities sessionCap setCapsToEnvVars(sessionCapabilities, envVars); // Video recording env vars (inline and external use the same naming). - // If SE_VIDEO_FILE_NAME is already "auto" from the environment, respect it and let the - // recorder handle naming. Otherwise, set it to jobName because sessionId is not yet available. if (recordVideoForSession(sessionCapabilities)) { - if (!isVideoFileNameAuto()) { - envVars.add( - new EnvVarBuilder().withName("SE_VIDEO_FILE_NAME").withValue(jobName + ".mp4").build()); - } + addVideoFileNameEnvVars(envVars, jobName); // Inline video recording: browser container records directly (no sidecar) if (isNoVideoSidecar()) { @@ -756,6 +751,22 @@ private List buildSessionEnvVars(String jobName, Capabilities sessionCap return envVars; } + private void addVideoFileNameEnvVars(List envVars, String jobName) { + if (isVideoSessionSubfolder()) { + envVars.add( + new EnvVarBuilder().withName("SE_VIDEO_SESSION_SUBFOLDER").withValue("true").build()); + // The recorder creates /videos// only while it owns the file name, and /videos is + // the same volume as the assets path, so the video lands at its final location. + if (!isVideoFileNameAuto()) { + envVars.add(new EnvVarBuilder().withName("SE_VIDEO_FILE_NAME").withValue("auto").build()); + } + } else if (!isVideoFileNameAuto()) { + // sessionId is not known yet, so the recorder writes jobName.mp4 and the session relocates it + envVars.add( + new EnvVarBuilder().withName("SE_VIDEO_FILE_NAME").withValue(jobName + ".mp4").build()); + } + } + private String resolveVideoFileName(String jobName, Capabilities sessionCapabilities) { return ofNullable(getVideoFileName(sessionCapabilities, "se:videoName")) .or(() -> ofNullable(getVideoFileName(sessionCapabilities, "se:name"))) @@ -834,10 +845,7 @@ private List buildVideoEnvVars(String jobName, Capabilities sessionCapab new EnvVarBuilder().withName("DISPLAY_CONTAINER_NAME").withValue("localhost").build()); envVars.add( new EnvVarBuilder().withName("SE_VIDEO_RECORD_STANDALONE").withValue("true").build()); - if (!isVideoFileNameAuto()) { - envVars.add( - new EnvVarBuilder().withName("SE_VIDEO_FILE_NAME").withValue(jobName + ".mp4").build()); - } + addVideoFileNameEnvVars(envVars, jobName); return envVars; } @@ -907,6 +915,14 @@ private boolean isVideoFileNameAuto() { return "auto".equalsIgnoreCase(System.getenv("SE_VIDEO_FILE_NAME")); } + boolean isVideoSessionSubfolder() { + return Boolean.parseBoolean(System.getenv("SE_VIDEO_SESSION_SUBFOLDER")); + } + + private boolean isRecorderManagedFileName() { + return isVideoFileNameAuto() || isVideoSessionSubfolder(); + } + Job buildJobSpecFromTemplate(String jobName, Capabilities sessionCapabilities) { // Deep copy via YAML round-trip so the original template is not mutated Job job = Serialization.unmarshal(Serialization.asYaml(jobTemplate), Job.class); diff --git a/java/test/org/openqa/selenium/grid/node/docker/DockerSessionFactoryTest.java b/java/test/org/openqa/selenium/grid/node/docker/DockerSessionFactoryTest.java new file mode 100644 index 0000000000000..4bcca33f0f03e --- /dev/null +++ b/java/test/org/openqa/selenium/grid/node/docker/DockerSessionFactoryTest.java @@ -0,0 +1,138 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.grid.node.docker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.net.URI; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.ImmutableCapabilities; +import org.openqa.selenium.docker.Docker; +import org.openqa.selenium.docker.Image; +import org.openqa.selenium.remote.http.HttpClient; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.tracing.Tracer; + +@Tag("UnitTests") +class DockerSessionFactoryTest { + + private static final Capabilities RECORDING_CAPS = + new ImmutableCapabilities("browserName", "chrome", "se:recordVideo", true); + + /** Stubs the inherited Node environment so the tests do not depend on the test runner's own. */ + private static class TestFactory extends DockerSessionFactory { + + private final Map inheritedEnvVars; + + TestFactory(Image videoImage, Map inheritedEnvVars) { + super( + mock(Tracer.class), + mock(HttpClient.Factory.class), + Duration.ofMinutes(5), + Duration.ofSeconds(120), + new Docker(req -> new HttpResponse()), + URI.create("http://localhost:2375"), + mock(Image.class), + new ImmutableCapabilities("browserName", "chrome"), + List.of(), + videoImage, + new DockerAssetsPath("/opt/selenium/assets", "/opt/selenium/assets"), + "grid-network", + true, + caps -> true, + Map.of(), + List.of(), + Map.of(), + Duration.ofSeconds(10)); + this.inheritedEnvVars = inheritedEnvVars; + } + + @Override + Map getBrowserContainerEnvVars(Capabilities sessionCapabilities) { + return new HashMap<>(inheritedEnvVars); + } + } + + @Test + void inlineRecordingAlwaysWritesToASessionSubfolder() { + DockerSessionFactory factory = new TestFactory(null, Map.of()); + + Map envVars = factory.createBrowserContainerEnvVars(RECORDING_CAPS); + + assertThat(envVars).containsEntry("SE_RECORD_VIDEO", "true"); + assertThat(envVars).containsEntry("SE_VIDEO_RECORD_STANDALONE", "true"); + assertThat(envVars).containsEntry("SE_VIDEO_SESSION_SUBFOLDER", "true"); + assertThat(envVars).containsEntry("SE_VIDEO_FILE_NAME", "auto"); + } + + @Test + void inlineRecordingOverridesAnInheritedSubfolderOptOut() { + // The browser container binds the assets root, so a flat layout would scatter every session's + // video into it. + DockerSessionFactory factory = + new TestFactory(null, Map.of("SE_VIDEO_SESSION_SUBFOLDER", "false")); + + Map envVars = factory.createBrowserContainerEnvVars(RECORDING_CAPS); + + assertThat(envVars).containsEntry("SE_VIDEO_SESSION_SUBFOLDER", "true"); + } + + @Test + void inlineRecordingOverridesAnInheritedFixedFileName() { + // video.sh only creates the session subfolder on its dynamic naming path, so a fixed name + // would silently disable the subfolder. + DockerSessionFactory factory = new TestFactory(null, Map.of("SE_VIDEO_FILE_NAME", "video.mp4")); + + Map envVars = factory.createBrowserContainerEnvVars(RECORDING_CAPS); + + assertThat(envVars).containsEntry("SE_VIDEO_FILE_NAME", "auto"); + assertThat(envVars).containsEntry("SE_VIDEO_SESSION_SUBFOLDER", "true"); + } + + @Test + void noVideoEnvVarsWhenSessionDoesNotRecord() { + DockerSessionFactory factory = new TestFactory(null, Map.of()); + + Map envVars = + factory.createBrowserContainerEnvVars(new ImmutableCapabilities("browserName", "chrome")); + + assertThat(envVars).doesNotContainKey("SE_RECORD_VIDEO"); + assertThat(envVars).doesNotContainKey("SE_VIDEO_SESSION_SUBFOLDER"); + assertThat(envVars).doesNotContainKey("SE_VIDEO_FILE_NAME"); + } + + @Test + void videoContainerDoesNotInheritTheSessionSubfolderSetting() { + // The video container's bind mount is already per-session, so a "true" inherited from the Node + // would nest twice. The value is pinned blank, which nothing inherited can survive, and which + // leaves the image's own default in charge. + Image videoImage = mock(Image.class); + DockerSessionFactory factory = new TestFactory(videoImage, Map.of()); + + Map envVars = factory.getVideoContainerEnvVars(RECORDING_CAPS, "10.0.0.5"); + + assertThat(envVars).containsEntry("SE_VIDEO_SESSION_SUBFOLDER", ""); + } +} diff --git a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java index c5de300d2394d..2c5a8880281dc 100644 --- a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java +++ b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java @@ -135,6 +135,38 @@ private static KubernetesSessionFactory createImageFactory( caps -> true); } + private static KubernetesSessionFactory createSubfolderImageFactory( + String videoImage, String assetsPath) { + Tracer tracer = Mockito.mock(Tracer.class); + HttpClient.Factory clientFactory = Mockito.mock(HttpClient.Factory.class); + + return new KubernetesSessionFactory( + tracer, + clientFactory, + Duration.ofMinutes(5), + Duration.ofSeconds(120), + () -> Mockito.mock(KubernetesClient.class), + "selenium", + "selenium/standalone-chrome:latest", + new ImmutableCapabilities("browserName", "chrome"), + "IfNotPresent", + null, + Map.of(), + Map.of(), + Map.of(), + videoImage, + assetsPath, + InheritedPodSpec.empty(), + 30L, + false, + caps -> true) { + @Override + boolean isVideoSessionSubfolder() { + return true; + } + }; + } + private static EnvVar findEnvVar(List envVars, String name) { return envVars.stream().filter(e -> name.equals(e.getName())).findFirst().orElse(null); } @@ -708,6 +740,63 @@ void browserContainerHasVideoFileNameEnvVar() { assertThat(videoFileName.getValue()).isEqualTo("test-job.mp4"); } + @Test + void browserContainerEnablesSessionSubfolderAndDropsJobFileName() { + KubernetesSessionFactory factory = createSubfolderImageFactory(null, "/opt/selenium/assets"); + + Job job = + factory.buildJobSpec( + "test-job", new ImmutableCapabilities("browserName", "chrome", "se:recordVideo", true)); + + Container browser = + KubernetesSessionFactory.findContainerByName( + job.getSpec().getTemplate().getSpec().getContainers(), "browser"); + assertThat(findEnvVar(browser.getEnv(), "SE_VIDEO_SESSION_SUBFOLDER")) + .isNotNull() + .extracting(EnvVar::getValue) + .isEqualTo("true"); + // The recorder derives _.mp4 itself; jobName naming would defeat the subfolder + assertThat(findEnvVar(browser.getEnv(), "SE_VIDEO_FILE_NAME")) + .isNotNull() + .extracting(EnvVar::getValue) + .isEqualTo("auto"); + } + + @Test + void videoSidecarEnablesSessionSubfolderAndDropsJobFileName() { + KubernetesSessionFactory factory = + createSubfolderImageFactory("selenium/video:latest", "/opt/selenium/assets"); + + Job job = + factory.buildJobSpec( + "test-job", new ImmutableCapabilities("browserName", "chrome", "se:recordVideo", true)); + + Container video = + KubernetesSessionFactory.findContainerByName( + job.getSpec().getTemplate().getSpec().getContainers(), "video"); + assertThat(video).isNotNull(); + assertThat(findEnvVar(video.getEnv(), "SE_VIDEO_SESSION_SUBFOLDER")) + .isNotNull() + .extracting(EnvVar::getValue) + .isEqualTo("true"); + assertThat(findEnvVar(video.getEnv(), "SE_VIDEO_FILE_NAME")) + .isNotNull() + .extracting(EnvVar::getValue) + .isEqualTo("auto"); + } + + @Test + void noSessionSubfolderEnvVarWhenSessionDoesNotRecord() { + KubernetesSessionFactory factory = createSubfolderImageFactory(null, "/opt/selenium/assets"); + + Job job = factory.buildJobSpec("test-job", new ImmutableCapabilities("browserName", "chrome")); + + Container browser = + KubernetesSessionFactory.findContainerByName( + job.getSpec().getTemplate().getSpec().getContainers(), "browser"); + assertThat(findEnvVar(browser.getEnv(), "SE_VIDEO_SESSION_SUBFOLDER")).isNull(); + } + @Test void browserContainerInlineVideoEnvVarsWhenNoVideoImage() { KubernetesSessionFactory factory = createImageFactory(null, null); diff --git a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java new file mode 100644 index 0000000000000..4baefb22380e2 --- /dev/null +++ b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java @@ -0,0 +1,128 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.grid.node.kubernetes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodList; +import io.fabric8.kubernetes.api.model.batch.v1.Job; +import io.fabric8.kubernetes.api.model.batch.v1.JobList; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.PodResource; +import io.fabric8.kubernetes.client.dsl.ScalableResource; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openqa.selenium.ImmutableCapabilities; +import org.openqa.selenium.remote.Dialect; +import org.openqa.selenium.remote.SessionId; +import org.openqa.selenium.remote.http.HttpClient; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.tracing.Tracer; + +class KubernetesSessionTest { + + @TempDir Path tempDir; + + private KubernetesSession createSession( + KubernetesClient kubeClient, String assetsPath, String videoFileName) throws Exception { + HttpClient httpClient = mock(HttpClient.class); + when(httpClient.execute(any(HttpRequest.class))).thenReturn(new HttpResponse()); + + return new KubernetesSession( + "test-job", + "selenium", + kubeClient, + "test-pod", + assetsPath, + videoFileName, + 30L, + null, + mock(Tracer.class), + httpClient, + new SessionId("test-session-id"), + new URL("http://localhost:4444"), + new ImmutableCapabilities(), + new ImmutableCapabilities(), + Dialect.W3C, + Dialect.W3C, + Instant.now()); + } + + /** + * Deep stubs stop at links whose return type is a type variable, so the Job and Pod chains are + * stubbed by hand. + */ + @SuppressWarnings("unchecked") + private ScalableResource stubJobResource(KubernetesClient kubeClient) { + NonNamespaceOperation> jobsInNamespace = + mock(NonNamespaceOperation.class); + ScalableResource jobResource = mock(ScalableResource.class); + when(kubeClient.batch().v1().jobs().inNamespace("selenium")).thenReturn(jobsInNamespace); + when(jobsInNamespace.withName("test-job")).thenReturn(jobResource); + return jobResource; + } + + @SuppressWarnings("unchecked") + private PodResource stubPodResource(KubernetesClient kubeClient) { + NonNamespaceOperation podsInNamespace = + mock(NonNamespaceOperation.class); + PodResource podResource = mock(PodResource.class); + when(kubeClient.pods().inNamespace("selenium")).thenReturn(podsInNamespace); + when(podsInNamespace.withName("test-pod")).thenReturn(podResource); + return podResource; + } + + @Test + void stopDoesNotWaitForThePodWhenThereIsNoVideoToRelocate() throws Exception { + KubernetesClient kubeClient = mock(KubernetesClient.class, RETURNS_DEEP_STUBS); + ScalableResource jobResource = stubJobResource(kubeClient); + + createSession(kubeClient, null, null).stop(); + + verify(jobResource).delete(); + // Polling the Pod only serves the relocation, which has nothing to do here + verify(kubeClient, never()).pods(); + } + + @Test + void stopRelocatesTheVideoIntoTheSessionFolder() throws Exception { + KubernetesClient kubeClient = mock(KubernetesClient.class, RETURNS_DEEP_STUBS); + stubJobResource(kubeClient); + // A null Pod means it is already gone, so the termination wait returns immediately + when(stubPodResource(kubeClient).get()).thenReturn(null); + Files.writeString(tempDir.resolve("test-job.mp4"), "recorded"); + + createSession(kubeClient, tempDir.toString(), "my-test_test-session-id.mp4").stop(); + + assertThat(tempDir.resolve("test-session-id").resolve("my-test_test-session-id.mp4")).exists(); + assertThat(tempDir.resolve("test-job.mp4")).doesNotExist(); + } +} From e1075609f07b2703e8fc2f2426cc74665a1318be Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Mon, 3 Aug 2026 05:57:12 +0700 Subject: [PATCH 22/56] [grid] inherit the Node Pod container securityContext for Dynamic Grid on K8s (#17860) --- .../node/kubernetes/InheritedPodSpec.java | 50 +++++++++++ .../node/kubernetes/KubernetesOptions.java | 6 +- .../kubernetes/KubernetesSessionFactory.java | 31 +++++-- .../node/kubernetes/InheritedPodSpecTest.java | 39 +++++++++ .../KubernetesSessionFactoryTest.java | 83 +++++++++++++++++++ 5 files changed, 201 insertions(+), 8 deletions(-) diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpec.java b/java/src/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpec.java index ebd5e07694f5e..ef52836c48f22 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpec.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpec.java @@ -22,6 +22,7 @@ import io.fabric8.kubernetes.api.model.PodDNSConfig; import io.fabric8.kubernetes.api.model.PodSecurityContext; import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.SecurityContext; import io.fabric8.kubernetes.api.model.Toleration; import java.util.Collections; import java.util.List; @@ -47,6 +48,7 @@ public class InheritedPodSpec { private final @Nullable String assetsClaimName; private final @Nullable String nodePodName; private final @Nullable String nodePodUid; + private final @Nullable SecurityContext containerSecurityContext; public InheritedPodSpec( @Nullable List tolerations, @@ -81,6 +83,7 @@ public InheritedPodSpec( resourceLimits, assetsClaimName, null, + null, null); } @@ -102,6 +105,46 @@ public InheritedPodSpec( @Nullable String assetsClaimName, @Nullable String nodePodName, @Nullable String nodePodUid) { + this( + tolerations, + affinity, + imagePullSecrets, + dnsPolicy, + dnsConfig, + securityContext, + priorityClassName, + nodeSelector, + serviceAccountName, + labels, + annotations, + imagePullPolicy, + resourceRequests, + resourceLimits, + assetsClaimName, + nodePodName, + nodePodUid, + null); + } + + public InheritedPodSpec( + @Nullable List tolerations, + @Nullable Affinity affinity, + @Nullable List imagePullSecrets, + @Nullable String dnsPolicy, + @Nullable PodDNSConfig dnsConfig, + @Nullable PodSecurityContext securityContext, + @Nullable String priorityClassName, + @Nullable Map nodeSelector, + @Nullable String serviceAccountName, + @Nullable Map labels, + @Nullable Map annotations, + @Nullable String imagePullPolicy, + @Nullable Map resourceRequests, + @Nullable Map resourceLimits, + @Nullable String assetsClaimName, + @Nullable String nodePodName, + @Nullable String nodePodUid, + @Nullable SecurityContext containerSecurityContext) { this.tolerations = tolerations != null ? List.copyOf(tolerations) : List.of(); this.affinity = affinity; this.imagePullSecrets = imagePullSecrets != null ? List.copyOf(imagePullSecrets) : List.of(); @@ -121,6 +164,7 @@ public InheritedPodSpec( this.assetsClaimName = assetsClaimName; this.nodePodName = nodePodName; this.nodePodUid = nodePodUid; + this.containerSecurityContext = containerSecurityContext; } public static InheritedPodSpec empty() { @@ -144,6 +188,7 @@ public boolean hasInheritedFields() { || !resourceRequests.isEmpty() || !resourceLimits.isEmpty() || assetsClaimName != null + || containerSecurityContext != null || hasNodePodOwnerReference(); } @@ -175,6 +220,11 @@ public PodSecurityContext getSecurityContext() { return securityContext; } + @Nullable + public SecurityContext getContainerSecurityContext() { + return containerSecurityContext; + } + @Nullable public String getPriorityClassName() { return priorityClassName; diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesOptions.java b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesOptions.java index de032983ec704..f1eff60443222 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesOptions.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesOptions.java @@ -23,6 +23,7 @@ import io.fabric8.kubernetes.api.model.PodSpec; import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.ResourceRequirements; +import io.fabric8.kubernetes.api.model.SecurityContext; import io.fabric8.kubernetes.api.model.VolumeMount; import io.fabric8.kubernetes.api.model.batch.v1.Job; import io.fabric8.kubernetes.client.Config; @@ -432,11 +433,13 @@ InheritedPodSpec inspectNodePod( String containerImagePullPolicy = null; Map containerResourceRequests = null; Map containerResourceLimits = null; + SecurityContext containerSecurityContext = null; String assetsClaimName = null; List containers = spec.getContainers(); if (containers != null && !containers.isEmpty()) { Container firstContainer = containers.get(0); containerImagePullPolicy = firstContainer.getImagePullPolicy(); + containerSecurityContext = firstContainer.getSecurityContext(); ResourceRequirements resources = firstContainer.getResources(); if (resources != null) { containerResourceRequests = resources.getRequests(); @@ -489,7 +492,8 @@ InheritedPodSpec inspectNodePod( containerResourceLimits, assetsClaimName, nodePodName, - nodePodUid); + nodePodUid, + containerSecurityContext); LOG.info(String.format("Inspected Node Pod '%s' for inheritable spec fields", podName)); return inherited; diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java index 3b02536e27063..f7d4054be2b20 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java @@ -41,6 +41,7 @@ import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder; +import io.fabric8.kubernetes.api.model.SecurityContext; import io.fabric8.kubernetes.api.model.Volume; import io.fabric8.kubernetes.api.model.VolumeBuilder; import io.fabric8.kubernetes.api.model.VolumeMount; @@ -834,6 +835,13 @@ private Container buildBrowserContainer(String jobName, Capabilities sessionCapa containerBuilder.withResources(resourcesBuilder.build()); } + // Inherit the Node Pod's container securityContext (e.g. allowPrivilegeEscalation, + // capabilities) so browser Pods can satisfy a restricted Pod Security Standard. + SecurityContext containerSecurityContext = inheritedPodSpec.getContainerSecurityContext(); + if (containerSecurityContext != null) { + containerBuilder.withSecurityContext(containerSecurityContext); + } + return containerBuilder.build(); } @@ -862,13 +870,22 @@ private Container buildVideoContainer(String jobName, Capabilities sessionCapabi .build()); } - return new ContainerBuilder() - .withName("video") - .withImage(videoImage) - .withImagePullPolicy(imagePullPolicy) - .withEnv(envVars) - .withVolumeMounts(volumeMounts) - .build(); + ContainerBuilder containerBuilder = + new ContainerBuilder() + .withName("video") + .withImage(videoImage) + .withImagePullPolicy(imagePullPolicy) + .withEnv(envVars) + .withVolumeMounts(volumeMounts); + + // Inherit the Node Pod's container securityContext so the video sidecar also satisfies a + // restricted Pod Security Standard (all containers in the Pod must comply). + SecurityContext containerSecurityContext = inheritedPodSpec.getContainerSecurityContext(); + if (containerSecurityContext != null) { + containerBuilder.withSecurityContext(containerSecurityContext); + } + + return containerBuilder.build(); } @Nullable diff --git a/java/test/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpecTest.java b/java/test/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpecTest.java index 2cb7f91e4ba64..8ead89df4f177 100644 --- a/java/test/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpecTest.java +++ b/java/test/org/openqa/selenium/grid/node/kubernetes/InheritedPodSpecTest.java @@ -29,6 +29,8 @@ import io.fabric8.kubernetes.api.model.PodSecurityContext; import io.fabric8.kubernetes.api.model.PodSecurityContextBuilder; import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.SecurityContext; +import io.fabric8.kubernetes.api.model.SecurityContextBuilder; import io.fabric8.kubernetes.api.model.Toleration; import io.fabric8.kubernetes.api.model.TolerationBuilder; import java.util.List; @@ -41,6 +43,7 @@ class InheritedPodSpecTest { void emptySpecHasNoInheritedFields() { InheritedPodSpec spec = InheritedPodSpec.empty(); assertThat(spec.hasInheritedFields()).isFalse(); + assertThat(spec.getContainerSecurityContext()).isNull(); assertThat(spec.getTolerations()).isEmpty(); assertThat(spec.getAffinity()).isNull(); assertThat(spec.getImagePullSecrets()).isEmpty(); @@ -379,6 +382,42 @@ void specWithAssetsClaimNameHasInheritedFields() { assertThat(spec.getAssetsClaimName()).isEqualTo("my-pvc"); } + @Test + void specWithContainerSecurityContextHasInheritedFields() { + SecurityContext containerSecCtx = + new SecurityContextBuilder() + .withAllowPrivilegeEscalation(false) + .withNewCapabilities() + .withDrop("ALL") + .endCapabilities() + .build(); + InheritedPodSpec spec = + new InheritedPodSpec( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + containerSecCtx); + assertThat(spec.hasInheritedFields()).isTrue(); + assertThat(spec.getContainerSecurityContext()).isNotNull(); + assertThat(spec.getContainerSecurityContext().getAllowPrivilegeEscalation()).isFalse(); + assertThat(spec.getContainerSecurityContext().getCapabilities().getDrop()) + .containsExactly("ALL"); + } + @Test void specWithNodePodOwnerReferenceHasInheritedFields() { InheritedPodSpec spec = diff --git a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java index 2c5a8880281dc..21487c8e10de5 100644 --- a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java +++ b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java @@ -29,6 +29,8 @@ import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder; +import io.fabric8.kubernetes.api.model.SecurityContext; +import io.fabric8.kubernetes.api.model.SecurityContextBuilder; import io.fabric8.kubernetes.api.model.Toleration; import io.fabric8.kubernetes.api.model.TolerationBuilder; import io.fabric8.kubernetes.api.model.Volume; @@ -722,6 +724,87 @@ void imageModeOwnerReferenceSetWhenPodIdentityPresent() { assertThat(job.getMetadata().getOwnerReferences().get(0).getUid()).isEqualTo("pod-uid-123"); } + // ---- Container securityContext inheritance ---- + + private static InheritedPodSpec containerSecurityContextSpec(SecurityContext securityContext) { + return new InheritedPodSpec( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + securityContext); + } + + private static SecurityContext restrictedSecurityContext() { + return new SecurityContextBuilder() + .withAllowPrivilegeEscalation(false) + .withNewCapabilities() + .withDrop("ALL") + .endCapabilities() + .build(); + } + + @Test + void imageModeBrowserContainerInheritsContainerSecurityContext() { + KubernetesSessionFactory factory = + createImageFactory(null, null, containerSecurityContextSpec(restrictedSecurityContext())); + + Job job = factory.buildJobSpec("test-job", new ImmutableCapabilities("browserName", "chrome")); + + Container browser = + KubernetesSessionFactory.findContainerByName( + job.getSpec().getTemplate().getSpec().getContainers(), "browser"); + assertThat(browser.getSecurityContext()).isNotNull(); + assertThat(browser.getSecurityContext().getAllowPrivilegeEscalation()).isFalse(); + assertThat(browser.getSecurityContext().getCapabilities().getDrop()).containsExactly("ALL"); + } + + @Test + void imageModeBrowserContainerHasNoSecurityContextWhenNotInherited() { + KubernetesSessionFactory factory = createImageFactory(null, null); + + Job job = factory.buildJobSpec("test-job", new ImmutableCapabilities("browserName", "chrome")); + + Container browser = + KubernetesSessionFactory.findContainerByName( + job.getSpec().getTemplate().getSpec().getContainers(), "browser"); + assertThat(browser.getSecurityContext()).isNull(); + } + + @Test + void imageModeVideoContainerInheritsContainerSecurityContext() { + KubernetesSessionFactory factory = + createImageFactory( + "selenium/video:latest", + null, + containerSecurityContextSpec(restrictedSecurityContext())); + + Job job = + factory.buildJobSpec( + "test-job", new ImmutableCapabilities("browserName", "chrome", "se:recordVideo", true)); + + Container video = + KubernetesSessionFactory.findContainerByName( + job.getSpec().getTemplate().getSpec().getContainers(), "video"); + assertThat(video).isNotNull(); + assertThat(video.getSecurityContext()).isNotNull(); + assertThat(video.getSecurityContext().getAllowPrivilegeEscalation()).isFalse(); + assertThat(video.getSecurityContext().getCapabilities().getDrop()).containsExactly("ALL"); + } + // ---- Browser container env vars ---- @Test From 4c6f3a5e1fdd5f30bb12d82db19305d1b1102702 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 18:14:08 -0500 Subject: [PATCH 23/56] [rb] allow pending test guards to require matching provided exception (#17859) * [rb] add exception-aware pending_if guard matching * [rb] keep pending_exception_guard spec deterministic under SKIP_PENDING --- rb/.rubocop.yml | 1 + rb/TESTING.md | 14 ++ rb/lib/selenium/webdriver/support/guards.rb | 10 +- .../webdriver/support/guards/guard.rb | 23 ++- .../lib/selenium/webdriver/support/guards.rbs | 2 + .../webdriver/support/guards/guard.rbs | 4 + .../selenium/webdriver/spec_helper.rb | 61 ++++++-- rb/spec/unit/selenium/webdriver/guard_spec.rb | 147 ++++++++++++++---- 8 files changed, 213 insertions(+), 49 deletions(-) diff --git a/rb/.rubocop.yml b/rb/.rubocop.yml index 9bcab28514e7c..e403aeae6840b 100644 --- a/rb/.rubocop.yml +++ b/rb/.rubocop.yml @@ -115,6 +115,7 @@ RSpec/InstanceVariable: Exclude: - 'spec/unit/selenium/webdriver/socket_poller_spec.rb' - 'spec/integration/selenium/webdriver/chrome/print_pdf_spec.rb' + - 'spec/integration/selenium/webdriver/spec_helper.rb' RSpec/MultipleExpectations: Enabled: false diff --git a/rb/TESTING.md b/rb/TESTING.md index 710f9bc0b2583..85d617e1ccbe6 100644 --- a/rb/TESTING.md +++ b/rb/TESTING.md @@ -179,6 +179,20 @@ it 'something', skip_if: [ end ``` +### Exception-Aware Pending + +`pending_if`/`except` also accept `exception: {class:, message:}` (`message:` optional — a Regexp matches +as a pattern, a String matches exactly, like `raise_error`). The example is marked pending only when it +fails with that exception; a wrong exception, `invalid argument`, assertion failure, or timeout still fails. + +```ruby +it 'does something', pending_if: {browser: :firefox, + exception: {class: Selenium::WebDriver::Error::WebDriverError, + message: /\Aunknown command:/}, + reason: 'Firefox does not implement this command'} do +end +``` + ## Helpers From `spec_support/helpers.rb`: diff --git a/rb/lib/selenium/webdriver/support/guards.rb b/rb/lib/selenium/webdriver/support/guards.rb index 694b078f4eda0..fa5465c7f2e3c 100644 --- a/rb/lib/selenium/webdriver/support/guards.rb +++ b/rb/lib/selenium/webdriver/support/guards.rb @@ -52,12 +52,18 @@ def disposition if !skipping_guard.nil? [:skip, skipping_guard.message] elsif !pending_guard.nil? && ENV.fetch('SKIP_PENDING', nil) - [:skip, pending_guard.message] - elsif !pending_guard.nil? + [:skip, "(skipped by SKIP_PENDING) #{pending_guard.message}"] + elsif !pending_guard.nil? && !pending_guard.exception? [:pending, pending_guard.message] end end + # The deferred `exception:` pending guard, evaluated against the failure after the run, else nil. + def pending_exception_guard + guard = pending_guard + guard if disposition.nil? && guard&.exception? + end + def satisfied?(guard) @guard_conditions.all? { |condition| condition.satisfied?(guard) } end diff --git a/rb/lib/selenium/webdriver/support/guards/guard.rb b/rb/lib/selenium/webdriver/support/guards/guard.rb index 538493dcb8fcc..015381bd01a2a 100644 --- a/rb/lib/selenium/webdriver/support/guards/guard.rb +++ b/rb/lib/selenium/webdriver/support/guards/guard.rb @@ -30,7 +30,7 @@ class Guard attr_reader :guarded, :type, :messages, :reason, :tracker def initialize(guarded, type, guards = nil) - @guarded = guarded + @guarded = guarded.dup @tracker = guards&.bug_tracker || '' @messages = guards&.messages || {} @messages[:unknown] = 'TODO: Investigate why this is failing and file a bug report' @@ -47,7 +47,7 @@ def message when Symbol messages[reason] else - "Guarded by #{guarded};" + "#{type.to_s.tr('_', ' ')} #{guarded};" end case type @@ -57,8 +57,10 @@ def message "Test skipped because it is unreliable in this configuration; #{details}" when :skip_unless, :exclusive "Test does not apply to this configuration; #{details}" - else + when :pending_if, :pending_unless, :except, :only "Test guarded; #{details}" + else + raise ArgumentError, "unknown guard type: #{type}" end end @@ -81,6 +83,21 @@ def exclude? def exclusive? @type == :skip_unless || @type == :exclusive end + + # A pending guard that only applies when the failure matches an expected exception. + def exception? + (except? || only?) && !@guarded[:exception].nil? + end + + # Whether the exception is the guard's `exception:` class and matches its optional `message:` + # (Regexp pattern or exact String), following RSpec's `raise_error` semantics. + def matches_exception?(exception) + spec = @guarded[:exception] + return false unless spec && exception.is_a?(spec[:class]) + + message = spec[:message] + message.nil? || (message.is_a?(Regexp) ? message.match?(exception.message) : message == exception.message) + end end # Guard end # Guards end # Support diff --git a/rb/sig/lib/selenium/webdriver/support/guards.rbs b/rb/sig/lib/selenium/webdriver/support/guards.rbs index d9d126369b105..634cf9aa98254 100644 --- a/rb/sig/lib/selenium/webdriver/support/guards.rbs +++ b/rb/sig/lib/selenium/webdriver/support/guards.rbs @@ -44,6 +44,8 @@ module Selenium def disposition: () -> Array[untyped]? + def pending_exception_guard: () -> untyped + def satisfied?: (untyped guard) -> untyped private diff --git a/rb/sig/lib/selenium/webdriver/support/guards/guard.rbs b/rb/sig/lib/selenium/webdriver/support/guards/guard.rbs index bc6ba1576645e..6117b73dbaec3 100644 --- a/rb/sig/lib/selenium/webdriver/support/guards/guard.rbs +++ b/rb/sig/lib/selenium/webdriver/support/guards/guard.rbs @@ -51,6 +51,10 @@ module Selenium def exclude?: () -> untyped def exclusive?: () -> untyped + + def exception?: () -> untyped + + def matches_exception?: (untyped exception) -> untyped end end end diff --git a/rb/spec/integration/selenium/webdriver/spec_helper.rb b/rb/spec/integration/selenium/webdriver/spec_helper.rb index f921d2298192d..a8e15a3b6b5dd 100644 --- a/rb/spec/integration/selenium/webdriver/spec_helper.rb +++ b/rb/spec/integration/selenium/webdriver/spec_helper.rb @@ -47,6 +47,41 @@ def example_finished(notification) end end +def create_guards(example) + guards = WebDriver::Support::Guards.new(example, bug_tracker: 'https://github.com/SeleniumHQ/selenium/issues') + guards.add_condition(:driver, GlobalTestEnv.driver) + guards.add_condition(:browser, GlobalTestEnv.browser) + guards.add_condition(:browser_family, GlobalTestEnv.browser_family) + guards.add_condition(:ci, WebDriver::Platform.ci) + guards.add_condition(:platform, WebDriver::Platform.os) + guards.add_condition(:headless, !ENV['HEADLESS'].nil?) + guards.add_condition(:bidi, !ENV['WEBDRIVER_BIDI'].nil?) + guards.add_condition(:rbe, GlobalTestEnv.rbe?) + guards.add_condition(:version, GlobalTestEnv.browser_version) + guards +end + +# resolves to error instead of pending if provided exception values do not match +def resolve_pending_exception(procsy, guard) + example = procsy.example + exception = example.exception + + if exception.nil? + RSpec::Core::Pending.mark_pending!(example, guard.message) + RSpec::Core::Pending.mark_fixed!(example) + raise RSpec::Core::Pending::PendingExampleFixedError + elsif guard.matches_exception?(exception) + RSpec::Core::Pending.mark_pending!(example, guard.message) + example.display_exception = exception + else + expected = guard.guarded[:exception] + example.display_exception = exception.exception( + "#{exception.message}\n\nExpected test to fail with " \ + "#{expected[:class]}: #{expected[:message]&.inspect}; #{guard.message}" + ) + end +end + RSpec.configure do |c| c.define_derived_metadata do |meta| meta[:aggregate_failures] = true @@ -69,19 +104,19 @@ def example_finished(notification) c.run_all_when_everything_filtered = true c.default_formatter = c.files_to_run.count > 1 ? 'progress' : 'doc' - c.before do |example| - guards = WebDriver::Support::Guards.new(example, bug_tracker: 'https://github.com/SeleniumHQ/selenium/issues') - guards.add_condition(:driver, GlobalTestEnv.driver) - guards.add_condition(:browser, GlobalTestEnv.browser) - guards.add_condition(:browser_family, GlobalTestEnv.browser_family) - guards.add_condition(:ci, WebDriver::Platform.ci) - guards.add_condition(:platform, WebDriver::Platform.os) - guards.add_condition(:headless, !ENV['HEADLESS'].nil?) - guards.add_condition(:bidi, !ENV['WEBDRIVER_BIDI'].nil?) - guards.add_condition(:rbe, GlobalTestEnv.rbe?) - guards.add_condition(:version, GlobalTestEnv.browser_version) - - results = guards.disposition + c.around do |procsy| + @guards = create_guards(procsy) + + # our `c.before` hook runs here before the example body + procsy.run + + guard = @guards.pending_exception_guard + resolve_pending_exception(procsy, guard) if guard + end + + # separate c.before hook needed to support traditional skip and pending resolutions + c.before do + results = @guards.disposition send(*results) if results end end diff --git a/rb/spec/unit/selenium/webdriver/guard_spec.rb b/rb/spec/unit/selenium/webdriver/guard_spec.rb index 3821cc3d38072..c7280ac3c7c24 100644 --- a/rb/spec/unit/selenium/webdriver/guard_spec.rb +++ b/rb/spec/unit/selenium/webdriver/guard_spec.rb @@ -81,9 +81,8 @@ module Support expect(guards.disposition.size).to eq(2) expect(guards.disposition[0]).to eq(ENV.fetch('SKIP_PENDING', nil) ? :skip : :pending) - message = /Test guarded;/ - guarded_by = /Guarded by {:?foo[:=][ >]false, :?reason[:=][ >]"No reason given"};/ - expect(guards.disposition[1]).to match(/#{message} #{guarded_by}/) + guarded_by = /except {:?foo[:=][ >]false, :?reason[:=][ >]"No reason given"};/ + expect(guards.disposition[1]).to match(/Test guarded; #{guarded_by}/) end it 'is skipped without provided reason', exclusive: {foo: true} do |example| @@ -93,11 +92,50 @@ module Support expect(guards.disposition.size).to eq(2) expect(guards.disposition[0]).to eq :skip message = /Test does not apply to this configuration;/ - guarded_by = /Guarded by {:?foo[:=][ >]true, :?reason[:=][ >]"No reason given"};/ + guarded_by = /exclusive {:?foo[:=][ >]true, :?reason[:=][ >]"No reason given"};/ expect(guards.disposition[1]).to match(/#{message} #{guarded_by}/) end end + describe '#pending_exception_guard' do + it 'returns the active guard carrying an exception clause', + except: {foo: false, exception: {class: RuntimeError}} do |example| + guards = described_class.new(example) + guards.add_condition(:foo, false) + + original = ENV.delete('SKIP_PENDING') + expect(guards.pending_exception_guard).to be_a(Guards::Guard) + ensure + ENV['SKIP_PENDING'] = original unless original.nil? + end + + it 'returns nil when the active pending guard has no exception clause', + except: {foo: false} do |example| + guards = described_class.new(example) + guards.add_condition(:foo, false) + + expect(guards.pending_exception_guard).to be_nil + end + + it 'returns nil when no pending guard is active' do |example| + guards = described_class.new(example) + + expect(guards.pending_exception_guard).to be_nil + end + + it 'returns nil when SKIP_PENDING skips the guard', + except: {foo: false, exception: {class: RuntimeError}} do |example| + guards = described_class.new(example) + guards.add_condition(:foo, false) + + original = ENV.fetch('SKIP_PENDING', nil) + ENV['SKIP_PENDING'] = 'true' + expect(guards.pending_exception_guard).to be_nil + ensure + original.nil? ? ENV.delete('SKIP_PENDING') : ENV['SKIP_PENDING'] = original + end + end + describe '#satisfied?' do it 'evaluates guard' do |example| guards = described_class.new(example) @@ -151,6 +189,14 @@ module Support expect(guard.type).to eq :only end + it 'does not mutate the given guarded Hash' do + original = {foo: 7}.freeze + guard = described_class.new(original, :only) + + expect(guard.guarded).to eq(foo: 7, reason: 'No reason given') + expect(original).to eq(foo: 7) + end + it 'creates unknown message by default' do guard = described_class.new({foo: 7}, :only) expect(guard.messages).to include(unknown: 'TODO: Investigate why this is failing and file a bug report') @@ -166,7 +212,7 @@ module Support it 'defaults to no reason given' do guard = described_class.new({}, :only) - expect(guard.message).to match(/Test guarded; Guarded by {:?reason[:=][ >]"No reason given"};/) + expect(guard.message).to match(/Test guarded; only {:?reason[:=][ >]"No reason given"};/) end it 'accepts integer' do |example| @@ -179,7 +225,7 @@ module Support it 'accepts String' do guard = described_class.new({reason: 'because'}, :only) - expect(guard.message).to match(/Test guarded; Guarded by {:?reason[:=][ >]"because"};/) + expect(guard.message).to match(/Test guarded; only {:?reason[:=][ >]"because"};/) end it 'accepts Symbol of known message' do @@ -200,46 +246,85 @@ module Support guard = described_class.new({reason: 'because'}, :skip_if) message = /Test skipped because it breaks test run;/ - guarded_by = /Guarded by {:?reason[:=][ >]"because"};/ - expect(guard.message).to match(/#{message} #{guarded_by}/) + expect(guard.message).to match(/#{message} skip if {:?reason[:=][ >]"because"};/) end - it 'has special message for skip_unless' do - guard = described_class.new({reason: 'because'}, :skip_unless) + it 'has a generic message for pending_if' do + guard = described_class.new({reason: 'because'}, :pending_if) - message = /Test does not apply to this configuration;/ - guarded_by = /Guarded by {:?reason[:=][ >]"because"};/ - expect(guard.message).to match(/#{message} #{guarded_by}/) + expect(guard.message).to match(/Test guarded; pending if {:?reason[:=][ >]"because"};/) end - it 'has a generic message for pending_if' do - guard = described_class.new({reason: 'because'}, :pending_if) + it 'has special message for flaky' do + guard = described_class.new({reason: 'because'}, :flaky) - expect(guard.message).to match(/Test guarded; Guarded by {:?reason[:=][ >]"because"};/) + message = /Test skipped because it is unreliable in this configuration;/ + expect(guard.message).to match(/#{message} flaky {:?reason[:=][ >]"because"};/) end + end - it 'has special message for exclude' do - guard = described_class.new({reason: 'because'}, :exclude) + describe '#exception?' do + it 'is true for a pending guard with an exception clause' do + guard = described_class.new({condition: :guarded, exception: {class: RuntimeError}}, :pending_if) + expect(guard).to be_exception + end - message = /Test skipped because it breaks test run;/ - guarded_by = /Guarded by {:?reason[:=][ >]"because"};/ - expect(guard.message).to match(/#{message} #{guarded_by}/) + it 'is false for a pending guard without an exception clause' do + guard = described_class.new({condition: :guarded}, :pending_if) + expect(guard).not_to be_exception end - it 'has special message for flaky' do - guard = described_class.new({reason: 'because'}, :flaky) + it 'is false for a non-pending guard type' do + guard = described_class.new({condition: :guarded, exception: {class: RuntimeError}}, :skip_if) + expect(guard).not_to be_exception + end + end - message = /Test skipped because it is unreliable in this configuration;/ - guarded_by = /Guarded by {:?reason[:=][ >]"because"};/ - expect(guard.message).to match(/#{message} #{guarded_by}/) + describe '#matches_exception?' do + context 'with a class only' do + it 'matches an instance of that class' do + guard = described_class.new({exception: {class: ArgumentError}}, :pending_if) + expect(guard.matches_exception?(ArgumentError.new('boom'))).to be true + end + + it 'does not match a different class' do + guard = described_class.new({exception: {class: ArgumentError}}, :pending_if) + expect(guard.matches_exception?(RuntimeError.new('boom'))).to be false + end end - it 'has special message for exclusive' do - guard = described_class.new({reason: 'because'}, :exclusive) + context 'with a Regexp message' do + let(:guard) { described_class.new({exception: {class: RuntimeError, message: /unknown/}}, :pending_if) } - message = /Test does not apply to this configuration;/ - guarded_by = /Guarded by {:?reason[:=][ >]"because"};/ - expect(guard.message).to match(/#{message} #{guarded_by}/) + it 'matches the pattern anywhere in the message' do + expect(guard.matches_exception?(RuntimeError.new('got: unknown command'))).to be true + end + + it 'does not match when the pattern is absent' do + expect(guard.matches_exception?(RuntimeError.new('invalid argument'))).to be false + end + end + + context 'with a String message' do + let(:guard) { described_class.new({exception: {class: RuntimeError, message: 'unknown'}}, :pending_if) } + + it 'matches the exact message' do + expect(guard.matches_exception?(RuntimeError.new('unknown'))).to be true + end + + it 'does not match a substring' do + expect(guard.matches_exception?(RuntimeError.new('unknown command'))).to be false + end + end + + it 'does not match without an exception clause' do + guard = described_class.new({condition: :guarded}, :pending_if) + expect(guard.matches_exception?(RuntimeError.new('boom'))).to be false + end + + it 'does not match a nil exception' do + guard = described_class.new({exception: {class: RuntimeError}}, :pending_if) + expect(guard.matches_exception?(nil)).to be false end end end From de243e1e4a269ef45cc75583d61602d32ca1b257 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 21:29:46 -0500 Subject: [PATCH 24/56] [build] update rerun with debug so console sees all test output --- scripts/github-actions/rerun-failures.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/github-actions/rerun-failures.sh b/scripts/github-actions/rerun-failures.sh index d3eaa38c2afac..1e13b8ca1f000 100755 --- a/scripts/github-actions/rerun-failures.sh +++ b/scripts/github-actions/rerun-failures.sh @@ -35,7 +35,7 @@ else # Reduce the run command to its bazel invocation base_cmd=$(sed -E 's/^.*;[[:space:]]*//; s/ --target_pattern_file=[^[:space:]]+//; s| //[^ ]*||g' <<<"$RUN_CMD") fi -rerun_cmd="$base_cmd --test_env=SE_DEBUG=true --flaky_test_attempts=1 --target_pattern_file=build/failures/_run1.txt" +rerun_cmd="$base_cmd --test_env=SE_DEBUG=true --test_output=all --flaky_test_attempts=1 --target_pattern_file=build/failures/_run1.txt" echo "Rerunning tests: $rerun_cmd" set +e { From 812df708bb283b6ce8854e1ef32ef0b8d0c36ec0 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 21:30:28 -0500 Subject: [PATCH 25/56] [build] failing .NET integration tests need to rerun with debug --- .github/workflows/ci-dotnet.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci-dotnet.yml b/.github/workflows/ci-dotnet.yml index c59c8df8927ac..6b059e79271b2 100644 --- a/.github/workflows/ci-dotnet.yml +++ b/.github/workflows/ci-dotnet.yml @@ -31,6 +31,7 @@ jobs: name: Browser Tests os: windows needs-display: true + rerun-with-debug: true run: | bazel test //dotnet/test/webdriver:ElementFindingTests-firefox //dotnet/test/webdriver:ElementFindingTests-chrome @@ -41,5 +42,6 @@ jobs: name: Remote Tests os: windows needs-display: true + rerun-with-debug: true run: | bazel test //dotnet/test/remote --flaky_test_attempts=3 From 10eeb94af04c183629f719156aaf896e4a2e4435 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 21:32:56 -0500 Subject: [PATCH 26/56] [dotnet][java][py][rb] pass --enable-chrome-logs unless CHROME_LOG_FILE is set (#17858) [dotnet][java][py][rb] pass --enable-chrome-logs unless CHROME_LOG_FILE is set or on Windows (#16201) --- .../Chromium/ChromiumDriverService.cs | 8 +++-- .../selenium/chrome/ChromeDriverService.java | 6 ++++ .../selenium/edge/EdgeDriverService.java | 6 ++++ .../chrome/ChromeDriverServiceTest.java | 33 ++++++++++++++++--- .../selenium/edge/EdgeDriverServiceTest.java | 33 ++++++++++++++++--- py/selenium/webdriver/chrome/service.py | 4 ++- py/selenium/webdriver/edge/service.py | 5 +-- rb/lib/selenium/webdriver/chrome/service.rb | 5 ++- rb/lib/selenium/webdriver/edge/service.rb | 5 ++- .../selenium/webdriver/chrome/service_spec.rb | 14 +++++--- .../selenium/webdriver/common/service_spec.rb | 4 +-- .../selenium/webdriver/edge/service_spec.rb | 16 ++++++--- 12 files changed, 111 insertions(+), 28 deletions(-) diff --git a/dotnet/src/webdriver/Chromium/ChromiumDriverService.cs b/dotnet/src/webdriver/Chromium/ChromiumDriverService.cs index 83bb05ae9a992..d6c8321139f95 100644 --- a/dotnet/src/webdriver/Chromium/ChromiumDriverService.cs +++ b/dotnet/src/webdriver/Chromium/ChromiumDriverService.cs @@ -174,8 +174,12 @@ protected override string CommandLineArguments argsBuilder.Append($" -allowed-ips={this.AllowedIPAddresses}"); } - // Unconditionally redirect browser logs to the same log as the driver - argsBuilder.Append(" --enable-chrome-logs"); + // Redirect browser logs to the driver log, unless the user set CHROME_LOG_FILE, which + // --enable-chrome-logs would otherwise override. + if (Environment.GetEnvironmentVariable("CHROME_LOG_FILE") is null) + { + argsBuilder.Append(" --enable-chrome-logs"); + } return argsBuilder.ToString(); } diff --git a/java/src/org/openqa/selenium/chrome/ChromeDriverService.java b/java/src/org/openqa/selenium/chrome/ChromeDriverService.java index d19610cf8bf37..1b9e3d315da7c 100644 --- a/java/src/org/openqa/selenium/chrome/ChromeDriverService.java +++ b/java/src/org/openqa/selenium/chrome/ChromeDriverService.java @@ -31,6 +31,7 @@ import java.util.Map; import org.jspecify.annotations.Nullable; import org.openqa.selenium.Capabilities; +import org.openqa.selenium.Platform; import org.openqa.selenium.WebDriverException; import org.openqa.selenium.chromium.ChromiumDriverLogLevel; import org.openqa.selenium.internal.Debug; @@ -296,6 +297,11 @@ protected void loadSystemProperties() { protected List createArgs() { List args = new ArrayList<>(); args.add(String.format(Locale.ROOT, "--port=%d", getPort())); + // --enable-chrome-logs (--enable-logging=stderr) fails to launch Chrome on Windows, and would + // override a user's CHROME_LOG_FILE; skip it in those cases. + if (!Platform.getCurrent().is(Platform.WINDOWS) && System.getenv("CHROME_LOG_FILE") == null) { + args.add("--enable-chrome-logs"); + } // Readable timestamp and append logs only work if log path is specified in args // Cannot use logOutput because goog:loggingPrefs requires --log-path get sent diff --git a/java/src/org/openqa/selenium/edge/EdgeDriverService.java b/java/src/org/openqa/selenium/edge/EdgeDriverService.java index 689822ea03dce..6d16275483c8b 100644 --- a/java/src/org/openqa/selenium/edge/EdgeDriverService.java +++ b/java/src/org/openqa/selenium/edge/EdgeDriverService.java @@ -31,6 +31,7 @@ import java.util.Map; import org.jspecify.annotations.Nullable; import org.openqa.selenium.Capabilities; +import org.openqa.selenium.Platform; import org.openqa.selenium.WebDriverException; import org.openqa.selenium.chromium.ChromiumDriverLogLevel; import org.openqa.selenium.internal.Debug; @@ -297,6 +298,11 @@ protected void loadSystemProperties() { protected List createArgs() { List args = new ArrayList<>(); args.add(String.format(Locale.ROOT, "--port=%d", getPort())); + // yes, it is --enable-chrome-logs, even on msedgedriver; --enable-logging=stderr fails to + // launch the browser on Windows, and would override a user's CHROME_LOG_FILE; skip it then. + if (!Platform.getCurrent().is(Platform.WINDOWS) && System.getenv("CHROME_LOG_FILE") == null) { + args.add("--enable-chrome-logs"); + } // Readable timestamp and append logs only work if log path is specified in args // Cannot use logOutput because goog:loggingPrefs requires --log-path get sent diff --git a/java/test/org/openqa/selenium/chrome/ChromeDriverServiceTest.java b/java/test/org/openqa/selenium/chrome/ChromeDriverServiceTest.java index 881e239fe23eb..259695c9e92a9 100644 --- a/java/test/org/openqa/selenium/chrome/ChromeDriverServiceTest.java +++ b/java/test/org/openqa/selenium/chrome/ChromeDriverServiceTest.java @@ -26,9 +26,12 @@ import java.io.File; import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.openqa.selenium.Platform; import org.openqa.selenium.chromium.ChromiumDriverLogLevel; @Tag("UnitTests") @@ -60,30 +63,50 @@ void testScoring() { void logLevelLastWins() { ChromeDriverService.Builder builderMock = spy(ChromeDriverService.Builder.class); - List silentLast = List.of("--port=1", "--log-level=OFF"); + List silentLast = expected("--port=1", "--enable-chrome-logs", "--log-level=OFF"); builderMock.withLogLevel(ChromiumDriverLogLevel.ALL).usingPort(1).withSilent(true).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(silentLast), any()); - List silentFirst = List.of("--port=1", "--log-level=DEBUG"); + List silentFirst = expected("--port=1", "--enable-chrome-logs", "--log-level=DEBUG"); builderMock.withSilent(true).withLogLevel(ChromiumDriverLogLevel.DEBUG).usingPort(1).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(silentFirst), any()); - List verboseLast = List.of("--port=1", "--log-level=ALL"); + List verboseLast = expected("--port=1", "--enable-chrome-logs", "--log-level=ALL"); builderMock.withLogLevel(ChromiumDriverLogLevel.OFF).usingPort(1).withVerbose(true).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(verboseLast), any()); - List verboseFirst = List.of("--port=1", "--log-level=INFO"); + List verboseFirst = expected("--port=1", "--enable-chrome-logs", "--log-level=INFO"); builderMock.withVerbose(true).withLogLevel(ChromiumDriverLogLevel.INFO).usingPort(1).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(verboseFirst), any()); } + @Test + void enablesChromeLogsByDefault() { + ChromeDriverService.Builder builderMock = spy(ChromeDriverService.Builder.class); + + builderMock.usingPort(1).build(); + verify(builderMock) + .createDriverService( + any(), anyInt(), any(), eq(expected("--port=1", "--enable-chrome-logs")), any()); + } + // Setting these to false makes no sense; we're just going to ignore it. @Test void ignoreFalseLogging() { ChromeDriverService.Builder builderMock = spy(ChromeDriverService.Builder.class); - List falseSilent = List.of("--port=1", "--log-level=DEBUG"); + List falseSilent = expected("--port=1", "--enable-chrome-logs", "--log-level=DEBUG"); builderMock.withLogLevel(ChromiumDriverLogLevel.DEBUG).usingPort(1).withSilent(false).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(falseSilent), any()); } + + // --enable-chrome-logs is only passed on non-Windows platforms; drop it from the expected args + // when the tests run on Windows so they stay correct there. + private static List expected(String... args) { + List result = new ArrayList<>(Arrays.asList(args)); + if (Platform.getCurrent().is(Platform.WINDOWS)) { + result.remove("--enable-chrome-logs"); + } + return result; + } } diff --git a/java/test/org/openqa/selenium/edge/EdgeDriverServiceTest.java b/java/test/org/openqa/selenium/edge/EdgeDriverServiceTest.java index 2968ba1ee0f78..5a37400290c15 100644 --- a/java/test/org/openqa/selenium/edge/EdgeDriverServiceTest.java +++ b/java/test/org/openqa/selenium/edge/EdgeDriverServiceTest.java @@ -26,9 +26,12 @@ import java.io.File; import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.openqa.selenium.Platform; import org.openqa.selenium.chromium.ChromiumDriverLogLevel; @Tag("UnitTests") @@ -60,30 +63,50 @@ void testScoring() { void logLevelLastWins() { EdgeDriverService.Builder builderMock = spy(EdgeDriverService.Builder.class); - List silentLast = List.of("--port=1", "--log-level=OFF"); + List silentLast = expected("--port=1", "--enable-chrome-logs", "--log-level=OFF"); builderMock.withLoglevel(ChromiumDriverLogLevel.ALL).usingPort(1).withSilent(true).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(silentLast), any()); - List silentFirst = List.of("--port=1", "--log-level=DEBUG"); + List silentFirst = expected("--port=1", "--enable-chrome-logs", "--log-level=DEBUG"); builderMock.withSilent(true).withLoglevel(ChromiumDriverLogLevel.DEBUG).usingPort(1).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(silentFirst), any()); - List verboseLast = List.of("--port=1", "--log-level=ALL"); + List verboseLast = expected("--port=1", "--enable-chrome-logs", "--log-level=ALL"); builderMock.withLoglevel(ChromiumDriverLogLevel.OFF).usingPort(1).withVerbose(true).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(verboseLast), any()); - List verboseFirst = List.of("--port=1", "--log-level=INFO"); + List verboseFirst = expected("--port=1", "--enable-chrome-logs", "--log-level=INFO"); builderMock.withVerbose(true).withLoglevel(ChromiumDriverLogLevel.INFO).usingPort(1).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(verboseFirst), any()); } + @Test + void enablesChromeLogsByDefault() { + EdgeDriverService.Builder builderMock = spy(EdgeDriverService.Builder.class); + + builderMock.usingPort(1).build(); + verify(builderMock) + .createDriverService( + any(), anyInt(), any(), eq(expected("--port=1", "--enable-chrome-logs")), any()); + } + // Setting these to false makes no sense; we're just going to ignore it. @Test void ignoreFalseLogging() { EdgeDriverService.Builder builderMock = spy(EdgeDriverService.Builder.class); - List falseSilent = List.of("--port=1", "--log-level=DEBUG"); + List falseSilent = expected("--port=1", "--enable-chrome-logs", "--log-level=DEBUG"); builderMock.withLoglevel(ChromiumDriverLogLevel.DEBUG).usingPort(1).withSilent(false).build(); verify(builderMock).createDriverService(any(), anyInt(), any(), eq(falseSilent), any()); } + + // --enable-chrome-logs is only passed on non-Windows platforms; drop it from the expected args + // when the tests run on Windows so they stay correct there. + private static List expected(String... args) { + List result = new ArrayList<>(Arrays.asList(args)); + if (Platform.getCurrent().is(Platform.WINDOWS)) { + result.remove("--enable-chrome-logs"); + } + return result; + } } diff --git a/py/selenium/webdriver/chrome/service.py b/py/selenium/webdriver/chrome/service.py index ad9d8dc4b5760..c48cdd61a74ee 100644 --- a/py/selenium/webdriver/chrome/service.py +++ b/py/selenium/webdriver/chrome/service.py @@ -58,7 +58,9 @@ def __init__( ) def command_line_args(self) -> list[str]: - return ["--enable-chrome-logs", f"--port={self.port}"] + self._service_args + # skip when CHROME_LOG_FILE is set; --enable-chrome-logs would override the user's log file + args = [] if "CHROME_LOG_FILE" in self.env else ["--enable-chrome-logs"] + return args + [f"--port={self.port}"] + self._service_args @property def service_args(self) -> Sequence[str]: diff --git a/py/selenium/webdriver/edge/service.py b/py/selenium/webdriver/edge/service.py index 420ee753a16df..229661982dd0e 100644 --- a/py/selenium/webdriver/edge/service.py +++ b/py/selenium/webdriver/edge/service.py @@ -58,8 +58,9 @@ def __init__( ) def command_line_args(self) -> list[str]: - # yes, it is --enable-chrome-logs, even on msedgedriver - return ["--enable-chrome-logs", f"--port={self.port}"] + self._service_args + # yes, it is --enable-chrome-logs, even on msedgedriver; skip when CHROME_LOG_FILE is set + args = [] if "CHROME_LOG_FILE" in self.env else ["--enable-chrome-logs"] + return args + [f"--port={self.port}"] + self._service_args @property def service_args(self) -> Sequence[str]: diff --git a/rb/lib/selenium/webdriver/chrome/service.rb b/rb/lib/selenium/webdriver/chrome/service.rb index 95c7657bc7d4b..049aafb666225 100644 --- a/rb/lib/selenium/webdriver/chrome/service.rb +++ b/rb/lib/selenium/webdriver/chrome/service.rb @@ -27,8 +27,11 @@ class Service < WebDriver::Service DRIVER_PATH_ENV_KEY = 'SE_CHROMEDRIVER' def initialize(args: nil, **) + args = Array(args.dup) + # skip when CHROME_LOG_FILE is set; --enable-chrome-logs would override the user's log file + args << '--enable-chrome-logs' unless ENV.key?('CHROME_LOG_FILE') || args.include?('--enable-chrome-logs') + if ENV.key?('SE_DEBUG') - args = Array(args.dup) warn_driver_log_override if args.reject! { |arg| arg.include?('log-level') || arg.include?('silent') } args << '--verbose' end diff --git a/rb/lib/selenium/webdriver/edge/service.rb b/rb/lib/selenium/webdriver/edge/service.rb index 54721e6d3876f..fd55d9870ef3c 100644 --- a/rb/lib/selenium/webdriver/edge/service.rb +++ b/rb/lib/selenium/webdriver/edge/service.rb @@ -27,8 +27,11 @@ class Service < WebDriver::Service DRIVER_PATH_ENV_KEY = 'SE_EDGEDRIVER' def initialize(args: nil, **) + args = Array(args.dup) + # yes, it is --enable-chrome-logs, even on msedgedriver; skip when CHROME_LOG_FILE is set + args << '--enable-chrome-logs' unless ENV.key?('CHROME_LOG_FILE') || args.include?('--enable-chrome-logs') + if ENV.key?('SE_DEBUG') - args = Array(args.dup) warn_driver_log_override if args.reject! { |arg| arg.include?('log-level') || arg.include?('silent') } args << '--verbose' end diff --git a/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb b/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb index 9e9e3e65b697e..fb915d6af0e08 100644 --- a/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb @@ -52,10 +52,16 @@ module Chrome expect(service.host).to eq Platform.localhost end - it 'does not create args by default' do + it 'enables chrome logs by default' do service = described_class.new - expect(service.extra_args).to be_empty + expect(service.extra_args).to eq ['--enable-chrome-logs'] + end + + it 'does not duplicate --enable-chrome-logs when provided' do + service = described_class.new(args: ['--enable-chrome-logs']) + + expect(service.extra_args).to eq ['--enable-chrome-logs'] end it 'uses sets log path to stdout' do @@ -74,13 +80,13 @@ module Chrome service = described_class.new(log: '/path/to/log.txt') expect(service.log).to be_nil - expect(service.args).to eq ['--log-path=/path/to/log.txt'] + expect(service.args).to eq ['--enable-chrome-logs', '--log-path=/path/to/log.txt'] end it 'uses provided args' do service = described_class.new(args: ['--foo', '--bar']) - expect(service.extra_args).to eq ['--foo', '--bar'] + expect(service.extra_args).to eq ['--foo', '--bar', '--enable-chrome-logs'] end context 'when SE_DEBUG is set' do diff --git a/rb/spec/unit/selenium/webdriver/common/service_spec.rb b/rb/spec/unit/selenium/webdriver/common/service_spec.rb index 13eb9fef864b3..fedfff71a782b 100644 --- a/rb/spec/unit/selenium/webdriver/common/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/common/service_spec.rb @@ -37,13 +37,13 @@ module WebDriver it 'creates Chrome instance' do service = described_class.chrome(args: args) expect(service).to be_a(Chrome::Service) - expect(service.args).to eq args + expect(service.args).to eq(args + %w[--enable-chrome-logs]) end it 'creates Edge instance' do service = described_class.edge(args: args) expect(service).to be_a(Edge::Service) - expect(service.args).to eq args + expect(service.args).to eq(args + %w[--enable-chrome-logs]) end it 'creates Firefox instance' do diff --git a/rb/spec/unit/selenium/webdriver/edge/service_spec.rb b/rb/spec/unit/selenium/webdriver/edge/service_spec.rb index 9d5a7346cac43..27507d9e09f63 100644 --- a/rb/spec/unit/selenium/webdriver/edge/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/edge/service_spec.rb @@ -51,10 +51,16 @@ module Edge expect(service.host).to eq Platform.localhost end - it 'does not create args by default' do + it 'enables chrome logs by default' do service = described_class.new - expect(service.extra_args).to be_empty + expect(service.extra_args).to eq ['--enable-chrome-logs'] + end + + it 'does not duplicate --enable-chrome-logs when provided' do + service = described_class.new(args: ['--enable-chrome-logs']) + + expect(service.extra_args).to eq ['--enable-chrome-logs'] end it 'uses sets log path to stdout' do @@ -73,13 +79,13 @@ module Edge service = described_class.chrome(log: '/path/to/log.txt') expect(service.log).to be_nil - expect(service.args).to eq ['--log-path=/path/to/log.txt'] + expect(service.args).to eq ['--enable-chrome-logs', '--log-path=/path/to/log.txt'] end it 'uses provided args' do service = described_class.new(args: ['--foo', '--bar']) - expect(service.extra_args).to eq ['--foo', '--bar'] + expect(service.extra_args).to eq ['--foo', '--bar', '--enable-chrome-logs'] end context 'when SE_DEBUG is set' do @@ -157,7 +163,7 @@ module Edge service = described_class.chrome(log: '/path/to/log.txt') expect(service.log).to be_nil - expect(service.args).to eq ['--log-path=/path/to/log.txt'] + expect(service.args).to eq ['--enable-chrome-logs', '--log-path=/path/to/log.txt'] end end end From 0ad0c51b9c693b10d2619fe293a8e8891a6c125c Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 21:33:37 -0500 Subject: [PATCH 27/56] [rb] support custom vendor specific capabilities in options classes (#17862) * [rb] add add_chromium_option/add_firefox_option escape hatches and merge hand-built vendor options * [rb] normalize vendor option names to string keys --- rb/lib/selenium/webdriver/chromium/options.rb | 20 ++++++++++ rb/lib/selenium/webdriver/common/options.rb | 14 ++++++- rb/lib/selenium/webdriver/firefox/options.rb | 19 ++++++++++ .../selenium/webdriver/chromium/options.rbs | 4 ++ .../lib/selenium/webdriver/common/options.rbs | 2 + .../selenium/webdriver/firefox/options.rbs | 4 ++ .../selenium/webdriver/chrome/options_spec.rb | 38 +++++++++++++++++++ .../webdriver/firefox/options_spec.rb | 23 +++++++++++ 8 files changed, 123 insertions(+), 1 deletion(-) diff --git a/rb/lib/selenium/webdriver/chromium/options.rb b/rb/lib/selenium/webdriver/chromium/options.rb index a1e4de39fcda1..4a2a3b72dca05 100644 --- a/rb/lib/selenium/webdriver/chromium/options.rb +++ b/rb/lib/selenium/webdriver/chromium/options.rb @@ -86,6 +86,7 @@ def initialize(profile: nil, **) @logging_prefs = options.delete(:logging_prefs) || {} @encoded_extensions = @options.delete(:encoded_extensions) || [] @extensions = [] + @vendor_options = {} @options.delete(:extensions).each { |ext| validate_extension(ext) } end @@ -184,6 +185,24 @@ def add_emulation(**opts) @options[:emulation] = opts end + # + # Add a Chromium-specific capability nested in the browser options object + # (e.g. `goog:chromeOptions` / `ms:edgeOptions`) that is not yet exposed + # through a dedicated method. This is the escape hatch for legitimate + # vendor capabilities the bindings do not model. + # + # @example Set an option not handled by other methods + # options = Selenium::WebDriver::Chrome::Options.new + # options.add_chromium_option('unhandledCapability', 'value') + # + # @param [String, Symbol] name Name of the capability, as expected by the driver + # @param [Object] value Value of the capability + # + + def add_chromium_option(name, value) + @vendor_options[name.to_s] = value + end + # # Enables mobile browser use on Android. # @@ -209,6 +228,7 @@ def process_browser_options(browser_options) enable_logging(browser_options) unless @logging_prefs.empty? options = browser_options[self.class::KEY] + options.merge!(@vendor_options) options['binary'] ||= binary_path if binary_path if @profile diff --git a/rb/lib/selenium/webdriver/common/options.rb b/rb/lib/selenium/webdriver/common/options.rb index 0067ec40c9905..917df4258d592 100644 --- a/rb/lib/selenium/webdriver/common/options.rb +++ b/rb/lib/selenium/webdriver/common/options.rb @@ -138,11 +138,23 @@ def as_json(*) browser_options = {self.class::KEY => browser_options} if defined?(self.class::KEY) process_browser_options(browser_options) - generate_as_json(w3c_options.merge(browser_options)) + generate_as_json(merge_browser_options(w3c_options, browser_options)) end private + # Preserve a hand-built vendor options hash (e.g. passed through #add_option) by merging it + # with the binding's own, rather than letting one silently overwrite the other. + def merge_browser_options(w3c_options, browser_options) + w3c_options.merge(browser_options) do |_key, w3c_value, browser_value| + if w3c_value.is_a?(Hash) && browser_value.is_a?(Hash) + browser_value.merge(w3c_value) + else + browser_value + end + end + end + def w3c?(key) W3C_OPTIONS.include?(key) || key.to_s.include?(':') end diff --git a/rb/lib/selenium/webdriver/firefox/options.rb b/rb/lib/selenium/webdriver/firefox/options.rb index e79e82b1d46a7..34efcedac4f59 100644 --- a/rb/lib/selenium/webdriver/firefox/options.rb +++ b/rb/lib/selenium/webdriver/firefox/options.rb @@ -69,6 +69,7 @@ def initialize(log_level: nil, **opts) @options[:prefs]['remote.active-protocols'] = 1 @options[:env] ||= {} @options[:log] ||= {level: log_level} if log_level + @vendor_options = {} process_profile(@options.delete(:profile)) end @@ -102,6 +103,23 @@ def add_preference(name, value) @options[:prefs][name] = value end + # + # Add a Firefox-specific capability nested in the `moz:firefoxOptions` + # object that is not yet exposed through a dedicated method. This is the + # escape hatch for legitimate vendor capabilities the bindings do not model. + # + # @example Set an option not handled by other methods + # options = Selenium::WebDriver::Firefox::Options.new + # options.add_firefox_option('unhandledCapability', 'value') + # + # @param [String, Symbol] name Name of the capability, as expected by geckodriver + # @param [Object] value Value of the capability + # + + def add_firefox_option(name, value) + @vendor_options[name.to_s] = value + end + # # Sets Firefox profile. # @@ -153,6 +171,7 @@ def enable_android(package: 'org.mozilla.firefox', serial_number: nil, activity: def process_browser_options(browser_options) browser_options['moz:debuggerAddress'] = true if @debugger_address options = browser_options[KEY] + options.merge!(@vendor_options) options['binary'] ||= Firefox.path if Firefox.path options['profile'] = @profile if @profile end diff --git a/rb/sig/lib/selenium/webdriver/chromium/options.rbs b/rb/sig/lib/selenium/webdriver/chromium/options.rbs index 17544d2157b78..b5d683849bba4 100644 --- a/rb/sig/lib/selenium/webdriver/chromium/options.rbs +++ b/rb/sig/lib/selenium/webdriver/chromium/options.rbs @@ -32,6 +32,8 @@ module Selenium @extensions: Array[String] + @vendor_options: Hash[String | Symbol, untyped] + attr_accessor profile: untyped attr_accessor logging_prefs: Hash[Symbol | String, untyped] @@ -54,6 +56,8 @@ module Selenium def add_emulation: (**untyped opts) -> untyped + def add_chromium_option: (untyped name, untyped value) -> untyped + def enable_android: (?package: String, ?serial_number: untyped?, ?use_running_app: untyped?, ?activity: untyped?) -> untyped def process_browser_options: (untyped browser_options) -> untyped? diff --git a/rb/sig/lib/selenium/webdriver/common/options.rbs b/rb/sig/lib/selenium/webdriver/common/options.rbs index 4df132e93445b..34c1326c19777 100644 --- a/rb/sig/lib/selenium/webdriver/common/options.rbs +++ b/rb/sig/lib/selenium/webdriver/common/options.rbs @@ -71,6 +71,8 @@ module Selenium def process_w3c_options: (untyped options) -> untyped + def merge_browser_options: (Hash[untyped, untyped] w3c_options, Hash[untyped, untyped] browser_options) -> Hash[untyped, untyped] + def process_browser_options: (untyped _browser_options) -> nil def camelize?: (untyped _key) -> true diff --git a/rb/sig/lib/selenium/webdriver/firefox/options.rbs b/rb/sig/lib/selenium/webdriver/firefox/options.rbs index 71c43c19c652f..0680084480d08 100644 --- a/rb/sig/lib/selenium/webdriver/firefox/options.rbs +++ b/rb/sig/lib/selenium/webdriver/firefox/options.rbs @@ -26,6 +26,8 @@ module Selenium @options: Hash[untyped, untyped] + @vendor_options: Hash[String | Symbol, untyped] + attr_accessor debugger_address: untyped KEY: String @@ -42,6 +44,8 @@ module Selenium def add_preference: (untyped name, untyped value) -> untyped + def add_firefox_option: (untyped name, untyped value) -> untyped + def profile=: (untyped profile) -> untyped def log_level: () -> untyped diff --git a/rb/spec/unit/selenium/webdriver/chrome/options_spec.rb b/rb/spec/unit/selenium/webdriver/chrome/options_spec.rb index 98e61626d8600..54496aa1a0d15 100644 --- a/rb/spec/unit/selenium/webdriver/chrome/options_spec.rb +++ b/rb/spec/unit/selenium/webdriver/chrome/options_spec.rb @@ -203,6 +203,35 @@ module Chrome end end + describe '#add_chromium_option' do + it 'nests a vendor capability inside the browser options object' do + options.add_chromium_option('unhandledCapability', 'value') + + expect(options.as_json['goog:chromeOptions']).to include('unhandledCapability' => 'value') + end + + it 'merges with capabilities set through dedicated methods' do + options.add_argument('foo') + options.add_chromium_option('unhandledCapability', 'value') + + chrome_options = options.as_json['goog:chromeOptions'] + expect(chrome_options['args']).to eq(['foo']) + expect(chrome_options['unhandledCapability']).to eq('value') + end + + it 'normalizes a symbol capability name to a string key' do + options.add_chromium_option(:unhandledCapability, 'value') + + expect(options.as_json['goog:chromeOptions']).to include('unhandledCapability' => 'value') + end + + it 'honors non-camelized special-casing when a symbol name matches prefs' do + options.add_chromium_option(:prefs, {'intl.accepted_languages' => 'en-US'}) + + expect(options.as_json['goog:chromeOptions']['prefs']).to eq('intl.accepted_languages' => 'en-US') + end + end + describe '#add_preference' do it 'adds a preference' do options.add_preference(:foo, 'bar') @@ -269,6 +298,15 @@ module Chrome 'goog:chromeOptions' => {}) end + it 'merges a hand-built vendor options hash instead of overwriting it' do + options.add_argument('foo') + options.add_option('goog:chromeOptions', {'detach' => true}) + + chrome_options = options.as_json['goog:chromeOptions'] + expect(chrome_options['detach']).to be(true) + expect(chrome_options['args']).to eq(['foo']) + end + it 'processes unhandled_prompt_behavior hash values' do opts = described_class.new(unhandled_prompt_behavior: { alert: :accept_and_notify, diff --git a/rb/spec/unit/selenium/webdriver/firefox/options_spec.rb b/rb/spec/unit/selenium/webdriver/firefox/options_spec.rb index 0501aa512490c..ec32d1d3b1a92 100644 --- a/rb/spec/unit/selenium/webdriver/firefox/options_spec.rb +++ b/rb/spec/unit/selenium/webdriver/firefox/options_spec.rb @@ -144,6 +144,29 @@ module Firefox end end + describe '#add_firefox_option' do + it 'nests a vendor capability inside the browser options object' do + options.add_firefox_option('unhandledCapability', 'value') + + expect(options.as_json['moz:firefoxOptions']).to include('unhandledCapability' => 'value') + end + + it 'merges with capabilities set through dedicated methods' do + options.add_argument('foo') + options.add_firefox_option('unhandledCapability', 'value') + + firefox_options = options.as_json['moz:firefoxOptions'] + expect(firefox_options['args']).to eq(['foo']) + expect(firefox_options['unhandledCapability']).to eq('value') + end + + it 'normalizes a symbol capability name to a string key' do + options.add_firefox_option(:unhandledCapability, 'value') + + expect(options.as_json['moz:firefoxOptions']).to include('unhandledCapability' => 'value') + end + end + describe '#add_preference' do it 'adds a preference' do options.add_preference(:foo, 'bar') From e0658c9561470ac4afd4651269b2b5951c548d2f Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 21:50:50 -0500 Subject: [PATCH 28/56] [rb] validate BiDi outbound ref fields against their declared type (#17861) * [rb] validate BiDi outbound ref fields against their declared type * [rb] reject a non-variant object at a scalar-arm BiDi union * [rb] validate BiDi union bare-scalar arms against their schema literals --- .../project_bidi_schema.mjs | 11 +- .../project_bidi_schema_test.mjs | 3 + .../selenium/webdriver/bidi/protocol/input.rb | 1 + .../webdriver/bidi/serialization/record.rb | 52 +++++++- .../webdriver/bidi/serialization/union.rb | 33 ++++++ .../webdriver/bidi/support/bidi_generate.rb | 30 ++++- .../bidi/support/templates/module.rb.erb | 3 + .../selenium/webdriver/bidi/serialization.rbs | 20 ++++ .../webdriver/bidi/serialization_spec.rb | 112 ++++++++++++++++++ 9 files changed, 257 insertions(+), 8 deletions(-) diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs index 8c97b4548f87b..4fd850e2ea0a9 100644 --- a/javascript/selenium-webdriver/project_bidi_schema.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -29,7 +29,7 @@ * | { ordered: [{ ref, requires: [key] }] } // structural, spec order * | { correlated: true } // resolved by request id, not the payload * field: { name, wire, required, type } - * type ref: { primitive } | { const } | { ref } | { enum, primitive? } | { list } | { map, extensible? } | { union, scalar? } + * type ref: { primitive } | { const } | { ref } | { enum, primitive? } | { list } | { map, extensible? } | { union, scalar?, scalarValues? } * any ref may also carry `nullable: true` (a `/ null` alternative). On a * record node, `map` is the value type of `* key => value` entries and * `extensible: true` marks an open `* text => any` record. @@ -53,6 +53,9 @@ * `RemoteValue / text`) and carries that arm's primitive: a binding collapsing it onto its * object_only ref arm passes a non-object payload (the string keys) through, but only when * it matches the primitive — a wrong-typed scalar is still rejected. + * `scalarValues` on a `union` ref pins the exact literals its `{ const }` scalar arms admit + * (input.Origin's "viewport" / "pointer"), so a binding can reject a wrong string, not just a + * wrong primitive — the tightest check the schema affords for a bare-scalar union arm. * * Types the normalizer synthesized for anonymous CDDL constructs additionally * carry `{ synthetic: true, owner, label }`: `owner` is the type the construct @@ -143,12 +146,16 @@ function scalarArmPrimitive(arm) { // and carries that arm's primitive (or the array of primitives when the scalar arms differ). // A binding that collapses such a union onto its object (object_only) ref arm must still let // a non-object payload through here, but only when it matches this primitive — a wrong-typed -// scalar is still a wire error. Derived once, in the schema, rather than re-detected per binding. +// scalar is still a wire error. `scalarValues` additionally pins the exact literals a `{ const }` +// scalar arm admits (input.Origin's "viewport" / "pointer"), so a binding can reject a wrong +// string too, not just a wrong primitive. Derived once, in the schema, not re-detected per binding. function unionNode(arms) { const node = { union: arms } const primitives = [...new Set(arms.map(scalarArmPrimitive).filter((p) => p !== undefined))] if (primitives.length === 1) node.scalar = primitives[0] else if (primitives.length > 1) node.scalar = primitives + const values = arms.filter((a) => a.const !== undefined).map((a) => a.const) + if (values.length) node.scalarValues = values return node } diff --git a/javascript/selenium-webdriver/project_bidi_schema_test.mjs b/javascript/selenium-webdriver/project_bidi_schema_test.mjs index dd7d57d5b3933..efda52c7a73ed 100644 --- a/javascript/selenium-webdriver/project_bidi_schema_test.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema_test.mjs @@ -437,6 +437,9 @@ describe('schema signals (objectOnly / extensible / enum primitive)', () => { const s = projectSchema([origin, group('x.Element', [field('type', [lit('element')]), field('id', ['text'])])], {}) assert.equal(s.types['x.Origin'].kind, 'alias') assert.equal(s.types['x.Origin'].objectOnly, undefined) + // The const arms' literals are pinned so a binding can reject a wrong string, not just a wrong primitive. + assert.equal(s.types['x.Origin'].type.scalar, 'string') + assert.deepEqual(s.types['x.Origin'].type.scalarValues, ['viewport', 'pointer']) }) it('marks every extensible type extensible, regardless of send/receive reachability', () => { diff --git a/rb/lib/selenium/webdriver/bidi/protocol/input.rb b/rb/lib/selenium/webdriver/bidi/protocol/input.rb index 4e3e0c1d69bf4..cb2fdee876fe2 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/input.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/input.rb @@ -256,6 +256,7 @@ class Origin < Serialization::Union variants( element: 'Input::ElementOrigin' ) + scalar_values 'viewport', 'pointer' end # @api private diff --git a/rb/lib/selenium/webdriver/bidi/serialization/record.rb b/rb/lib/selenium/webdriver/bidi/serialization/record.rb index 2c07af77f2f57..679357dab7183 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/record.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/record.rb @@ -122,14 +122,64 @@ def validate_values(attributes) # Checks a field that carries an actual value (neither omitted nor nil): a nullable-const # field against its literal, list/scalar shape, primitive type (lists excepted, as inbound - # does), and enum membership (resolved lazily so a cross-domain enum need not load first). + # does), ref type, and enum membership (resolved lazily so a cross-domain enum need not load first). def validate_present(field, value) validate_const(field, value) check_outbound_shape(field, value) check_outbound_primitive(field, value) unless field.list + validate_ref(field, value) if field.ref Serialization.validate!("#{name}##{field.name}", value, Protocol.const_get(field.enum)) if field.enum end + # Outbound mirror of read_ref: a ref-typed value must be the type it declares, so a wrong + # record or a value no union variant accepts is a caller error caught here, not a browser + # round-trip. Shape is already checked, so a list is an Array. + def validate_ref(field, value) + klass = (@refs ||= {})[field.name] ||= Protocol.const_get(field.ref) + field.list ? validate_ref_list(field, klass, value) : validate_ref_value(field, klass, value) + end + + # Mirrors read_list: a scalar field is a [key, value] map, a nested list recurses, otherwise + # each element is checked against the ref. + def validate_ref_list(field, klass, list) + list.each do |element| + if field.scalar + validate_ref_entry(field, klass, element) + elsif element.is_a?(::Array) + validate_ref_list(field, klass, element) + else + validate_ref_value(field, klass, element) + end + end + end + + # A [key, value] map entry: the key may be a variant or a bare scalar, the value is a variant. + def validate_ref_entry(field, klass, element) + unless element.is_a?(::Array) && element.size == 2 + raise ::ArgumentError, "#{name}##{field.name} expected a [key, value] pair, got #{element.inspect}" + end + + key, value = element + key.is_a?(Serializable) ? validate_ref_value(field, klass, key) : check_outbound_scalar(field, key) + validate_ref_value(field, klass, value) + end + + # A record ref must be an instance of that record; a union ref must be one the union accepts. + def validate_ref_value(field, klass, value) + return if klass < Union ? klass.valid_outbound?(value) : value.is_a?(klass) + + raise ::ArgumentError, "#{name}##{field.name} expected #{field.ref}, got #{value.inspect}" + end + + # Outbound mirror of scalar_value: a bare map key must match one of the arm's primitives. + def check_outbound_scalar(field, value) + expected = Array(field.scalar).flat_map { |primitive| PRIMITIVE_TYPES[primitive] || [] } + return if expected.empty? || expected.any? { |type| value.is_a?(type) } + + raise ::ArgumentError, + "#{name}##{field.name} expected #{Array(field.scalar).join(' or ')}, got #{value.inspect}" + end + # A nullable constant (`literal / null`) is caller-settable but its only non-null value is # the literal, so a value that is neither the literal nor nil (nil is handled above) is a # local error rather than a wire round-trip. A non-const field carries UNSET here and passes. diff --git a/rb/lib/selenium/webdriver/bidi/serialization/union.rb b/rb/lib/selenium/webdriver/bidi/serialization/union.rb index b0e9ff29b3455..551ff2addc699 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/union.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/union.rb @@ -48,6 +48,11 @@ def fallback(path) = @fallback = path # object, so a non-Hash payload is a schema violation rather than a scalar arm. def object_only = @object_only = true + # Declared (via the schema's `scalarValues` signal) on a non-object_only union whose + # bare-scalar arms are a fixed set of literals (input.Origin's "viewport" / "pointer"). + # An outbound scalar outside that set matches no arm, so it is a caller error. + def scalar_values(*values) = @scalar_values = values + # A non-Hash payload is a bare scalar arm (e.g. input.Origin's "viewport") with no # object to dispatch on, so it is returned unchanged — unless every arm is an object # (object_only), where a non-Hash cannot match any variant and is a wire error. @@ -84,8 +89,36 @@ def build(**kwargs) raise ::ArgumentError, "invalid combination for #{name}: #{invalid.join(', ')}" end + # Outbound mirror of from_json: is +value+ one this union accepts? Any variant is accepted, + # and a variant that is itself a union recurses (e.g. LocalValue's RemoteReference fallback). + # A non-object_only union (e.g. input.Origin) also admits one of its pinned bare-scalar + # literals; an object (a Hash or another union's record) that matched no variant does not. + def valid_outbound?(value) + return true if variant_refs.any? { |ref| variant_accepts?(ref, value) } + + !@object_only && scalar_arm?(value) + end + private + # A bare-scalar arm must be one of the literals the schema pinned for this union + # (scalar_values, e.g. input.Origin's "viewport" / "pointer"). The generator guarantees a + # non-object_only union declares them, so no runtime guard is needed here. + def scalar_arm?(value) + @scalar_values.include?(value) + end + + # Every variant's class name: the discriminated table, the presence paths, and the fallback. + def variant_refs + @variant_refs ||= [*@variants&.values, *@presence&.keys, @fallback].compact + end + + # A variant that is itself a union recurses; a record variant is matched by instance. + def variant_accepts?(ref, value) + klass = (@variant_classes ||= {})[ref] ||= Protocol.const_get(ref) + klass < Union ? klass.valid_outbound?(value) : value.is_a?(klass) + end + # The discriminator value may legitimately be null (e.g. script.NullValue's # "null" tag), so it is matched by key presence. def select(json_payload) diff --git a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb index a5df6e59cc5df..5cd71cfdd9b01 100644 --- a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb +++ b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb @@ -456,8 +456,10 @@ def discriminator_pair # the union's definition in the live spec (nil when the schema has none). object_only # mirrors the schema's `objectOnly` signal: when true, a non-Hash payload is rejected # rather than passed through (every arm is an object, so it can match no variant). + # scalar_values mirrors the schema's `scalarValues` signal: the exact literals a bare-scalar + # arm admits (input.Origin's "viewport" / "pointer"), so outbound rejects any other scalar. UnionClass = Struct.new(:ruby_name, :discriminator_wire, :variants, :schema_name, :nested, :spec_href, :object_only, - keyword_init: true) do + :scalar_values, keyword_init: true) do def union? = true def value_variants = variants.select { |v| v.mode == :value } def presence_variants = variants.select { |v| v.mode == :presence } @@ -473,6 +475,13 @@ def discriminator_decl(indent) BiDiGenerate.wrap_call("#{head}, ", pairs, indent, open: '{', close: '}') end + + def scalar_values? = !(scalar_values.nil? || scalar_values.empty?) + + # `scalar_values 'viewport', 'pointer'` — the literals a bare-scalar arm admits. + def scalar_values_decl + "scalar_values #{scalar_values.map { |v| BiDiGenerate.ruby_literal(v) }.join(', ')}" + end end # spec_href links the domain's module section in the live spec (nil when unknown). @@ -854,7 +863,15 @@ def union_class(name) # order); consume it rather than re-deriving and silently depending on emit # order. An alias-to-union (only input.Origin) has no selector — its const-string # arms aren't first-class types — so it keeps the structural re-derivation. - type['kind'] == 'union' ? union_from_selector(name, type['selector']) : union_from_alias(name) + klass = type['kind'] == 'union' ? union_from_selector(name, type['selector']) : union_from_alias(name) + # A non-object_only union has a bare-scalar arm; only const-literal arms (scalar_values) are + # modeled, so the runtime can validate an outbound scalar. A non-object_only union without them + # is a shape the generator doesn't yet handle — fail here, at generation, not at a caller's runtime. + if !klass.object_only && !klass.scalar_values? + raise "non-object_only union #{name} has no scalar_values to validate its bare-scalar arm" + end + + klass end # Map a union `selector` to dispatch variants the template renders: @@ -902,7 +919,8 @@ def ordered_variants(selector) # discriminator; the bare-string arms need no dispatch (Union.from_json returns a # non-Hash payload unchanged). So dispatch the ref arms by their const tag. def union_from_alias(name) - consts = @types[name]['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref| + spec = @types[name] + consts = spec['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref| const = @types[ref]['fields'].find { |f| f['type'].key?('const') } const || raise("alias-union #{name} arm #{ref} has no const discriminator to dispatch on") [ref, const] @@ -911,10 +929,12 @@ def union_from_alias(name) VariantIR.new(mode: :value, value: const['type']['const'], ref: ruby_path(ref), requires: nil) end # An alias-union carries bare-scalar arms (input.Origin's "viewport"/"pointer"), so it - # is never object_only — those arms must still pass a non-Hash payload through. + # is never object_only — those arms must still pass a non-Hash payload through, but only + # a value the schema pins in scalarValues (so a stray "banana" is still rejected outbound). UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name), discriminator_wire: consts.values.first['wire'], variants: variants, schema_name: name, - spec_href: @types[name]['specHref'], object_only: @types[name]['objectOnly'] ? true : false) + spec_href: spec['specHref'], object_only: spec['objectOnly'] ? true : false, + scalar_values: spec['type']['scalarValues']) end def record_params(fields) diff --git a/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb b/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb index 687d44169a015..ccf75ab8c4e69 100644 --- a/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb +++ b/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb @@ -69,6 +69,9 @@ module Selenium <%- if type.object_only -%> object_only <%- end -%> +<%- if type.scalar_values? -%> + <%= type.scalar_values_decl %> +<%- end -%> <%- type.nested_types.each do |nested| -%> # @api private diff --git a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs index 85cea3f491db9..fb9c5ecb99422 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs @@ -64,6 +64,16 @@ module Selenium def validate_present: (untyped field, untyped value) -> void + def validate_ref: (untyped field, untyped value) -> void + + def validate_ref_list: (untyped field, untyped klass, untyped list) -> void + + def validate_ref_entry: (untyped field, untyped klass, untyped element) -> void + + def validate_ref_value: (untyped field, untyped klass, untyped value) -> void + + def check_outbound_scalar: (untyped field, untyped value) -> void + def validate_const: (untyped field, untyped value) -> void def check_outbound_shape: (untyped field, untyped value) -> void @@ -125,12 +135,22 @@ module Selenium def self.object_only: () -> bool + def self.scalar_values: (*untyped values) -> Array[untyped] + def self.from_json: (untyped json_payload) -> untyped def self.build: (**untyped kwargs) -> untyped + def self.valid_outbound?: (untyped value) -> bool + private + def self.scalar_arm?: (untyped value) -> bool + + def self.variant_refs: () -> Array[String] + + def self.variant_accepts?: (String ref, untyped value) -> bool + def self.select: (Hash[untyped, untyped] json_payload) -> untyped def self.outbound_variant: (Hash[untyped, untyped] kwargs) -> untyped diff --git a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb index c220fec5eb808..2ad1d196182f2 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb @@ -417,6 +417,118 @@ def moz_install(**kwargs) end end + describe 'outbound ref validation' do + # A record-typed ref: the value must be an instance of that exact record. A different + # record (even a sibling reference type) is a caller error caught before the wire. + it 'accepts the declared record for a record-typed ref' do + params = Input::SetFilesParameters.new( + context: 'c', element: Script::SharedReference.new(shared_id: 's1'), files: [] + ) + + expect(params.element).to be_a(Script::SharedReference) + end + + it 'rejects a wrong record for a record-typed ref' do + wrong = Script::RemoteObjectReference.new(handle: 'h') + + expect { Input::SetFilesParameters.new(context: 'c', element: wrong, files: []) } + .to raise_error(ArgumentError, /SetFilesParameters#element expected Script::SharedReference/) + end + + # A union-typed ref accepts any of the union's declared variants (decision 1), so a Cookie + # value may be either BytesValue arm. + it 'accepts any declared variant for a union-typed ref' do + string_cookie = Network::Cookie.new(**valid_cookie_attrs, value: Network::StringValue.new(value: 'YQ==')) + base64_cookie = Network::Cookie.new(**valid_cookie_attrs, value: Network::Base64Value.new(value: 'YQ==')) + + expect(string_cookie.value).to be_a(Network::StringValue) + expect(base64_cookie.value).to be_a(Network::Base64Value) + end + + # A record from a different union with the same wire shape is still not a BytesValue + # variant, so it is rejected rather than duck-typed onto the wire. + it 'rejects a variant from a different union for a union-typed ref' do + expect { Network::Cookie.new(**valid_cookie_attrs, value: Script::StringValue.new(value: 'x')) } + .to raise_error(ArgumentError, /Cookie#value expected Network::BytesValue/) + end + + # BytesValue is object_only, so a bare scalar cannot match any arm — the outbound mirror of + # rejecting a non-object where a typed object is expected (decision 4). + it 'rejects a bare scalar for an object-only union ref' do + expect { Network::Cookie.new(**valid_cookie_attrs, value: 'plain') } + .to raise_error(ArgumentError, /Cookie#value expected Network::BytesValue/) + end + + # A union with a bare-scalar arm (input.Origin's "viewport"/"pointer") admits each of its + # declared literals, but a record from another union remains a cross-union mismatch. + it 'accepts a declared bare-scalar arm for a non-object-only union ref' do + expect(Input::PointerMoveAction.new(x: 0, y: 0, origin: 'viewport').origin).to eq('viewport') + expect(Input::PointerMoveAction.new(x: 0, y: 0, origin: 'pointer').origin).to eq('pointer') + end + + # The object arm is still accepted alongside the scalar arms. + it 'accepts the object arm for a scalar-tolerant union ref' do + origin = Input::ElementOrigin.new(element: Script::SharedReference.new(shared_id: 's1')) + + expect(Input::PointerMoveAction.new(x: 0, y: 0, origin: origin).origin).to be_a(Input::ElementOrigin) + end + + # scalar_values pins the arm's literals ("viewport"/"pointer"), so a string outside that set + # matches no arm and is a caller error rather than a value the browser rejects a round-trip later. + it 'rejects a bare string that is not one of the union scalar arms' do + expect { Input::PointerMoveAction.new(x: 0, y: 0, origin: 'banana') } + .to raise_error(ArgumentError, /PointerMoveAction#origin expected Input::Origin/) + end + + # A wrong-typed scalar (a number or boolean where the arm is a string literal) is likewise + # not one of the declared arms. + it 'rejects a wrong-typed scalar for a union ref whose arms are string literals' do + expect { Input::PointerMoveAction.new(x: 0, y: 0, origin: 1) } + .to raise_error(ArgumentError, /PointerMoveAction#origin expected Input::Origin/) + expect { Input::PointerMoveAction.new(x: 0, y: 0, origin: true) } + .to raise_error(ArgumentError, /PointerMoveAction#origin expected Input::Origin/) + end + + it 'rejects a cross-union variant even where a scalar arm exists' do + expect { Input::PointerMoveAction.new(x: 0, y: 0, origin: Script::StringValue.new(value: 'x')) } + .to raise_error(ArgumentError, /PointerMoveAction#origin expected Input::Origin/) + end + + # A raw Hash is an object that matched no variant, not a bare-scalar arm, so it is rejected + # rather than passed through untyped. + it 'rejects a raw Hash for a union ref with a scalar arm' do + expect { Input::PointerMoveAction.new(x: 0, y: 0, origin: {type: 'element'}) } + .to raise_error(ArgumentError, /PointerMoveAction#origin expected Input::Origin/) + end + + # A ref list validates every element against the ref, so one bad element is rejected even + # when its siblings are valid variants. + it 'accepts a list whose every element is a declared variant' do + array = Script::ArrayLocalValue.new(value: [Script::StringValue.new(value: 'x'), + Script::NumberValue.new(value: 1)]) + + expect(array.value.size).to eq(2) + end + + it 'validates each element of a ref list, rejecting a non-variant element' do + expect { Script::ArrayLocalValue.new(value: [Script::NumberValue.new(value: 1), 42]) } + .to raise_error(ArgumentError, /ArrayLocalValue#value expected Script::LocalValue, got 42/) + end + + # A scalar-arm map keeps its bare-string key while still typing the value: a wrong-typed + # value (not a LocalValue variant) at the value position is rejected before the wire. + it 'rejects a non-variant value at a scalar-arm map position' do + expect { Script::ObjectLocalValue.new(value: [['k', 'not-a-value']]) } + .to raise_error(ArgumentError, /ObjectLocalValue#value expected Script::LocalValue, got "not-a-value"/) + end + + # The bare key at a scalar-arm map position must still match the arm's primitive. + it 'rejects a wrong-typed bare key at a scalar-arm map position' do + expect { Script::ObjectLocalValue.new(value: [[42, Script::StringValue.new(value: 'x')]]) } + .to raise_error(ArgumentError, /ObjectLocalValue#value expected string, got 42/) + end + end + describe 'enum symbol coercion' do it 'takes an idiomatic symbol and serializes the wire token (kebab included)' do params = Bluetooth::SimulateAdapterParameters.new(context: 'c', state: :powered_off) From 014d72be4d4d10c3dd8b37f86ded9ac9ce276157 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 22:53:57 -0500 Subject: [PATCH 29/56] [rb] fix tests and custom matchers to work with SE_DEBUG (#17863) * [rb] capture log matcher output under SE_DEBUG and keep service specs env-agnostic * [rb] drop the SE_DEBUG output lock in the spec log-capture helper --- rb/spec/rspec_matchers.rb | 5 ++++- .../selenium/webdriver/chrome/service_spec.rb | 12 +++++++----- .../selenium/webdriver/common/service_spec.rb | 12 ++++++++---- .../unit/selenium/webdriver/edge/service_spec.rb | 15 +++++++++------ .../selenium/webdriver/firefox/service_spec.rb | 15 ++++----------- .../unit/selenium/webdriver/ie/service_spec.rb | 8 +++++--- 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/rb/spec/rspec_matchers.rb b/rb/spec/rspec_matchers.rb index 4b805a6fa5a65..f1b73d3393517 100644 --- a/rb/spec/rspec_matchers.rb +++ b/rb/spec/rspec_matchers.rb @@ -69,10 +69,13 @@ def ids_in(line) end # Suppresses logging output to stderr while capturing it, so an expected entry does not pollute - # test output and an unexpected one still fails the assertion. + # test output and an unexpected one still fails the assertion. SE_DEBUG locks the output to block + # runtime overrides; the test suite has no such need, so drop the lock and leave it off. def capture_log_lines default_output = Selenium::WebDriver.logger.io io = StringIO.new + + Selenium::WebDriver.logger.instance_variable_set(:@output_forced, false) Selenium::WebDriver.logger.output = io begin diff --git a/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb b/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb index fb915d6af0e08..b86d327a2b3c0 100644 --- a/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/chrome/service_spec.rb @@ -25,6 +25,7 @@ module Chrome describe Service do describe '#new' do let(:service_path) { "/path/to/#{Service::EXECUTABLE}" } + let(:debug_args) { ENV.key?('SE_DEBUG') ? ['--verbose'] : [] } before do allow(Platform).to receive(:assert_executable) @@ -55,13 +56,13 @@ module Chrome it 'enables chrome logs by default' do service = described_class.new - expect(service.extra_args).to eq ['--enable-chrome-logs'] + expect(service.extra_args).to eq(['--enable-chrome-logs'] + debug_args) end it 'does not duplicate --enable-chrome-logs when provided' do service = described_class.new(args: ['--enable-chrome-logs']) - expect(service.extra_args).to eq ['--enable-chrome-logs'] + expect(service.extra_args).to eq(['--enable-chrome-logs'] + debug_args) end it 'uses sets log path to stdout' do @@ -80,21 +81,22 @@ module Chrome service = described_class.new(log: '/path/to/log.txt') expect(service.log).to be_nil - expect(service.args).to eq ['--enable-chrome-logs', '--log-path=/path/to/log.txt'] + expect(service.args).to eq(['--enable-chrome-logs'] + debug_args + ['--log-path=/path/to/log.txt']) end it 'uses provided args' do service = described_class.new(args: ['--foo', '--bar']) - expect(service.extra_args).to eq ['--foo', '--bar', '--enable-chrome-logs'] + expect(service.extra_args).to eq(['--foo', '--bar', '--enable-chrome-logs'] + debug_args) end context 'when SE_DEBUG is set' do around do |example| + original_debug = ENV.fetch('SE_DEBUG', nil) ENV['SE_DEBUG'] = '1' example.run ensure - ENV.delete('SE_DEBUG') + original_debug ? ENV['SE_DEBUG'] = original_debug : ENV.delete('SE_DEBUG') end it 'adds --verbose flag' do diff --git a/rb/spec/unit/selenium/webdriver/common/service_spec.rb b/rb/spec/unit/selenium/webdriver/common/service_spec.rb index fedfff71a782b..bc165311374de 100644 --- a/rb/spec/unit/selenium/webdriver/common/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/common/service_spec.rb @@ -34,28 +34,32 @@ module WebDriver describe 'browser shortcuts' do let(:args) { %w[--foo --bar] } + def debug_args(flag) + ENV.key?('SE_DEBUG') ? [flag] : [] + end + it 'creates Chrome instance' do service = described_class.chrome(args: args) expect(service).to be_a(Chrome::Service) - expect(service.args).to eq(args + %w[--enable-chrome-logs]) + expect(service.args).to eq(args + %w[--enable-chrome-logs] + debug_args('--verbose')) end it 'creates Edge instance' do service = described_class.edge(args: args) expect(service).to be_a(Edge::Service) - expect(service.args).to eq(args + %w[--enable-chrome-logs]) + expect(service.args).to eq(args + %w[--enable-chrome-logs] + debug_args('--verbose')) end it 'creates Firefox instance' do service = described_class.firefox(args: args) expect(service).to be_a(Firefox::Service) - expect(service.args).to eq(args + %w[--websocket-port 0]) + expect(service.args).to eq(args + %w[--websocket-port 0] + debug_args('-v')) end it 'creates IE instance' do service = described_class.internet_explorer(args: args) expect(service).to be_a(IE::Service) - expect(service.args).to eq args + expect(service.args).to eq(args + debug_args('--log-level=DEBUG')) end it 'creates Safari instance' do diff --git a/rb/spec/unit/selenium/webdriver/edge/service_spec.rb b/rb/spec/unit/selenium/webdriver/edge/service_spec.rb index 27507d9e09f63..8406342f30182 100644 --- a/rb/spec/unit/selenium/webdriver/edge/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/edge/service_spec.rb @@ -23,6 +23,8 @@ module Selenium module WebDriver module Edge describe Service do + let(:debug_args) { ENV.key?('SE_DEBUG') ? ['--verbose'] : [] } + describe '#new' do let(:service_path) { "/path/to/#{Service::EXECUTABLE}" } @@ -54,13 +56,13 @@ module Edge it 'enables chrome logs by default' do service = described_class.new - expect(service.extra_args).to eq ['--enable-chrome-logs'] + expect(service.extra_args).to eq(['--enable-chrome-logs'] + debug_args) end it 'does not duplicate --enable-chrome-logs when provided' do service = described_class.new(args: ['--enable-chrome-logs']) - expect(service.extra_args).to eq ['--enable-chrome-logs'] + expect(service.extra_args).to eq(['--enable-chrome-logs'] + debug_args) end it 'uses sets log path to stdout' do @@ -79,21 +81,22 @@ module Edge service = described_class.chrome(log: '/path/to/log.txt') expect(service.log).to be_nil - expect(service.args).to eq ['--enable-chrome-logs', '--log-path=/path/to/log.txt'] + expect(service.args).to eq(['--enable-chrome-logs'] + debug_args + ['--log-path=/path/to/log.txt']) end it 'uses provided args' do service = described_class.new(args: ['--foo', '--bar']) - expect(service.extra_args).to eq ['--foo', '--bar', '--enable-chrome-logs'] + expect(service.extra_args).to eq(['--foo', '--bar', '--enable-chrome-logs'] + debug_args) end context 'when SE_DEBUG is set' do around do |example| + original_debug = ENV.fetch('SE_DEBUG', nil) ENV['SE_DEBUG'] = '1' example.run ensure - ENV.delete('SE_DEBUG') + original_debug ? ENV['SE_DEBUG'] = original_debug : ENV.delete('SE_DEBUG') end it 'adds --verbose flag' do @@ -163,7 +166,7 @@ module Edge service = described_class.chrome(log: '/path/to/log.txt') expect(service.log).to be_nil - expect(service.args).to eq ['--enable-chrome-logs', '--log-path=/path/to/log.txt'] + expect(service.args).to eq(['--enable-chrome-logs'] + debug_args + ['--log-path=/path/to/log.txt']) end end end diff --git a/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb b/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb index 3e35b02065b32..ee7d0d936571b 100644 --- a/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/firefox/service_spec.rb @@ -25,14 +25,7 @@ module Firefox describe Service do describe '#new' do let(:service_path) { "/path/to/#{Service::EXECUTABLE}" } - - around do |example| - original_debug = ENV.fetch('SE_DEBUG', nil) - ENV.delete('SE_DEBUG') - example.run - ensure - original_debug ? ENV['SE_DEBUG'] = original_debug : ENV.delete('SE_DEBUG') - end + let(:debug_args) { ENV.key?('SE_DEBUG') ? ['-v'] : [] } before do allow(Platform).to receive(:assert_executable) @@ -60,7 +53,7 @@ module Firefox it 'creates websocket args by default' do service = described_class.new - expect(service.extra_args.count).to eq 2 + expect(service.extra_args.count).to eq(2 + debug_args.size) end it 'uses sets log path to stdout' do @@ -97,7 +90,7 @@ module Firefox it 'does not uses websocket-port' do service = described_class.new(args: ['--connect-existing']) expect(service.extra_args).not_to include('--websocket-port') - expect(service.extra_args).to eq(['--connect-existing']) + expect(service.extra_args).to eq(['--connect-existing'] + debug_args) end end @@ -105,7 +98,7 @@ module Firefox it 'does not add websocket-port' do service = described_class.new(args: ['--websocket-port=1234']) expect(service.extra_args).not_to include('--websocket-port=0') - expect(service.extra_args).to eq(['--websocket-port=1234']) + expect(service.extra_args).to eq(['--websocket-port=1234'] + debug_args) end end diff --git a/rb/spec/unit/selenium/webdriver/ie/service_spec.rb b/rb/spec/unit/selenium/webdriver/ie/service_spec.rb index 0d1ae3af4c87d..05cefa63a0007 100644 --- a/rb/spec/unit/selenium/webdriver/ie/service_spec.rb +++ b/rb/spec/unit/selenium/webdriver/ie/service_spec.rb @@ -25,6 +25,7 @@ module IE describe Service do describe '#new' do let(:service_path) { "/path/to/#{Service::EXECUTABLE}" } + let(:debug_args) { ENV.key?('SE_DEBUG') ? ['--log-level=DEBUG'] : [] } before do allow(Platform).to receive(:assert_executable) @@ -53,7 +54,7 @@ module IE it 'does not create args by default' do service = described_class.new - expect(service.extra_args).to be_empty + expect(service.extra_args).to eq(debug_args) end it 'uses sets log path to stdout' do @@ -77,15 +78,16 @@ module IE it 'uses provided args' do service = described_class.new(args: ['--foo', '--bar']) - expect(service.extra_args).to eq ['--foo', '--bar'] + expect(service.extra_args).to eq(['--foo', '--bar'] + debug_args) end context 'when SE_DEBUG is set' do around do |example| + original_debug = ENV.fetch('SE_DEBUG', nil) ENV['SE_DEBUG'] = '1' example.run ensure - ENV.delete('SE_DEBUG') + original_debug ? ENV['SE_DEBUG'] = original_debug : ENV.delete('SE_DEBUG') end it 'adds --log-level=DEBUG flag' do From 094f26f3a9b3cdd87069c0923984bca0a32fb68c Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 2 Aug 2026 22:55:15 -0500 Subject: [PATCH 30/56] [build] derive per-type inbound/outbound directionality in the shared schema (#17864) [bidi] derive per-type inbound/outbound directionality in the shared schema --- .../project_bidi_schema.mjs | 98 +++++++++++++---- .../project_bidi_schema_test.mjs | 102 ++++++++++++++++++ 2 files changed, 182 insertions(+), 18 deletions(-) diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs index 4fd850e2ea0a9..d3e3cacd08437 100644 --- a/javascript/selenium-webdriver/project_bidi_schema.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -57,6 +57,13 @@ * (input.Origin's "viewport" / "pointer"), so a binding can reject a wrong string, not just a * wrong primitive — the tightest check the schema affords for a bare-scalar union arm. * + * Each structured (`record` / `union`) type additionally carries `outbound` / + * `inbound`: reachable (by a pure `ref` walk) from some command's `params`, and from + * some command's `result` or an event's `params`, respectively. A binding gives a + * send-side accessor only to `outbound` types. Both flags are independent, so all four + * combinations occur — including `(false, false)` for a type in no message (a flattened + * base, an envelope), which correctly gets no accessor. + * * Types the normalizer synthesized for anonymous CDDL constructs additionally * carry `{ synthetic: true, owner, label }`: `owner` is the type the construct * was lifted out of and `label` is the member name within it, so a binding can @@ -533,6 +540,54 @@ export function buildSpecLinks(dfnsDocs, anchors = {}) { } } +// Every type name a *type expression* references (the value of a `field.type`, or a +// list element / map value type), descending through list, map, inline union arms, and +// inline record fields. Shared by refsOfNode and checkSchema. +function refsInType(node) { + if (!node) return [] + if (node.ref) return [node.ref] + if (node.list) return refsInType(node.list) + if (node.map) return refsInType(node.map) + if (node.union) return node.union.flatMap(refsInType) + if (node.record) return node.record.flatMap((f) => refsInType(f.type)) + return [] +} + +// Every type name a projected *type node* (a named `schema.types` entry) references: a +// record's field and map-value refs, a union's variant (and selector) refs, an alias's +// target refs. Composition is already resolved upstream, so this ref adjacency is +// complete for a reachability walk. +function refsOfNode(node) { + if (!node) return [] + if (node.kind === 'record') { + const refs = node.fields.flatMap((f) => refsInType(f.type)) + if (node.map) refs.push(...refsInType(node.map)) + return refs + } + if (node.kind === 'union') { + const refs = [...node.variants] + if (node.selector?.variants) refs.push(...node.selector.variants.map((v) => v.ref)) + if (node.selector?.default) refs.push(node.selector.default) + return refs + } + if (node.kind === 'alias') return refsInType(node.type) + return [] +} + +// The transitive closure of a set of root type names over refsOfNode. An unknown name +// (a ref with no type entry) terminates that branch. +function reachableTypes(roots, types) { + const seen = new Set() + const stack = [...roots] + while (stack.length) { + const name = stack.pop() + if (seen.has(name) || !types[name]) continue + seen.add(name) + for (const r of refsOfNode(types[name])) stack.push(r) + } + return seen +} + /** * Build the flat, binding-neutral schema from the raw AST and command/event model. * @param {object[]} ast The parsed CDDL AST (array of definition nodes). @@ -602,6 +657,19 @@ export function projectSchema(ast, model, links = {}) { } } + // Per-type directionality (see the header block): reachable from a command's params + // (outbound) vs from a command's result or an event's params (inbound), closed over + // the same ref edges the integrity check walks — no name heuristics. + const outboundRoots = commands.map((c) => c.params?.ref).filter(Boolean) + const inboundRoots = [...commands.map((c) => c.result?.ref), ...events.map((e) => e.params?.ref)].filter(Boolean) + const outboundReach = reachableTypes(outboundRoots, types) + const inboundReach = reachableTypes(inboundRoots, types) + for (const [name, node] of Object.entries(types)) + if (node.kind === 'record' || node.kind === 'union') { + node.outbound = outboundReach.has(name) + node.inbound = inboundReach.has(name) + } + // Per-domain module links, for a binding that emits one class/namespace per domain. const domains = {} for (const domain of Object.keys(model)) { @@ -668,20 +736,6 @@ function extractVendor(types) { export function checkSchema(schema) { const errors = [] const has = (name) => Object.hasOwn(schema.types, name) - const refsIn = (node) => - !node - ? [] - : node.ref - ? [node.ref] - : node.list - ? refsIn(node.list) - : node.map - ? refsIn(node.map) - : node.union - ? node.union.flatMap(refsIn) - : node.record - ? node.record.flatMap((f) => refsIn(f.type)) - : [] const hasUnknown = (node) => !node ? false @@ -709,7 +763,7 @@ export function checkSchema(schema) { ? node.union.some(hasEmptyInlineRecord) : false const report = (where, node) => { - for (const r of refsIn(node)) if (!has(r)) errors.push(`${where}: unresolved type ${r}`) + for (const r of refsInType(node)) if (!has(r)) errors.push(`${where}: unresolved type ${r}`) if (hasUnknown(node)) errors.push(`${where}: projected to an unknown primitive (unhandled CDDL type)`) if (hasEmptyInlineRecord(node)) errors.push(`${where}: projected an empty inline record (dropped type reference)`) } @@ -758,14 +812,14 @@ export function checkSchema(schema) { if (node.kind === 'record') { const envelopeRoot = envelopeResultUnion(node, schema.types) for (const f of node.fields) - for (const r of refsIn(f.type)) + for (const r of refsInType(f.type)) if (correlated.has(r) && !(f.name === 'result' && f.type.ref === r && r === envelopeRoot)) leak(`${name}.${f.name}`, r) - if (node.map) for (const r of refsIn(node.map)) if (correlated.has(r)) leak(`${name}.*`, r) + if (node.map) for (const r of refsInType(node.map)) if (correlated.has(r)) leak(`${name}.*`, r) } else if (node.kind === 'union' && !node.selector?.correlated) { for (const v of node.variants) if (correlated.has(v)) leak(name, v) } else if (node.kind === 'alias') { - for (const r of refsIn(node.type)) if (correlated.has(r)) leak(name, r) + for (const r of refsInType(node.type)) if (correlated.has(r)) leak(name, r) } } return errors @@ -905,5 +959,13 @@ export function checkCompleteness(rawAst, schema) { for (const known of KNOWN_INCOMPLETE) { if (emitted.has(known)) errors.push(`stale KNOWN_INCOMPLETE entry (now emitted, remove it): ${known}`) } + // Every structured type must carry both directionality flags — a missing one means + // the pass skipped a node. `(false, false)` is a valid combination (a type in no + // message: an envelope, a grouping union, a flattened base), not an error. + for (const [name, node] of Object.entries(schema.types)) { + if (node.kind !== 'record' && node.kind !== 'union') continue + if (typeof node.outbound !== 'boolean' || typeof node.inbound !== 'boolean') + errors.push(`${name}: missing directionality flag (inbound/outbound)`) + } return errors } diff --git a/javascript/selenium-webdriver/project_bidi_schema_test.mjs b/javascript/selenium-webdriver/project_bidi_schema_test.mjs index efda52c7a73ed..03f543e382292 100644 --- a/javascript/selenium-webdriver/project_bidi_schema_test.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema_test.mjs @@ -163,6 +163,8 @@ describe('projectType (list / union / alias defs)', () => { ], }, objectOnly: true, // both arms are records + inbound: false, // no model → reachable from no message root + outbound: false, }) }) it('projects a single-member dispatch choice group as an alias to its ref', () => { @@ -485,6 +487,106 @@ describe('schema signals (objectOnly / extensible / enum primitive)', () => { }) }) +describe('directionality (inbound / outbound per structured type)', () => { + const union = (name, refs) => ({ + Type: 'variable', + Name: name, + IsChoiceAddition: false, + Comments: [], + PropertyType: refs.map(ref), + }) + // A command (params x.DoParams → result x.DoResult) and an event (params x.HappenedParams) + // seed the walk. x.Both is referenced from both params and result; x.NoMessage from neither. + // x.LocalNode and x.RemoteNode are structural look-alikes (same `type: "node"`) reached + // only through params vs only through result, so they must land on opposite sides. + const ast = [ + group('x.DoParams', [ + field('cfg', [ref('x.OutOnly')]), + field('shared', [ref('x.Both')]), + field('lv', [ref('x.LocalValue')]), + ]), + group('x.OutOnly', [field('a', ['text'])]), + group('x.Both', [field('b', ['text'])]), + group('x.DoResult', [ + field('info', [ref('x.InOnly')]), + field('note', [ref('x.Both')]), + field('rv', [ref('x.RemoteValue')]), + ]), + group('x.InOnly', [field('c', ['text'])]), + group('x.HappenedParams', [field('d', ['text'])]), + group('x.NoMessage', [field('e', ['text'])]), + union('x.LocalValue', ['x.LocalNode', 'x.LocalString']), + group('x.LocalNode', [field('type', [lit('node')]), field('v', ['text'])]), + group('x.LocalString', [field('type', [lit('string')]), field('v', ['text'])]), + union('x.RemoteValue', ['x.RemoteNode', 'x.RemoteString']), + group('x.RemoteNode', [field('type', [lit('node')]), field('v', ['text'])]), + group('x.RemoteString', [field('type', [lit('string')]), field('v', ['text'])]), + ] + const model = { + x: { + commands: [{ method: 'x.doThing', name: 'doThing', params: 'x.DoParams', result: 'x.DoResult' }], + events: [{ method: 'x.happened', name: 'happened', params: 'x.HappenedParams' }], + }, + } + const schema = projectSchema(ast, model) + const dir = (n) => ({ inbound: schema.types[n].inbound, outbound: schema.types[n].outbound }) + + it('marks a params-only record outbound (send side)', () => { + assert.deepEqual(dir('x.OutOnly'), { inbound: false, outbound: true }) + assert.deepEqual(dir('x.DoParams'), { inbound: false, outbound: true }) + }) + + it('marks a result/event-only payload inbound (receive side)', () => { + assert.deepEqual(dir('x.InOnly'), { inbound: true, outbound: false }) + assert.deepEqual(dir('x.DoResult'), { inbound: true, outbound: false }) + assert.deepEqual(dir('x.HappenedParams'), { inbound: true, outbound: false }) + }) + + it('marks a type reached from both params and result as both (Cookie-shaped)', () => { + assert.deepEqual(dir('x.Both'), { inbound: true, outbound: true }) + }) + + it('leaves a type reachable from no message at (false, false)', () => { + assert.deepEqual(dir('x.NoMessage'), { inbound: false, outbound: false }) + }) + + it('splits structural look-alikes by reachability, not by name (LocalValue vs RemoteValue variant)', () => { + assert.deepEqual(dir('x.LocalNode'), { inbound: false, outbound: true }) // reached via params + assert.deepEqual(dir('x.RemoteNode'), { inbound: true, outbound: false }) // reached via result + assert.deepEqual(dir('x.LocalValue'), { inbound: false, outbound: true }) + assert.deepEqual(dir('x.RemoteValue'), { inbound: true, outbound: false }) + }) + + it('passes both validators (flags present on every structured type, (false,false) not an error)', () => { + assert.deepEqual(checkSchema(schema), []) + assert.deepEqual(checkCompleteness(ast, schema), []) + }) + + it('fails completeness when a structured type is missing a directionality flag', () => { + const broken = projectSchema(ast, model) + delete broken.types['x.OutOnly'].outbound + assert.ok( + checkCompleteness(ast, broken).some((e) => /x\.OutOnly: missing directionality flag/.test(e)), + 'a stripped flag must fail closed', + ) + }) + + it('does not flag enums or aliases (only record/union carry directionality)', () => { + // An enum and an alias are leaves/pass-throughs, not constructed message parts. + const s = projectSchema( + [ + { Type: 'variable', Name: 'x.E', IsChoiceAddition: false, Comments: [], PropertyType: [lit('a'), lit('b')] }, + { Type: 'variable', Name: 'x.A', IsChoiceAddition: false, Comments: [], PropertyType: [ref('x.OutOnly')] }, + group('x.OutOnly', [field('a', ['text'])]), + ], + {}, + ) + assert.equal(s.types['x.E'].inbound, undefined) + assert.equal(s.types['x.A'].outbound, undefined) + assert.deepEqual(checkCompleteness([], s), []) + }) +}) + describe('checkCompleteness (input vs output, generator-independent)', () => { it('fails when a command/event present in the AST is missing from the schema', () => { const astWithExtra = [ From 92223f9c35c3c8cb61383db16bac831b4ec70ee4 Mon Sep 17 00:00:00 2001 From: Selenium CI Bot Date: Mon, 3 Aug 2026 10:12:13 +0200 Subject: [PATCH 31/56] [build] Automated Browser Version Update (#17839) Co-authored-by: Navin Chandra --- common/repositories.bzl | 66 ++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/common/repositories.bzl b/common/repositories.bzl index df50fa616a238..c7cb3ba74a4af 100644 --- a/common/repositories.bzl +++ b/common/repositories.bzl @@ -12,8 +12,8 @@ def pin_browsers(): http_archive( name = "linux_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/153.0/linux-x86_64/en-US/firefox-153.0.tar.xz", - sha256 = "bfc57e7b6b4e6204b11e7e03c4b93cff708e9fb37f6b9948be243455311d82ee", + url = "https://ftp.mozilla.org/pub/firefox/releases/153.0.1/linux-x86_64/en-US/firefox-153.0.1.tar.xz", + sha256 = "05fb58905a90ce717c36a2ba5af0bbdc4d0e8b0eed6f50469030774c8c85b8eb", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -34,8 +34,8 @@ js_library( dmg_archive( name = "mac_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/153.0/mac/en-US/Firefox%20153.0.dmg", - sha256 = "2f9b5a20e546e7e79e4182f8fe10353a3e251635963ab3f6d399a3f290adeb96", + url = "https://ftp.mozilla.org/pub/firefox/releases/153.0.1/mac/en-US/Firefox%20153.0.1.dmg", + sha256 = "e5a7f8f34b16ac5d8d429a1438468f023ad7bf9099fa928db537f45e32159f78", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -51,8 +51,8 @@ js_library( http_archive( name = "linux_beta_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b3/linux-x86_64/en-US/firefox-154.0b3.tar.xz", - sha256 = "2d2d8e431242d6c063fc5af1548110e13201aa647415b43ac85269903e03472d", + url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b5/linux-x86_64/en-US/firefox-154.0b5.tar.xz", + sha256 = "1a3db36dcfab84c6e08f4c75ddb83ad03c6af7f15e4d6e99eba9210dd9c267d1", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -73,8 +73,8 @@ js_library( dmg_archive( name = "mac_beta_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b3/mac/en-US/Firefox%20154.0b3.dmg", - sha256 = "a339ad4160df2e8c1a3ed2b1c9c87512c7172b4e30e4f84233044457f3d772d6", + url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b5/mac/en-US/Firefox%20154.0b5.dmg", + sha256 = "100d718aed667c87af912adef24ef2b30d0e9525e2f8eec5f757bd5fa6aa673d", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -124,10 +124,10 @@ js_library( pkg_archive( name = "mac_edge", - url = "https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/716145f5-d360-45d2-b483-c2e902fd004b/MicrosoftEdge-150.0.4078.105.pkg", - sha256 = "74ce0195965c7276bb1f0a2929b4949cb4253f3040070c2d40f33f142858f924", + url = "https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/ea921391-7ff2-42bb-9044-9ac25c36970b/MicrosoftEdge-151.0.4129.59.pkg", + sha256 = "52701590e088388b865ebd889c9fde7dfc9a477652f79248da12db8f7970f450", move = { - "MicrosoftEdge-150.0.4078.105.pkg/Payload/Microsoft Edge.app": "Edge.app", + "MicrosoftEdge-151.0.4129.59.pkg/Payload/Microsoft Edge.app": "Edge.app", }, build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") @@ -144,8 +144,8 @@ js_library( deb_archive( name = "linux_edge", - url = "https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-stable/microsoft-edge-stable_150.0.4078.105-1_amd64.deb", - sha256 = "7114192e2c7e8c12aeecb604b2732355e925716035729ebf8a0afe6895cdc01b", + url = "https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-stable/microsoft-edge-stable_151.0.4129.59-1_amd64.deb", + sha256 = "5e443a1c2385950faaf60ae21aac85cfd117bc05f7e40c965720c40d7d4c75af", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -166,8 +166,8 @@ js_library( http_archive( name = "linux_edgedriver", - url = "https://msedgedriver.microsoft.com/150.0.4078.105/edgedriver_linux64.zip", - sha256 = "a5d1427aaefc299cae4afc4160708421e1a677c8d749f1ec6b60ff8c70fb117b", + url = "https://msedgedriver.microsoft.com/151.0.4129.59/edgedriver_linux64.zip", + sha256 = "6051dc70a2c29700970f534ac8ec1c87e0ea71d9b5e9b9d418176cf400e41fa0", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -183,8 +183,8 @@ js_library( http_archive( name = "mac_edgedriver", - url = "https://msedgedriver.microsoft.com/150.0.4078.99/edgedriver_mac64_m1.zip", - sha256 = "71dcdf98ea6a6714fcb63349685583d6b82f630c1a979c931ec525b746d2f846", + url = "https://msedgedriver.microsoft.com/151.0.4129.59/edgedriver_mac64_m1.zip", + sha256 = "11b9b3b97cb85ae573cbdba5a36762ab275ac7460a809189b11aaa29356a8d58", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -200,8 +200,8 @@ js_library( http_archive( name = "linux_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/linux64/chrome-linux64.zip", - sha256 = "14ac03a67e154e3f8bbc57e03ef03315fda8fedff8e045eee8b31500283a33f4", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/linux64/chrome-linux64.zip", + sha256 = "6bd04aab53fba1544ce6027d9daddb24137295033124a61ecdf9840d785792e9", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -221,8 +221,8 @@ js_library( ) http_archive( name = "mac_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/mac-arm64/chrome-mac-arm64.zip", - sha256 = "9529990b6afd9867a862c7a5bff2a4a8eef84614d910acac22e4c5fa5c24daee", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/mac-arm64/chrome-mac-arm64.zip", + sha256 = "1c516b5d6c00a074034d5ce03dc1cc9bd2cde2a09293d9613244e0bc153cb80f", strip_prefix = "chrome-mac-arm64", patch_cmds = [ "mv 'Google Chrome for Testing.app' Chrome.app", @@ -242,8 +242,8 @@ js_library( ) http_archive( name = "linux_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/linux64/chromedriver-linux64.zip", - sha256 = "2faa72828261cd3c5ff00cbc71cfca57a12c26c1406e084e1a34d8d90e292140", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/linux64/chromedriver-linux64.zip", + sha256 = "89b11804aa50b90b4821b19311f4bf688ce8d394484b2eea08bbcffd5644c1d8", strip_prefix = "chromedriver-linux64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") @@ -260,8 +260,8 @@ js_library( http_archive( name = "mac_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/mac-arm64/chromedriver-mac-arm64.zip", - sha256 = "d450678936f5a2b39598a4e7d548177931a7a5c4759c0e4824f2cab1ae26523e", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/mac-arm64/chromedriver-mac-arm64.zip", + sha256 = "e2956eda0e610414ea280574ccab35e5dd88b5b7f510353232fe157e5a598b7b", strip_prefix = "chromedriver-mac-arm64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") @@ -278,8 +278,8 @@ js_library( http_archive( name = "linux_beta_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/linux64/chrome-linux64.zip", - sha256 = "14ac03a67e154e3f8bbc57e03ef03315fda8fedff8e045eee8b31500283a33f4", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/linux64/chrome-linux64.zip", + sha256 = "931865951a28fccf0491a7f5e0a2fe1a0605765210a4ce4a4758d9d3d97d0b77", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -299,8 +299,8 @@ js_library( ) http_archive( name = "mac_beta_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/mac-arm64/chrome-mac-arm64.zip", - sha256 = "9529990b6afd9867a862c7a5bff2a4a8eef84614d910acac22e4c5fa5c24daee", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/mac-arm64/chrome-mac-arm64.zip", + sha256 = "78570bb23c7442f581c2b5f1c86c3e83b68b6cd35424cf390add51a1e305398b", strip_prefix = "chrome-mac-arm64", patch_cmds = [ "mv 'Google Chrome for Testing.app' Chrome.app", @@ -320,8 +320,8 @@ js_library( ) http_archive( name = "linux_beta_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/linux64/chromedriver-linux64.zip", - sha256 = "2faa72828261cd3c5ff00cbc71cfca57a12c26c1406e084e1a34d8d90e292140", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/linux64/chromedriver-linux64.zip", + sha256 = "3561dff6eb2126862418182cf2bff617230d8122b3d7f202b2ba781e35842caf", strip_prefix = "chromedriver-linux64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") @@ -338,8 +338,8 @@ js_library( http_archive( name = "mac_beta_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.47/mac-arm64/chromedriver-mac-arm64.zip", - sha256 = "d450678936f5a2b39598a4e7d548177931a7a5c4759c0e4824f2cab1ae26523e", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/mac-arm64/chromedriver-mac-arm64.zip", + sha256 = "39fa1eb272ce2d6cb98515b01e5d1775ff0d66922f72663a75d982045cdd19f6", strip_prefix = "chromedriver-mac-arm64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") From 5b1cde1542033ef1ebfcd17660ec81d706401876 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Mon, 3 Aug 2026 10:06:00 -0500 Subject: [PATCH 32/56] [rb] generate BiDi domain type accessors and union variant factories (#17865) --- .../webdriver/bidi/protocol/bluetooth.rb | 8 + .../webdriver/bidi/protocol/browser.rb | 7 + .../bidi/protocol/browsing_context.rb | 29 +++ .../webdriver/bidi/protocol/domain.rb | 4 + .../webdriver/bidi/protocol/emulation.rb | 6 + .../selenium/webdriver/bidi/protocol/input.rb | 37 +++ .../selenium/webdriver/bidi/protocol/log.rb | 3 + .../webdriver/bidi/protocol/network.rb | 19 ++ .../webdriver/bidi/protocol/permissions.rb | 2 + .../webdriver/bidi/protocol/script.rb | 85 +++++++ .../webdriver/bidi/protocol/session.rb | 18 ++ .../webdriver/bidi/protocol/storage.rb | 9 + .../bidi/protocol/user_agent_client_hints.rb | 3 + .../webdriver/bidi/protocol/web_extension.rb | 10 + .../webdriver/bidi/support/bidi_generate.rb | 234 ++++++++++++++++-- .../bidi/support/templates/module.rb.erb | 20 +- .../bidi/support/templates/module.rbs.erb | 18 ++ rb/lib/selenium/webdriver/bidi/transport.rb | 5 + .../webdriver/bidi/protocol/bluetooth.rbs | 8 + .../webdriver/bidi/protocol/browser.rbs | 17 +- .../bidi/protocol/browsing_context.rbs | 39 ++- .../webdriver/bidi/protocol/domain.rbs | 2 + .../webdriver/bidi/protocol/emulation.rbs | 7 + .../webdriver/bidi/protocol/input.rbs | 59 ++++- .../selenium/webdriver/bidi/protocol/log.rbs | 8 +- .../webdriver/bidi/protocol/network.rbs | 51 ++-- .../webdriver/bidi/protocol/permissions.rbs | 1 + .../webdriver/bidi/protocol/script.rbs | 137 ++++++++-- .../webdriver/bidi/protocol/session.rbs | 23 +- .../webdriver/bidi/protocol/storage.rbs | 27 +- .../bidi/protocol/user_agent_client_hints.rbs | 2 + .../webdriver/bidi/protocol/web_extension.rbs | 18 +- .../lib/selenium/webdriver/bidi/transport.rbs | 2 + .../selenium/webdriver/bidi/protocol_spec.rb | 59 +++++ .../bidi/support/bidi_generate_spec.rb | 36 +++ 35 files changed, 919 insertions(+), 94 deletions(-) diff --git a/rb/lib/selenium/webdriver/bidi/protocol/bluetooth.rb b/rb/lib/selenium/webdriver/bidi/protocol/bluetooth.rb index 6a9ccd81438cc..5005403ffefcc 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/bluetooth.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/bluetooth.rb @@ -150,6 +150,9 @@ class HandleRequestDevicePromptParameters < Serialization::Union context: {wire_key: 'context', primitive: 'string'}, prompt: {wire_key: 'prompt', primitive: 'string'} ) + + def self.true(**) = Bluetooth::HandleRequestDevicePromptParameters::AcceptParameters.new(**) + def self.false(**) = Bluetooth::HandleRequestDevicePromptParameters::CancelParameters.new(**) end # @api private @@ -325,6 +328,11 @@ class HandleRequestDevicePromptParameters < Serialization::Union 'bluetooth.gattConnectionAttempted' => Bluetooth::GattConnectionAttemptedParameters }.freeze + def bluetooth_manufacturer_data(**) = BluetoothManufacturerData.new(**) + def characteristic_properties(**) = CharacteristicProperties.new(**) + def scan_record(**) = ScanRecord.new(**) + def simulate_advertisement_scan_entry_parameters(**) = SimulateAdvertisementScanEntryParameters.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ def handle_request_device_prompt(context:, prompt:, accept:, device: Serialization::UNSET) diff --git a/rb/lib/selenium/webdriver/bidi/protocol/browser.rb b/rb/lib/selenium/webdriver/bidi/protocol/browser.rb index ef1fa7c6e0ffd..6cd864bd544d6 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/browser.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/browser.rb @@ -121,6 +121,8 @@ class SetClientWindowStateParameters < Serialization::Union x: {wire_key: 'x', required: false, primitive: 'integer'}, y: {wire_key: 'y', required: false, primitive: 'integer'} ) + + def self.normal(**) = Browser::SetClientWindowStateParameters::ClientWindowRectState.new(**) end # @api private @@ -152,8 +154,13 @@ class DownloadBehavior < Serialization::Union # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ Denied = Serialization::Record.define(type: {fixed: 'denied'}) + + def self.allowed(**) = Browser::DownloadBehavior::Allowed.new(**) + def self.denied(**) = Browser::DownloadBehavior::Denied.new(**) end + def download_behavior = DownloadBehavior + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-browser-close diff --git a/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb b/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb index 859a8d6c71b12..443c062985f88 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb @@ -113,6 +113,12 @@ class Locator < Serialization::Union xpath: 'BrowsingContext::XPathLocator' ) object_only + + def self.accessibility(**) = BrowsingContext::AccessibilityLocator.new(**) + def self.css(**) = BrowsingContext::CssLocator.new(**) + def self.context(**) = BrowsingContext::ContextLocator.new(**) + def self.inner_text(**) = BrowsingContext::InnerTextLocator.new(**) + def self.xpath(**) = BrowsingContext::XPathLocator.new(**) end # @api private @@ -224,6 +230,9 @@ class ClipRectangle < Serialization::Union element: 'BrowsingContext::ElementClipRectangle' ) object_only + + def self.box(**) = BrowsingContext::BoxClipRectangle.new(**) + def self.element(**) = BrowsingContext::ElementClipRectangle.new(**) end # @api private @@ -524,6 +533,9 @@ class DownloadEndParams < Serialization::Union url: {wire_key: 'url', primitive: 'string'}, user_context: {wire_key: 'userContext', required: false, primitive: 'string'} ) + + def self.canceled(**) = BrowsingContext::DownloadEndParams::CanceledParams.new(**) + def self.complete(**) = BrowsingContext::DownloadEndParams::CompleteParams.new(**) end # @api private @@ -566,6 +578,23 @@ class DownloadEndParams < Serialization::Union 'browsingContext.userPromptOpened' => BrowsingContext::UserPromptOpenedParameters }.freeze + def locator = Locator + def accessibility_locator(**) = AccessibilityLocator.new(**) + def css_locator(**) = CssLocator.new(**) + def context_locator(**) = ContextLocator.new(**) + def inner_text_locator(**) = InnerTextLocator.new(**) + def x_path_locator(**) = XPathLocator.new(**) + def image_format(**) = ImageFormat.new(**) + def clip_rectangle = ClipRectangle + def element_clip_rectangle(**) = ElementClipRectangle.new(**) + def box_clip_rectangle(**) = BoxClipRectangle.new(**) + def print_margin_parameters(**) = PrintMarginParameters.new(**) + def print_page_parameters(**) = PrintPageParameters.new(**) + def viewport(**) = Viewport.new(**) + def media_track_constraints(**) = MediaTrackConstraints.new(**) + def accessibility_locator_value(**) = AccessibilityLocator::Value.new(**) + def context_locator_value(**) = ContextLocator::Value.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-browsingContext-activate diff --git a/rb/lib/selenium/webdriver/bidi/protocol/domain.rb b/rb/lib/selenium/webdriver/bidi/protocol/domain.rb index 95a06be3ff755..d3741d7b637d8 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/domain.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/domain.rb @@ -32,6 +32,10 @@ def initialize(source) private + # The connection this domain runs over, so a generated vendor accessor can build its + # sibling variant (`Moz.new(connection)`) — construction stays connection-based. + def connection = @transport.connection + def execute(cmd:, params: nil, result: nil) @transport.execute(cmd: cmd, params: params, result: result) end diff --git a/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb b/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb index b1680174313f0..4d51d232e6ff0 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb @@ -217,6 +217,12 @@ class SetGeolocationOverrideParameters < Serialization::Union user_contexts: {wire_key: 'userContexts', required: false, list: true} ) + def geolocation_coordinates(**) = GeolocationCoordinates.new(**) + def geolocation_position_error(**) = GeolocationPositionError.new(**) + def network_conditions_offline(**) = NetworkConditionsOffline.new(**) + def screen_area(**) = ScreenArea.new(**) + def screen_orientation(**) = ScreenOrientation.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-emulation-setForcedColorsModeThemeOverride diff --git a/rb/lib/selenium/webdriver/bidi/protocol/input.rb b/rb/lib/selenium/webdriver/bidi/protocol/input.rb index cb2fdee876fe2..c7cb971e5cf26 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/input.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/input.rb @@ -67,6 +67,11 @@ class SourceActions < Serialization::Union wheel: 'Input::WheelSourceActions' ) object_only + + def self.none(**) = Input::NoneSourceActions.new(**) + def self.key(**) = Input::KeySourceActions.new(**) + def self.pointer(**) = Input::PointerSourceActions.new(**) + def self.wheel(**) = Input::WheelSourceActions.new(**) end # @api private @@ -98,6 +103,10 @@ class KeySourceAction < Serialization::Union key_up: 'Input::KeyUpAction' ) object_only + + def self.pause(**) = Input::PauseAction.new(**) + def self.key_down(**) = Input::KeyDownAction.new(**) + def self.key_up(**) = Input::KeyUpAction.new(**) end # @api private @@ -134,6 +143,11 @@ class PointerSourceAction < Serialization::Union pointer_move: 'Input::PointerMoveAction' ) object_only + + def self.pause(**) = Input::PauseAction.new(**) + def self.pointer_down(**) = Input::PointerDownAction.new(**) + def self.pointer_up(**) = Input::PointerUpAction.new(**) + def self.pointer_move(**) = Input::PointerMoveAction.new(**) end # @api private @@ -155,6 +169,9 @@ class WheelSourceAction < Serialization::Union scroll: 'Input::WheelScrollAction' ) object_only + + def self.pause(**) = Input::PauseAction.new(**) + def self.scroll(**) = Input::WheelScrollAction.new(**) end # @api private @@ -257,6 +274,8 @@ class Origin < Serialization::Union element: 'Input::ElementOrigin' ) scalar_values 'viewport', 'pointer' + + def self.element(**) = Input::ElementOrigin.new(**) end # @api private @@ -287,6 +306,24 @@ class Origin < Serialization::Union 'input.fileDialogOpened' => Input::FileDialogInfo }.freeze + def element_origin(**) = ElementOrigin.new(**) + def source_actions = SourceActions + def none_source_actions(**) = NoneSourceActions.new(**) + def key_source_actions(**) = KeySourceActions.new(**) + def key_source_action = KeySourceAction + def pointer_source_actions(**) = PointerSourceActions.new(**) + def pointer_parameters(**) = PointerParameters.new(**) + def pointer_source_action = PointerSourceAction + def wheel_source_actions(**) = WheelSourceActions.new(**) + def wheel_source_action = WheelSourceAction + def pause_action(**) = PauseAction.new(**) + def key_down_action(**) = KeyDownAction.new(**) + def key_up_action(**) = KeyUpAction.new(**) + def pointer_up_action(**) = PointerUpAction.new(**) + def pointer_down_action(**) = PointerDownAction.new(**) + def pointer_move_action(**) = PointerMoveAction.new(**) + def wheel_scroll_action(**) = WheelScrollAction.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-input-performActions diff --git a/rb/lib/selenium/webdriver/bidi/protocol/log.rb b/rb/lib/selenium/webdriver/bidi/protocol/log.rb index 033ec77040221..3ec6d22b328aa 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/log.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/log.rb @@ -51,6 +51,9 @@ class Entry < Serialization::Union ) fallback 'Log::GenericLogEntry' object_only + + def self.console(**) = Log::ConsoleLogEntry.new(**) + def self.javascript(**) = Log::JavascriptLogEntry.new(**) end # @api private diff --git a/rb/lib/selenium/webdriver/bidi/protocol/network.rb b/rb/lib/selenium/webdriver/bidi/protocol/network.rb index 07d6ba14c8abe..7c5db700363e4 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/network.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/network.rb @@ -120,6 +120,9 @@ class BytesValue < Serialization::Union base64: 'Network::Base64Value' ) object_only + + def self.string(**) = Network::StringValue.new(**) + def self.base64(**) = Network::Base64Value.new(**) end # @api private @@ -264,6 +267,9 @@ class UrlPattern < Serialization::Union string: 'Network::UrlPatternString' ) object_only + + def self.pattern(**) = Network::UrlPatternPattern.new(**) + def self.string(**) = Network::UrlPatternString.new(**) end # @api private @@ -365,6 +371,8 @@ class ContinueWithAuthParameters < Serialization::Union request: {wire_key: 'request', primitive: 'string'}, action: {wire_key: 'action', enum: 'Network::CONTINUE_WITH_AUTH_NO_CREDENTIALS_ACTION'} ) + + def self.provide_credentials(**) = Network::ContinueWithAuthParameters::Credentials.new(**) end # @api private @@ -522,6 +530,17 @@ class ContinueWithAuthParameters < Serialization::Union 'network.responseStarted' => Network::ResponseStartedParameters }.freeze + def auth_credentials(**) = AuthCredentials.new(**) + def bytes_value = BytesValue + def string_value(**) = StringValue.new(**) + def base64_value(**) = Base64Value.new(**) + def cookie_header(**) = CookieHeader.new(**) + def header(**) = Header.new(**) + def set_cookie_header(**) = SetCookieHeader.new(**) + def url_pattern = UrlPattern + def url_pattern_pattern(**) = UrlPatternPattern.new(**) + def url_pattern_string(**) = UrlPatternString.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-network-addDataCollector diff --git a/rb/lib/selenium/webdriver/bidi/protocol/permissions.rb b/rb/lib/selenium/webdriver/bidi/protocol/permissions.rb index 500265c68b6c6..877141934fcd8 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/permissions.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/permissions.rb @@ -47,6 +47,8 @@ class Permissions < Domain user_context: {wire_key: 'userContext', required: false, primitive: 'string'} ) + def permission_descriptor(**) = PermissionDescriptor.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ def set_permission( diff --git a/rb/lib/selenium/webdriver/bidi/protocol/script.rb b/rb/lib/selenium/webdriver/bidi/protocol/script.rb index ed05cc601c9af..975634cb590bc 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/script.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/script.rb @@ -102,6 +102,9 @@ class EvaluateResult < Serialization::Union exception: 'Script::EvaluateResultException' ) object_only + + def self.success(**) = Script::EvaluateResultSuccess.new(**) + def self.exception(**) = Script::EvaluateResultException.new(**) end # @api private @@ -169,6 +172,20 @@ class LocalValue < Serialization::Union ) fallback 'Script::RemoteReference' object_only + + def self.undefined(**) = Script::UndefinedValue.new(**) + def self.null(**) = Script::NullValue.new(**) + def self.string(**) = Script::StringValue.new(**) + def self.number(**) = Script::NumberValue.new(**) + def self.boolean(**) = Script::BooleanValue.new(**) + def self.bigint(**) = Script::BigIntValue.new(**) + def self.channel(**) = Script::ChannelValue.new(**) + def self.array(**) = Script::ArrayLocalValue.new(**) + def self.date(**) = Script::DateLocalValue.new(**) + def self.map(**) = Script::MapLocalValue.new(**) + def self.object(**) = Script::ObjectLocalValue.new(**) + def self.regexp(**) = Script::RegExpLocalValue.new(**) + def self.set(**) = Script::SetLocalValue.new(**) end # @api private @@ -248,6 +265,13 @@ class PrimitiveProtocolValue < Serialization::Union bigint: 'Script::BigIntValue' ) object_only + + def self.undefined(**) = Script::UndefinedValue.new(**) + def self.null(**) = Script::NullValue.new(**) + def self.string(**) = Script::StringValue.new(**) + def self.number(**) = Script::NumberValue.new(**) + def self.boolean(**) = Script::BooleanValue.new(**) + def self.bigint(**) = Script::BigIntValue.new(**) end # @api private @@ -314,6 +338,15 @@ class RealmInfo < Serialization::Union worklet: 'Script::WorkletRealmInfo' ) object_only + + def self.window(**) = Script::WindowRealmInfo.new(**) + def self.dedicated_worker(**) = Script::DedicatedWorkerRealmInfo.new(**) + def self.shared_worker(**) = Script::SharedWorkerRealmInfo.new(**) + def self.service_worker(**) = Script::ServiceWorkerRealmInfo.new(**) + def self.worker(**) = Script::WorkerRealmInfo.new(**) + def self.paint_worklet(**) = Script::PaintWorkletRealmInfo.new(**) + def self.audio_worklet(**) = Script::AudioWorkletRealmInfo.new(**) + def self.worklet(**) = Script::WorkletRealmInfo.new(**) end # @api private @@ -490,6 +523,33 @@ class RemoteValue < Serialization::Union window: 'Script::WindowProxyRemoteValue' ) object_only + + def self.undefined(**) = Script::UndefinedValue.new(**) + def self.null(**) = Script::NullValue.new(**) + def self.string(**) = Script::StringValue.new(**) + def self.number(**) = Script::NumberValue.new(**) + def self.boolean(**) = Script::BooleanValue.new(**) + def self.bigint(**) = Script::BigIntValue.new(**) + def self.symbol(**) = Script::SymbolRemoteValue.new(**) + def self.array(**) = Script::ArrayRemoteValue.new(**) + def self.object(**) = Script::ObjectRemoteValue.new(**) + def self.function(**) = Script::FunctionRemoteValue.new(**) + def self.regexp(**) = Script::RegExpRemoteValue.new(**) + def self.date(**) = Script::DateRemoteValue.new(**) + def self.map(**) = Script::MapRemoteValue.new(**) + def self.set(**) = Script::SetRemoteValue.new(**) + def self.weakmap(**) = Script::WeakMapRemoteValue.new(**) + def self.weakset(**) = Script::WeakSetRemoteValue.new(**) + def self.generator(**) = Script::GeneratorRemoteValue.new(**) + def self.error(**) = Script::ErrorRemoteValue.new(**) + def self.proxy(**) = Script::ProxyRemoteValue.new(**) + def self.promise(**) = Script::PromiseRemoteValue.new(**) + def self.typedarray(**) = Script::TypedArrayRemoteValue.new(**) + def self.arraybuffer(**) = Script::ArrayBufferRemoteValue.new(**) + def self.nodelist(**) = Script::NodeListRemoteValue.new(**) + def self.htmlcollection(**) = Script::HTMLCollectionRemoteValue.new(**) + def self.node(**) = Script::NodeRemoteValue.new(**) + def self.window(**) = Script::WindowProxyRemoteValue.new(**) end # @api private @@ -866,6 +926,31 @@ class Target < Serialization::Union 'script.realmDestroyed' => Script::RealmDestroyedParameters }.freeze + def channel_value(**) = ChannelValue.new(**) + def channel_properties(**) = ChannelProperties.new(**) + def local_value = LocalValue + def array_local_value(**) = ArrayLocalValue.new(**) + def date_local_value(**) = DateLocalValue.new(**) + def map_local_value(**) = MapLocalValue.new(**) + def object_local_value(**) = ObjectLocalValue.new(**) + def reg_exp_value(**) = RegExpValue.new(**) + def reg_exp_local_value(**) = RegExpLocalValue.new(**) + def set_local_value(**) = SetLocalValue.new(**) + def primitive_protocol_value = PrimitiveProtocolValue + def undefined_value(**) = UndefinedValue.new(**) + def null_value(**) = NullValue.new(**) + def string_value(**) = StringValue.new(**) + def number_value(**) = NumberValue.new(**) + def boolean_value(**) = BooleanValue.new(**) + def big_int_value(**) = BigIntValue.new(**) + def remote_reference = RemoteReference + def shared_reference(**) = SharedReference.new(**) + def remote_object_reference(**) = RemoteObjectReference.new(**) + def serialization_options(**) = SerializationOptions.new(**) + def realm_target(**) = RealmTarget.new(**) + def context_target(**) = ContextTarget.new(**) + def target = Target + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-script-addPreloadScript diff --git a/rb/lib/selenium/webdriver/bidi/protocol/session.rb b/rb/lib/selenium/webdriver/bidi/protocol/session.rb index ac6021349168a..8378d829ac11f 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/session.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/session.rb @@ -79,6 +79,12 @@ class ProxyConfiguration < Serialization::Union system: 'Session::SystemProxyConfiguration' ) object_only + + def self.autodetect(**) = Session::AutodetectProxyConfiguration.new(**) + def self.direct(**) = Session::DirectProxyConfiguration.new(**) + def self.manual(**) = Session::ManualProxyConfiguration.new(**) + def self.pac(**) = Session::PacProxyConfiguration.new(**) + def self.system(**) = Session::SystemProxyConfiguration.new(**) end # @api private @@ -224,6 +230,18 @@ class UnsubscribeParameters < Serialization::Union object_only end + def capabilities_request(**) = CapabilitiesRequest.new(**) + def capability_request(**) = CapabilityRequest.new(**) + def proxy_configuration = ProxyConfiguration + def autodetect_proxy_configuration(**) = AutodetectProxyConfiguration.new(**) + def direct_proxy_configuration(**) = DirectProxyConfiguration.new(**) + def manual_proxy_configuration(**) = ManualProxyConfiguration.new(**) + def pac_proxy_configuration(**) = PacProxyConfiguration.new(**) + def system_proxy_configuration(**) = SystemProxyConfiguration.new(**) + def user_prompt_handler(**) = UserPromptHandler.new(**) + def unsubscribe_by_id_request(**) = UnsubscribeByIDRequest.new(**) + def unsubscribe_by_attributes_request(**) = UnsubscribeByAttributesRequest.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-session-end diff --git a/rb/lib/selenium/webdriver/bidi/protocol/storage.rb b/rb/lib/selenium/webdriver/bidi/protocol/storage.rb index 35ecb16a384b1..5be1c20e0e12c 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/storage.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/storage.rb @@ -81,6 +81,9 @@ class PartitionDescriptor < Serialization::Union storage_key: 'Storage::StorageKeyPartitionDescriptor' ) object_only + + def self.context(**) = Storage::BrowsingContextPartitionDescriptor.new(**) + def self.storage_key(**) = Storage::StorageKeyPartitionDescriptor.new(**) end # @api private @@ -144,6 +147,12 @@ class PartitionDescriptor < Serialization::Union partition_key: {wire_key: 'partitionKey', ref: 'Storage::PartitionKey'} ) + def cookie_filter(**) = CookieFilter.new(**) + def browsing_context_partition_descriptor(**) = BrowsingContextPartitionDescriptor.new(**) + def storage_key_partition_descriptor(**) = StorageKeyPartitionDescriptor.new(**) + def partition_descriptor = PartitionDescriptor + def partial_cookie(**) = PartialCookie.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-storage-deleteCookies diff --git a/rb/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb b/rb/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb index 098b17cd341e6..a74c09a50a2bc 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb @@ -64,6 +64,9 @@ class UserAgentClientHints < Domain user_contexts: {wire_key: 'userContexts', required: false, list: true} ) + def client_hints_metadata(**) = ClientHintsMetadata.new(**) + def brand_version(**) = BrandVersion.new(**) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ def set_client_hints_override( diff --git a/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb b/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb index 825dcd6424308..a18126a50bf2e 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/web_extension.rb @@ -39,6 +39,10 @@ class ExtensionData < Serialization::Union path: 'WebExtension::ExtensionPath' ) object_only + + def self.archive_path(**) = WebExtension::ExtensionArchivePath.new(**) + def self.base64(**) = WebExtension::ExtensionBase64Encoded.new(**) + def self.path(**) = WebExtension::ExtensionPath.new(**) end # @api private @@ -83,6 +87,12 @@ class ExtensionData < Serialization::Union extensible: true ) + def extension_data = ExtensionData + def extension_path(**) = ExtensionPath.new(**) + def extension_archive_path(**) = ExtensionArchivePath.new(**) + def extension_base64_encoded(**) = ExtensionBase64Encoded.new(**) + def moz = Moz.new(connection) + # @api private # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#command-webExtension-install diff --git a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb index 5cd71cfdd9b01..bff300ba3703b 100644 --- a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb +++ b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb @@ -374,8 +374,8 @@ def rbs_reader # baked variant tag {ruby_name:, wire:, value:} or nil; schema_name/synthetic/owner/ # nested drive owner-nesting (see nest_synthetic). spec_href links to the type's # definition in the live spec (nil when the schema has none, e.g. a synthetic type). - TypeClass = Struct.new(:ruby_name, :fields, :discriminator, :extensible, - :schema_name, :synthetic, :owner, :label, :nested, :spec_href, keyword_init: true) do + TypeClass = Struct.new(:ruby_name, :fields, :discriminator, :extensible, :schema_name, :synthetic, + :owner, :label, :nested, :spec_href, :outbound, :inbound, keyword_init: true) do def union? = false def nested_types = nested || [] @@ -458,14 +458,48 @@ def discriminator_pair # rather than passed through (every arm is an object, so it can match no variant). # scalar_values mirrors the schema's `scalarValues` signal: the exact literals a bare-scalar # arm admits (input.Origin's "viewport" / "pointer"), so outbound rejects any other scalar. - UnionClass = Struct.new(:ruby_name, :discriminator_wire, :variants, :schema_name, :nested, :spec_href, :object_only, - :scalar_values, keyword_init: true) do + UnionClass = Struct.new(:ruby_name, :discriminator_wire, :variants, :schema_name, :nested, :spec_href, + :object_only, :scalar_values, :outbound, :inbound, :variant_arg_sigs, + keyword_init: true) do def union? = true def value_variants = variants.select { |v| v.mode == :value } def presence_variants = variants.select { |v| v.mode == :presence } def fallback_variant = variants.find { |v| v.mode == :fallback } def nested_types = nested || [] + # A class-method factory per discriminated variant, so a caller builds the right + # variant record without naming its class or repeating the discriminator: + # `ExtensionData.path(path: '/x')` returns `ExtensionPath.new(path: '/x')`. The method + # name is the variant's discriminator symbol; every value variant's ref is a record, so + # `.new` is always defined. Presence/fallback arms are omitted (no single tag to name). + def variant_factories + value_variants.map do |variant| + "def self.#{BiDiGenerate.enum_key(variant.value)}(**) = #{variant.ref}.new(**)" + end + end + + # RBS for variant_factories: the variant record's own typed `new` signature (threaded in + # as variant_arg_sigs at build time), so a call is checked against the record's fields + # rather than an opaque splat; the return type pins the concrete variant. + def rbs_variant_factories + value_variants.map do |variant| + args = (variant_arg_sigs || {})[BiDiGenerate.enum_key(variant.value)] || '**untyped' + "def self.#{BiDiGenerate.enum_key(variant.value)}: (#{args}) " \ + "-> ::Selenium::WebDriver::BiDi::Protocol::#{variant.ref}" + end + end + + # The union's RBS *value* type — the concrete types a value of this union can actually be: + # each variant record, plus any bare-scalar arm (input.Origin's "viewport"/"pointer"). The + # union class itself has no instances, so this alias (not the class) is what a field, param, + # or result of the union is typed to, letting a variant pass where the union is expected. + def rbs_value_type + refs = (value_variants + presence_variants + [fallback_variant].compact).map(&:ref).uniq + parts = refs.map { |ref| "::Selenium::WebDriver::BiDi::Protocol::#{ref}" } + parts += Array(scalar_values).map { |value| value.is_a?(::String) ? value.inspect : value.to_s } + parts.empty? ? 'untyped' : parts.join(' | ') + end + # `discriminator 'wire'`, or `discriminator 'wire', {sym: 'token', …}` (wrapped when # long) carrying the inbound wire->symbol map for string-tagged variants. def discriminator_decl(indent) @@ -484,9 +518,20 @@ def scalar_values_decl end end + # A prefix-free accessor emitted on the Domain subclass. method_name is the snake_case + # accessor; type_name is the local class it fronts. Three kinds route rendering: a union + # accessor returns the class so its variant factories dispatch; a record accessor + # constructs the instance directly; a vendor accessor returns a sibling vendor domain + # (`Moz.new(connection)`). rbs_args is the record's typed `new` signature (nil otherwise). + # See build_accessors / vendor_accessors. + Accessor = Struct.new(:method_name, :type_name, :union, :vendor, :rbs_args, keyword_init: true) do + def union? = union + def vendor? = vendor + end + # spec_href links the domain's module section in the live spec (nil when unknown). - Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, :vendor_modules, - :spec_href, keyword_init: true) + Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, :accessors, + :vendor_modules, :spec_href, keyword_init: true) class Schema def initialize(schema) @@ -539,6 +584,33 @@ def commands_for(domain) @commands.select { |c| c['domain'] == domain } end + # The domain's command param/result wrapper type names — the classes a command + # constructs (`params`) or parses its result into. They are reachable (so tagged + # outbound/inbound) but are the message wrappers a command method already builds, + # not data a caller composes, so they are excluded from the type accessors. + def command_wrapper_refs(domain) + commands_for(domain).flat_map { |c| [c.dig('params', 'ref'), c.dig('result', 'ref')] }.compact.to_set + end + + # Type names reached by at least one non-union-arm reference: used as a record field, + # list element, map value, or alias target somewhere — not solely as a named union's + # variant. A type reached only as a union arm is built through its union (a variant + # factory or the command's flattened dispatch), so a nested one needs no accessor; one + # reached as a plain field ref (browsingContext.AccessibilityLocator's `value`) does. + def plainly_reached_types + @plainly_reached_types ||= @types.each_value.with_object(Set.new) do |node, reached| + plain_refs(node).each { |ref| reached << ref } + end + end + + # The class path to a type relative to its domain class (an accessor body resolves in + # the Domain subclass scope): "ExtensionData", or "AccessibilityLocator::Value" for a + # synthetic nested under its owner. + def domain_relative_path(name) + prefix = "#{BiDiGenerate.snake_to_class_name(BiDiGenerate.camel_to_snake(name.split('.', 2).first))}::" + ruby_path(name).sub(/\A#{Regexp.escape(prefix)}/, '') + end + # The vendor modules a domain carries, one per namespace (`moz` → module `Moz`). The # schema's `vendor` section names, per namespace, which shared type each vendor extends; # we map that type back to the command that sends it, so the vendor method mirrors the @@ -681,12 +753,43 @@ def structured_ref(name) resolved[:list] ? nil : resolved[:ref] end + # Public ruby-path resolver (`Owner::Label` for a synthetic), matching how a variant's + # ref is emitted — so a caller can map a variant ref back to its emitted record. + def ruby_path_for(name) = ruby_path(name) + private def domain_path(name) name.include?('.') ? ruby_path(name) : nil end + # The refs a node exposes through a NON-arm position: a record's fields and map value, + # or an alias's target. A named union contributes none — its variants are arm positions + # (built through the union), so they do not count toward plainly_reached_types. + def plain_refs(node) + case node['kind'] + when 'record' + refs = node['fields'].flat_map { |f| refs_in_type(f['type']) } + node['map'] ? refs + refs_in_type(node['map']) : refs + when 'alias' then refs_in_type(node['type']) + else [] + end + end + + # Every type name a *type expression* references (mirrors the projector's refsInType), + # descending list element, map value, inline union arms, and inline record fields. An + # inline union arm inside a field is a plain position — the field is filled with it. + def refs_in_type(node) + return [] unless node + return [node['ref']] if node['ref'] + return refs_in_type(node['list']) if node['list'] + return refs_in_type(node['map']) if node['map'] + return node['union'].flat_map { |arm| refs_in_type(arm) } if node['union'] + return node['record'].flat_map { |f| refs_in_type(f['type']) } if node['record'] + + [] + end + # Class path, nesting a synthetic type under its owner as `Owner::Label` so a ref # resolves to the same nested constant the type is emitted as. def ruby_path(name) @@ -760,7 +863,7 @@ def resolve_named(name, seen = {}) case type['kind'] when 'record' then type['fields'].empty? ? OPAQUE : named_type(name) - when 'union' then named_type(name) + when 'union' then named_union(name) when 'enum' then {ref: nil, list: false, rbs: 'Symbol'} when 'alias' then resolve_named_alias(name, type['type'], seen) else OPAQUE @@ -774,8 +877,24 @@ def named_type(name) {ref: domain_path(name), list: false, rbs: rbs_abs(ruby_path(name))} end + # Like named_type, but a union is typed to its value alias (variant | variant | …), not + # its class — the class has no instances, so a variant must be assignable where the union + # is expected. The serialization ref is unchanged (still the union that dispatches inbound). + def named_union(name) + {ref: domain_path(name), list: false, rbs: union_alias_path(name)} + end + + # Absolute RBS path of a union's value alias: its class path with the last segment + # snake-cased (WebExtension::ExtensionData -> ...::WebExtension::extension_data), matching + # the `type` alias emitted alongside the class. + def union_alias_path(name) + segments = ruby_path(name).split('::') + segments[-1] = BiDiGenerate.camel_to_snake(segments[-1]) + rbs_abs(segments.join('::')) + end + def resolve_named_alias(name, inner, seen) - return named_type(name) if inner.key?('union') + return named_union(name) if inner.key?('union') return resolve_named(inner['ref'], seen) if inner.key?('ref') if inner.key?('list') @@ -790,6 +909,13 @@ def nilable(type, flag) flag ? BiDiGenerate.rbs_nilable(type) : type end + # The type's send/receive tags (schema `outbound`/`inbound`) as constructor kwargs, + # coerced to plain booleans — shared by every structured-type builder. + def directionality(name) + node = @types[name] + {outbound: node['outbound'] ? true : false, inbound: node['inbound'] ? true : false} + end + def record_class(name, type) const = type['fields'].find { |f| baked_discriminator?(f) } discriminator = const && {ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(const['name'])), @@ -802,7 +928,8 @@ def record_class(name, type) TypeClass.new(ruby_name: BiDiGenerate.type_class_name(name), fields: fields, discriminator: discriminator, extensible: type['extensible'] ? true : false, schema_name: name, synthetic: type['synthetic'] ? true : false, - owner: type['owner'], label: type['label'], spec_href: type['specHref']) + owner: type['owner'], label: type['label'], spec_href: type['specHref'], + **directionality(name)) end # A const field is a baked discriminator tag, unless it is also nullable: the spec's @@ -895,7 +1022,8 @@ def union_from_selector(name, selector) UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name), discriminator_wire: selector['by'], variants: variants, schema_name: name, - spec_href: @types[name]['specHref'], object_only: @types[name]['objectOnly'] ? true : false) + spec_href: @types[name]['specHref'], object_only: @types[name]['objectOnly'] ? true : false, + **directionality(name)) end def discriminated_variants(selector) @@ -934,7 +1062,7 @@ def union_from_alias(name) UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name), discriminator_wire: consts.values.first['wire'], variants: variants, schema_name: name, spec_href: spec['specHref'], object_only: spec['objectOnly'] ? true : false, - scalar_values: spec['type']['scalarValues']) + scalar_values: spec['type']['scalarValues'], **directionality(name)) end def record_params(fields) @@ -1074,17 +1202,95 @@ def guard_union_dispatch_keys_simple!(selector, ref) def self.build_ir(schema) schema.domains.map do |domain| - Module.new( + types = schema.types_for(domain) + thread_variant_arg_sigs(schema, types) + vendor_modules = schema.vendor_modules_for(domain) + mod = Module.new( name: domain, ruby_class: snake_to_class_name(camel_to_snake(domain)), filename: camel_to_snake(domain), commands: schema.commands_for(domain).map { |cmd| build_command(schema, cmd) }, events: schema.events_for(domain).map { |ev| build_event(schema, ev) }, enums: schema.enums_for(domain), - types: nest_synthetic(schema.types_for(domain)), - vendor_modules: schema.vendor_modules_for(domain), + accessors: build_accessors(schema, domain, types) + vendor_accessors(vendor_modules), + types: nest_synthetic(types), + vendor_modules: vendor_modules, spec_href: schema.domain_href(domain) ) + check_accessor_collisions!(mod) + mod + end + end + + # An accessor per vendor variant, returning a sibling vendor domain over the same connection + # (`web_extension.moz` -> `Moz.new(connection)`). Named after the vendor namespace. + def self.vendor_accessors(vendor_modules) + vendor_modules.map do |vendor_module| + Accessor.new(method_name: safe_method_name(vendor_module.namespace), type_name: vendor_module.name, + union: false, vendor: true) + end + end + + # Give each union its variants' typed `new` signatures, keyed by factory method name, so + # rbs_variant_factories can emit a checked signature instead of a splat. Keyed by ruby + # path (the form a variant ref carries); a cross-module variant not in this list falls + # back to `**untyped`. + def self.thread_variant_arg_sigs(schema, types) + record_sigs = types.reject(&:union?).to_h { |t| [schema.ruby_path_for(t.schema_name), t.rbs_new_args] } + types.select(&:union?).each do |union| + union.variant_arg_sigs = union.value_variants.to_h do |variant| + [BiDiGenerate.enum_key(variant.value), record_sigs[variant.ref]] + end + end + end + + # Outbound-scoped domain accessors: for every emitted type a caller constructs to send, + # a prefix-free constructor on the Domain subclass. Built from the pre-nesting type list + # so a nested synthetic (referenced by a Ruby-relative `Owner::Label` path) is reachable. + def self.build_accessors(schema, domain, types) + wrappers = schema.command_wrapper_refs(domain) + plainly_reached = schema.plainly_reached_types + types.select { |t| accessor?(t, wrappers, plainly_reached) }.map do |t| + Accessor.new(method_name: safe_method_name(camel_to_snake(type_class_name(t.schema_name))), + type_name: schema.domain_relative_path(t.schema_name), union: t.union?, + rbs_args: t.union? ? nil : t.rbs_new_args) + end + end + + # A type earns a send-side accessor when it is outbound and not a command param/result + # wrapper (a command method already builds those). A nested-away synthetic reached only + # as a union arm is excluded — it is built through its union (a variant factory or the + # command's flattened dispatch), never standalone. A top-level union variant record keeps + # its accessor (the plan constructs it directly, e.g. extension_path), as does a synthetic + # reached by a plain field ref (browsingContext.AccessibilityLocator's `value`). + def self.accessor?(type, wrappers, plainly_reached) + return false unless type.outbound + return false if wrappers.include?(type.schema_name) + + nested_synthetic = !type.union? && type.synthetic + !nested_synthetic || plainly_reached.include?(type.schema_name) + end + + # Public instance methods every accessor would shadow if it reused their name: + # Domain's own (`execute`/`initialize`) plus everything Object/Kernel expose. The + # collision guard fails generation before a schema-driven shadow can ship. + INHERITED_INSTANCE_METHODS = (%w[execute initialize].to_set + Object.instance_methods.to_set(&:to_s)).freeze + + # Fail generation if an accessor name would collide with a command method, an + # inherited method, or another accessor — turning a future shadow into a build error + # rather than a silently overridden method. + def self.check_accessor_collisions!(mod) + commands = mod.commands.to_set(&:method_name) + seen = {} + mod.accessors.each do |accessor| + name = accessor.method_name + clash = if commands.include?(name) then 'a command method' + elsif INHERITED_INSTANCE_METHODS.include?(name) then 'an inherited method' + elsif seen[name] then "the accessor for #{seen[name]}" + end + raise "accessor #{mod.ruby_class}##{name} collides with #{clash}" if clash + + seen[name] = accessor.type_name end end diff --git a/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb b/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb index ccf75ab8c4e69..3534410f61eaf 100644 --- a/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb +++ b/rb/lib/selenium/webdriver/bidi/support/templates/module.rb.erb @@ -77,6 +77,12 @@ module Selenium # @api private # @see <%= BiDiGenerate::BIDI_DOC_URL %> <%= nested.define_assignment(nested.ruby_name, 12) %> +<%- end -%> +<%- unless type.variant_factories.empty? -%> + +<%- type.variant_factories.each do |factory| -%> + <%= factory %> +<%- end -%> <%- end -%> end @@ -100,9 +106,21 @@ module Selenium EVENT_TYPES = { <%= mod.events.map(&:type_entry).join(",\n ") %> }.freeze -<%- unless mod.commands.empty? -%> +<%- if !mod.commands.empty? || !mod.accessors.empty? -%> <%- end -%> +<%- end -%> +<%- mod.accessors.each do |accessor| -%> +<%- if accessor.vendor? -%> + def <%= accessor.method_name %> = <%= accessor.type_name %>.new(connection) +<%- elsif accessor.union? -%> + def <%= accessor.method_name %> = <%= accessor.type_name %> +<%- else -%> + def <%= accessor.method_name %>(**) = <%= accessor.type_name %>.new(**) +<%- end -%> +<%- end -%> +<%- if !mod.accessors.empty? && !mod.commands.empty? -%> + <%- end -%> <%- mod.commands.each_with_index do |cmd, index| -%> <%- unless index.zero? -%> diff --git a/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb b/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb index 3d4b6711cef44..04166aeb676e5 100644 --- a/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb +++ b/rb/lib/selenium/webdriver/bidi/support/templates/module.rbs.erb @@ -29,6 +29,12 @@ module Selenium <%- mod.enums.each do |enum| -%> <%= enum.constant_name %>: Hash[Symbol, String] +<%- end -%> +<%- mod.types.select(&:union?).each do |union| -%> + type <%= BiDiGenerate.camel_to_snake(union.ruby_name) %> = <%= union.rbs_value_type %> +<%- end -%> +<%- if mod.types.any?(&:union?) -%> + <%- end -%> <%- mod.types.each do |type| -%> <%- if type.union? -%> @@ -40,6 +46,9 @@ module Selenium <%- end -%> def self.new: (<%= nested.rbs_new_args %>) -> instance end +<%- end -%> +<%- type.rbs_variant_factories.each do |factory| -%> + <%= factory %> <%- end -%> end @@ -65,6 +74,15 @@ module Selenium <%- unless mod.events.empty? -%> EVENT_TYPES: Hash[String, untyped] +<%- end -%> +<%- mod.accessors.each do |accessor| -%> +<%- if accessor.vendor? -%> + def <%= accessor.method_name %>: () -> <%= accessor.type_name %> +<%- elsif accessor.union? -%> + def <%= accessor.method_name %>: () -> singleton(<%= accessor.type_name %>) +<%- else -%> + def <%= accessor.method_name %>: (<%= accessor.rbs_args %>) -> <%= accessor.type_name %> +<%- end -%> <%- end -%> <%- mod.commands.each do |cmd| -%> def <%= cmd.method_name %>: <%= cmd.rbs_signature %> diff --git a/rb/lib/selenium/webdriver/bidi/transport.rb b/rb/lib/selenium/webdriver/bidi/transport.rb index ef1d34c14d7f0..41d7aee519331 100644 --- a/rb/lib/selenium/webdriver/bidi/transport.rb +++ b/rb/lib/selenium/webdriver/bidi/transport.rb @@ -25,6 +25,11 @@ class BiDi # # @api private class Transport + # The websocket the transport sends over. Exposed so a domain can build a sibling + # domain (e.g. a vendor variant) over the same connection without Transport ever + # becoming a public constructor argument. + attr_reader :connection + def initialize(connection) @connection = connection end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/bluetooth.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/bluetooth.rbs index c27629d6c0add..7e04b5b59b252 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/bluetooth.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/bluetooth.rbs @@ -41,6 +41,8 @@ module Selenium DESCRIPTOR_EVENT_GENERATED_PARAMETERS_TYPE: Hash[Symbol, String] + type handle_request_device_prompt_parameters = ::Selenium::WebDriver::BiDi::Protocol::Bluetooth::HandleRequestDevicePromptParameters::AcceptParameters | ::Selenium::WebDriver::BiDi::Protocol::Bluetooth::HandleRequestDevicePromptParameters::CancelParameters + class BluetoothManufacturerData < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader key: Integer attr_reader data: String @@ -87,6 +89,8 @@ module Selenium attr_reader prompt: String def self.new: (?accept: bool, context: String, prompt: String) -> instance end + def self.true: (?accept: bool, context: String, prompt: String, device: String) -> ::Selenium::WebDriver::BiDi::Protocol::Bluetooth::HandleRequestDevicePromptParameters::AcceptParameters + def self.false: (?accept: bool, context: String, prompt: String) -> ::Selenium::WebDriver::BiDi::Protocol::Bluetooth::HandleRequestDevicePromptParameters::CancelParameters end class SimulateAdapterParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -223,6 +227,10 @@ module Selenium EVENT_TYPES: Hash[String, untyped] + def bluetooth_manufacturer_data: (key: Integer, data: String) -> BluetoothManufacturerData + def characteristic_properties: (?broadcast: bool, ?read: bool, ?write_without_response: bool, ?write: bool, ?notify: bool, ?indicate: bool, ?authenticated_signed_writes: bool, ?extended_properties: bool) -> CharacteristicProperties + def scan_record: (?name: String, ?uuids: Array[String], ?appearance: Numeric, ?manufacturer_data: Array[::Selenium::WebDriver::BiDi::Protocol::Bluetooth::BluetoothManufacturerData]) -> ScanRecord + def simulate_advertisement_scan_entry_parameters: (device_address: String, rssi: Numeric, scan_record: ::Selenium::WebDriver::BiDi::Protocol::Bluetooth::ScanRecord) -> SimulateAdvertisementScanEntryParameters def handle_request_device_prompt: (context: String, prompt: String, accept: bool, ?device: String) -> untyped def simulate_adapter: (context: String, state: Symbol, ?le_supported: bool) -> untyped def disable_simulation: (context: String) -> untyped diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/browser.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/browser.rbs index 3a2ea487b11ec..9c3a69a563662 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/browser.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/browser.rbs @@ -27,6 +27,9 @@ module Selenium CLIENT_WINDOW_NAMED_STATE_STATE: Hash[Symbol, String] + type set_client_window_state_parameters = ::Selenium::WebDriver::BiDi::Protocol::Browser::SetClientWindowStateParameters::ClientWindowRectState | ::Selenium::WebDriver::BiDi::Protocol::Browser::SetClientWindowStateParameters::ClientWindowNamedState + type download_behavior = ::Selenium::WebDriver::BiDi::Protocol::Browser::DownloadBehavior::Allowed | ::Selenium::WebDriver::BiDi::Protocol::Browser::DownloadBehavior::Denied + class ClientWindowInfo < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader active: bool attr_reader client_window: String @@ -47,7 +50,7 @@ module Selenium attr_reader accept_insecure_certs: untyped attr_reader proxy: untyped attr_reader unhandled_prompt_behavior: untyped - def self.new: (?accept_insecure_certs: bool, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler) -> instance + def self.new: (?accept_insecure_certs: bool, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::proxy_configuration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler) -> instance end class GetClientWindowsResult < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -80,12 +83,13 @@ module Selenium attr_reader y: untyped def self.new: (?state: String, client_window: String, ?width: Integer, ?height: Integer, ?x: Integer, ?y: Integer) -> instance end + def self.normal: (?state: String, client_window: String, ?width: Integer, ?height: Integer, ?x: Integer, ?y: Integer) -> ::Selenium::WebDriver::BiDi::Protocol::Browser::SetClientWindowStateParameters::ClientWindowRectState end class SetDownloadBehaviorParameters < ::Selenium::WebDriver::BiDi::Serialization::Record - attr_reader download_behavior: ::Selenium::WebDriver::BiDi::Protocol::Browser::DownloadBehavior? + attr_reader download_behavior: ::Selenium::WebDriver::BiDi::Protocol::Browser::download_behavior? attr_reader user_contexts: untyped - def self.new: (download_behavior: ::Selenium::WebDriver::BiDi::Protocol::Browser::DownloadBehavior?, ?user_contexts: Array[String]) -> instance + def self.new: (download_behavior: ::Selenium::WebDriver::BiDi::Protocol::Browser::download_behavior?, ?user_contexts: Array[String]) -> instance end class DownloadBehavior < ::Selenium::WebDriver::BiDi::Serialization::Union @@ -98,15 +102,18 @@ module Selenium attr_reader type: String def self.new: (?type: String) -> instance end + def self.allowed: (?type: String, destination_folder: String) -> ::Selenium::WebDriver::BiDi::Protocol::Browser::DownloadBehavior::Allowed + def self.denied: (?type: String) -> ::Selenium::WebDriver::BiDi::Protocol::Browser::DownloadBehavior::Denied end + def download_behavior: () -> singleton(DownloadBehavior) def close: () -> untyped - def create_user_context: (?accept_insecure_certs: bool, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler) -> ::Selenium::WebDriver::BiDi::Protocol::Browser::UserContextInfo + def create_user_context: (?accept_insecure_certs: bool, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::proxy_configuration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler) -> ::Selenium::WebDriver::BiDi::Protocol::Browser::UserContextInfo def get_client_windows: () -> ::Selenium::WebDriver::BiDi::Protocol::Browser::GetClientWindowsResult def get_user_contexts: () -> ::Selenium::WebDriver::BiDi::Protocol::Browser::GetUserContextsResult def remove_user_context: (user_context: String) -> untyped def set_client_window_state: (client_window: String, state: Symbol, ?width: Integer, ?height: Integer, ?x: Integer, ?y: Integer) -> ::Selenium::WebDriver::BiDi::Protocol::Browser::ClientWindowInfo - def set_download_behavior: (download_behavior: ::Selenium::WebDriver::BiDi::Protocol::Browser::DownloadBehavior?, ?user_contexts: Array[String]) -> untyped + def set_download_behavior: (download_behavior: ::Selenium::WebDriver::BiDi::Protocol::Browser::download_behavior?, ?user_contexts: Array[String]) -> untyped end end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs index 13213ce399014..7d811e027a286 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/browsing_context.rbs @@ -37,6 +37,10 @@ module Selenium PRINT_PARAMETERS_ORIENTATION: Hash[Symbol, String] + type locator = ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::AccessibilityLocator | ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::CssLocator | ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ContextLocator | ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::InnerTextLocator | ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::XPathLocator + type clip_rectangle = ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::BoxClipRectangle | ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ElementClipRectangle + type download_end_params = ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::DownloadEndParams::CanceledParams | ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::DownloadEndParams::CompleteParams + class Info < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader children: Array[::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::Info]? attr_reader client_window: String @@ -49,6 +53,11 @@ module Selenium end class Locator < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.accessibility: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::AccessibilityLocator::Value) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::AccessibilityLocator + def self.css: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::CssLocator + def self.context: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ContextLocator::Value) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ContextLocator + def self.inner_text: (?type: String, value: String, ?ignore_case: bool, ?match_type: Symbol, ?max_depth: Integer) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::InnerTextLocator + def self.xpath: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::XPathLocator end class AccessibilityLocator < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -123,7 +132,7 @@ module Selenium attr_reader origin: untyped attr_reader format: untyped attr_reader clip: untyped - def self.new: (context: String, ?origin: Symbol, ?format: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ImageFormat, ?clip: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ClipRectangle) -> instance + def self.new: (context: String, ?origin: Symbol, ?format: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ImageFormat, ?clip: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::clip_rectangle) -> instance end class ImageFormat < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -133,6 +142,8 @@ module Selenium end class ClipRectangle < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.box: (?type: String, x: Numeric, y: Numeric, width: Numeric, height: Numeric) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::BoxClipRectangle + def self.element: (?type: String, element: ::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ElementClipRectangle end class ElementClipRectangle < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -195,11 +206,11 @@ module Selenium class LocateNodesParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader context: String - attr_reader locator: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::Locator + attr_reader locator: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::locator attr_reader max_node_count: untyped attr_reader serialization_options: untyped attr_reader start_nodes: untyped - def self.new: (context: String, locator: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::Locator, ?max_node_count: Integer, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?start_nodes: Array[::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference]) -> instance + def self.new: (context: String, locator: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::locator, ?max_node_count: Integer, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?start_nodes: Array[::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference]) -> instance end class LocateNodesResult < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -358,6 +369,8 @@ module Selenium attr_reader user_context: untyped def self.new: (?status: String, download: String, filepath: String?, context: String, navigation: String?, timestamp: Integer, url: String, ?user_context: String) -> instance end + def self.canceled: (?status: String, download: String, context: String, navigation: String?, timestamp: Integer, url: String, ?user_context: String) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::DownloadEndParams::CanceledParams + def self.complete: (?status: String, download: String, filepath: String?, context: String, navigation: String?, timestamp: Integer, url: String, ?user_context: String) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::DownloadEndParams::CompleteParams end class UserPromptClosedParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -381,13 +394,29 @@ module Selenium EVENT_TYPES: Hash[String, untyped] + def locator: () -> singleton(Locator) + def accessibility_locator: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::AccessibilityLocator::Value) -> AccessibilityLocator + def css_locator: (?type: String, value: String) -> CssLocator + def context_locator: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ContextLocator::Value) -> ContextLocator + def inner_text_locator: (?type: String, value: String, ?ignore_case: bool, ?match_type: Symbol, ?max_depth: Integer) -> InnerTextLocator + def x_path_locator: (?type: String, value: String) -> XPathLocator + def image_format: (type: String, ?quality: Numeric) -> ImageFormat + def clip_rectangle: () -> singleton(ClipRectangle) + def element_clip_rectangle: (?type: String, element: ::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference) -> ElementClipRectangle + def box_clip_rectangle: (?type: String, x: Numeric, y: Numeric, width: Numeric, height: Numeric) -> BoxClipRectangle + def print_margin_parameters: (?bottom: Numeric, ?left: Numeric, ?right: Numeric, ?top: Numeric) -> PrintMarginParameters + def print_page_parameters: (?height: Numeric, ?width: Numeric) -> PrintPageParameters + def viewport: (width: Integer, height: Integer) -> Viewport + def media_track_constraints: (?width: Integer, ?height: Integer, ?frame_rate: Integer) -> MediaTrackConstraints + def accessibility_locator_value: (?name: String, ?role: String) -> AccessibilityLocator::Value + def context_locator_value: (context: String) -> ContextLocator::Value def activate: (context: String) -> untyped - def capture_screenshot: (context: String, ?origin: Symbol, ?format: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ImageFormat, ?clip: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ClipRectangle) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::CaptureScreenshotResult + def capture_screenshot: (context: String, ?origin: Symbol, ?format: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::ImageFormat, ?clip: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::clip_rectangle) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::CaptureScreenshotResult def close: (context: String, ?prompt_unload: bool) -> untyped def create: (type: Symbol, ?reference_context: String, ?background: bool, ?user_context: String) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::CreateResult def get_tree: (?max_depth: Integer, ?root: String) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::GetTreeResult def handle_user_prompt: (context: String, ?accept: bool, ?user_text: String) -> untyped - def locate_nodes: (context: String, locator: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::Locator, ?max_node_count: Integer, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?start_nodes: Array[::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference]) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::LocateNodesResult + def locate_nodes: (context: String, locator: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::locator, ?max_node_count: Integer, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?start_nodes: Array[::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference]) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::LocateNodesResult def navigate: (context: String, url: String, ?wait: Symbol) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::NavigateResult def print: (context: String, ?background: bool, ?margin: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::PrintMarginParameters, ?orientation: Symbol, ?page: ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::PrintPageParameters, ?page_ranges: Array[untyped], ?scale: Numeric, ?shrink_to_fit: bool) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::PrintResult def reload: (context: String, ?ignore_cache: bool, ?wait: Symbol) -> ::Selenium::WebDriver::BiDi::Protocol::BrowsingContext::NavigateResult diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/domain.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/domain.rbs index 9e805744d178b..7aa514d6eb81c 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/domain.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/domain.rbs @@ -27,6 +27,8 @@ module Selenium private + def connection: () -> untyped + def execute: (cmd: String, ?params: untyped, ?result: untyped) -> untyped end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs index d6b01bb0bc8b1..10aea78ba2e77 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/emulation.rbs @@ -31,6 +31,8 @@ module Selenium SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE: Hash[Symbol, String] + type set_geolocation_override_parameters = ::Selenium::WebDriver::BiDi::Protocol::Emulation::SetGeolocationOverrideParameters::Coordinates | ::Selenium::WebDriver::BiDi::Protocol::Emulation::SetGeolocationOverrideParameters::Error + class SetForcedColorsModeThemeOverrideParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader theme: Symbol? attr_reader contexts: untyped @@ -149,6 +151,11 @@ module Selenium def self.new: (max_touch_points: Integer?, ?contexts: Array[String], ?user_contexts: Array[String]) -> instance end + def geolocation_coordinates: (latitude: Numeric, longitude: Numeric, ?accuracy: Numeric, ?altitude: Numeric?, ?altitude_accuracy: Numeric?, ?heading: Numeric?, ?speed: Numeric?) -> GeolocationCoordinates + def geolocation_position_error: (?type: String) -> GeolocationPositionError + def network_conditions_offline: (?type: String) -> NetworkConditionsOffline + def screen_area: (width: Integer, height: Integer) -> ScreenArea + def screen_orientation: (natural: Symbol, type: Symbol) -> ScreenOrientation def set_forced_colors_mode_theme_override: (theme: Symbol?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped def set_geolocation_override: (?contexts: Array[String], ?user_contexts: Array[String], ?coordinates: ::Selenium::WebDriver::BiDi::Protocol::Emulation::GeolocationCoordinates?, ?error: ::Selenium::WebDriver::BiDi::Protocol::Emulation::GeolocationPositionError) -> untyped def set_locale_override: (locale: String?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs index 57e242b4ebbe8..7413f2d31d1fd 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/input.rbs @@ -27,6 +27,12 @@ module Selenium POINTER_TYPE: Hash[Symbol, String] + type source_actions = ::Selenium::WebDriver::BiDi::Protocol::Input::NoneSourceActions | ::Selenium::WebDriver::BiDi::Protocol::Input::KeySourceActions | ::Selenium::WebDriver::BiDi::Protocol::Input::PointerSourceActions | ::Selenium::WebDriver::BiDi::Protocol::Input::WheelSourceActions + type key_source_action = ::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction | ::Selenium::WebDriver::BiDi::Protocol::Input::KeyDownAction | ::Selenium::WebDriver::BiDi::Protocol::Input::KeyUpAction + type pointer_source_action = ::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction | ::Selenium::WebDriver::BiDi::Protocol::Input::PointerDownAction | ::Selenium::WebDriver::BiDi::Protocol::Input::PointerUpAction | ::Selenium::WebDriver::BiDi::Protocol::Input::PointerMoveAction + type wheel_source_action = ::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction | ::Selenium::WebDriver::BiDi::Protocol::Input::WheelScrollAction + type origin = ::Selenium::WebDriver::BiDi::Protocol::Input::ElementOrigin | "viewport" | "pointer" + class ElementOrigin < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String attr_reader element: ::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference @@ -35,11 +41,15 @@ module Selenium class PerformActionsParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader context: String - attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::SourceActions] - def self.new: (context: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::SourceActions]) -> instance + attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::source_actions] + def self.new: (context: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::source_actions]) -> instance end class SourceActions < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.none: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction]) -> ::Selenium::WebDriver::BiDi::Protocol::Input::NoneSourceActions + def self.key: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::key_source_action]) -> ::Selenium::WebDriver::BiDi::Protocol::Input::KeySourceActions + def self.pointer: (?type: String, id: String, ?parameters: ::Selenium::WebDriver::BiDi::Protocol::Input::PointerParameters, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::pointer_source_action]) -> ::Selenium::WebDriver::BiDi::Protocol::Input::PointerSourceActions + def self.wheel: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::wheel_source_action]) -> ::Selenium::WebDriver::BiDi::Protocol::Input::WheelSourceActions end class NoneSourceActions < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -52,19 +62,22 @@ module Selenium class KeySourceActions < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String attr_reader id: String - attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::KeySourceAction] - def self.new: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::KeySourceAction]) -> instance + attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::key_source_action] + def self.new: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::key_source_action]) -> instance end class KeySourceAction < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.pause: (?type: String, ?duration: Integer) -> ::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction + def self.key_down: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Input::KeyDownAction + def self.key_up: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Input::KeyUpAction end class PointerSourceActions < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String attr_reader id: String attr_reader parameters: untyped - attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::PointerSourceAction] - def self.new: (?type: String, id: String, ?parameters: ::Selenium::WebDriver::BiDi::Protocol::Input::PointerParameters, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::PointerSourceAction]) -> instance + attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::pointer_source_action] + def self.new: (?type: String, id: String, ?parameters: ::Selenium::WebDriver::BiDi::Protocol::Input::PointerParameters, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::pointer_source_action]) -> instance end class PointerParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -73,16 +86,22 @@ module Selenium end class PointerSourceAction < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.pause: (?type: String, ?duration: Integer) -> ::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction + def self.pointer_down: (?type: String, button: Integer, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> ::Selenium::WebDriver::BiDi::Protocol::Input::PointerDownAction + def self.pointer_up: (?type: String, button: Integer) -> ::Selenium::WebDriver::BiDi::Protocol::Input::PointerUpAction + def self.pointer_move: (?type: String, x: Numeric, y: Numeric, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::origin, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> ::Selenium::WebDriver::BiDi::Protocol::Input::PointerMoveAction end class WheelSourceActions < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String attr_reader id: String - attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::WheelSourceAction] - def self.new: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::WheelSourceAction]) -> instance + attr_reader actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::wheel_source_action] + def self.new: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::wheel_source_action]) -> instance end class WheelSourceAction < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.pause: (?type: String, ?duration: Integer) -> ::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction + def self.scroll: (?type: String, x: Integer, y: Integer, delta_x: Integer, delta_y: Integer, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::origin) -> ::Selenium::WebDriver::BiDi::Protocol::Input::WheelScrollAction end class PauseAction < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -135,7 +154,7 @@ module Selenium attr_reader twist: untyped attr_reader altitude_angle: untyped attr_reader azimuth_angle: untyped - def self.new: (?type: String, x: Numeric, y: Numeric, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::Origin, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance + def self.new: (?type: String, x: Numeric, y: Numeric, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::origin, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> instance end class WheelScrollAction < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -146,7 +165,7 @@ module Selenium attr_reader delta_y: Integer attr_reader duration: untyped attr_reader origin: untyped - def self.new: (?type: String, x: Integer, y: Integer, delta_x: Integer, delta_y: Integer, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::Origin) -> instance + def self.new: (?type: String, x: Integer, y: Integer, delta_x: Integer, delta_y: Integer, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::origin) -> instance end class PointerCommonProperties < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -161,6 +180,7 @@ module Selenium end class Origin < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.element: (?type: String, element: ::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference) -> ::Selenium::WebDriver::BiDi::Protocol::Input::ElementOrigin end class ReleaseActionsParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -185,7 +205,24 @@ module Selenium EVENT_TYPES: Hash[String, untyped] - def perform_actions: (context: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::SourceActions]) -> untyped + def element_origin: (?type: String, element: ::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference) -> ElementOrigin + def source_actions: () -> singleton(SourceActions) + def none_source_actions: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::PauseAction]) -> NoneSourceActions + def key_source_actions: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::key_source_action]) -> KeySourceActions + def key_source_action: () -> singleton(KeySourceAction) + def pointer_source_actions: (?type: String, id: String, ?parameters: ::Selenium::WebDriver::BiDi::Protocol::Input::PointerParameters, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::pointer_source_action]) -> PointerSourceActions + def pointer_parameters: (?pointer_type: Symbol) -> PointerParameters + def pointer_source_action: () -> singleton(PointerSourceAction) + def wheel_source_actions: (?type: String, id: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::wheel_source_action]) -> WheelSourceActions + def wheel_source_action: () -> singleton(WheelSourceAction) + def pause_action: (?type: String, ?duration: Integer) -> PauseAction + def key_down_action: (?type: String, value: String) -> KeyDownAction + def key_up_action: (?type: String, value: String) -> KeyUpAction + def pointer_up_action: (?type: String, button: Integer) -> PointerUpAction + def pointer_down_action: (?type: String, button: Integer, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> PointerDownAction + def pointer_move_action: (?type: String, x: Numeric, y: Numeric, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::origin, ?width: Integer, ?height: Integer, ?pressure: Numeric, ?tangential_pressure: Numeric, ?twist: Integer, ?altitude_angle: Numeric, ?azimuth_angle: Numeric) -> PointerMoveAction + def wheel_scroll_action: (?type: String, x: Integer, y: Integer, delta_x: Integer, delta_y: Integer, ?duration: Integer, ?origin: ::Selenium::WebDriver::BiDi::Protocol::Input::origin) -> WheelScrollAction + def perform_actions: (context: String, actions: Array[::Selenium::WebDriver::BiDi::Protocol::Input::source_actions]) -> untyped def release_actions: (context: String) -> untyped def set_files: (context: String, element: ::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference, files: Array[String]) -> untyped end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/log.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/log.rbs index c8d035539c5d5..a5d0efe60f839 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/log.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/log.rbs @@ -27,7 +27,11 @@ module Selenium LEVEL: Hash[Symbol, String] + type entry = ::Selenium::WebDriver::BiDi::Protocol::Log::ConsoleLogEntry | ::Selenium::WebDriver::BiDi::Protocol::Log::JavascriptLogEntry | ::Selenium::WebDriver::BiDi::Protocol::Log::GenericLogEntry + class Entry < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.console: (?type: String, level: Symbol, source: ::Selenium::WebDriver::BiDi::Protocol::Script::Source, text: String?, timestamp: Integer, ?stack_trace: ::Selenium::WebDriver::BiDi::Protocol::Script::StackTrace, method_: String, args: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> ::Selenium::WebDriver::BiDi::Protocol::Log::ConsoleLogEntry + def self.javascript: (?type: String, level: Symbol, source: ::Selenium::WebDriver::BiDi::Protocol::Script::Source, text: String?, timestamp: Integer, ?stack_trace: ::Selenium::WebDriver::BiDi::Protocol::Script::StackTrace) -> ::Selenium::WebDriver::BiDi::Protocol::Log::JavascriptLogEntry end class BaseLogEntry < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -57,8 +61,8 @@ module Selenium attr_reader timestamp: Integer attr_reader stack_trace: untyped attr_reader method_: String - attr_reader args: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue] - def self.new: (?type: String, level: Symbol, source: ::Selenium::WebDriver::BiDi::Protocol::Script::Source, text: String?, timestamp: Integer, ?stack_trace: ::Selenium::WebDriver::BiDi::Protocol::Script::StackTrace, method_: String, args: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue]) -> instance + attr_reader args: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value] + def self.new: (?type: String, level: Symbol, source: ::Selenium::WebDriver::BiDi::Protocol::Script::Source, text: String?, timestamp: Integer, ?stack_trace: ::Selenium::WebDriver::BiDi::Protocol::Script::StackTrace, method_: String, args: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> instance end class JavascriptLogEntry < ::Selenium::WebDriver::BiDi::Serialization::Record diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs index ce3035c4807bd..a231d2437d63b 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/network.rbs @@ -39,6 +39,10 @@ module Selenium SET_CACHE_BEHAVIOR_PARAMETERS_CACHE_BEHAVIOR: Hash[Symbol, String] + type bytes_value = ::Selenium::WebDriver::BiDi::Protocol::Network::StringValue | ::Selenium::WebDriver::BiDi::Protocol::Network::Base64Value + type url_pattern = ::Selenium::WebDriver::BiDi::Protocol::Network::UrlPatternPattern | ::Selenium::WebDriver::BiDi::Protocol::Network::UrlPatternString + type continue_with_auth_parameters = ::Selenium::WebDriver::BiDi::Protocol::Network::ContinueWithAuthParameters::Credentials | ::Selenium::WebDriver::BiDi::Protocol::Network::ContinueWithAuthParameters::NoCredentials + class AuthChallenge < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader scheme: String attr_reader realm: String @@ -65,6 +69,8 @@ module Selenium end class BytesValue < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.string: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Network::StringValue + def self.base64: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Network::Base64Value end class StringValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -81,7 +87,7 @@ module Selenium class Cookie < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader name: String - attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue + attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value attr_reader domain: String attr_reader path: String attr_reader size: Integer @@ -90,13 +96,13 @@ module Selenium attr_reader same_site: Symbol attr_reader expiry: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, domain: String, path: String, size: Integer, http_only: bool, secure: bool, same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance + def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, domain: String, path: String, size: Integer, http_only: bool, secure: bool, same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance end class CookieHeader < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader name: String - attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue - def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue) -> instance + attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value + def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value) -> instance end class FetchTimingInfo < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -118,8 +124,8 @@ module Selenium class Header < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader name: String - attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue - def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue) -> instance + attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value + def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value) -> instance end class Initiator < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -168,7 +174,7 @@ module Selenium class SetCookieHeader < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader name: String - attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue + attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value attr_reader domain: untyped attr_reader http_only: untyped attr_reader expiry: untyped @@ -176,10 +182,12 @@ module Selenium attr_reader path: untyped attr_reader same_site: untyped attr_reader secure: untyped - def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?domain: String, ?http_only: bool, ?expiry: String, ?max_age: Integer, ?path: String, ?same_site: Symbol, ?secure: bool) -> instance + def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?domain: String, ?http_only: bool, ?expiry: String, ?max_age: Integer, ?path: String, ?same_site: Symbol, ?secure: bool) -> instance end class UrlPattern < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.pattern: (?type: String, ?protocol: String, ?hostname: String, ?port: String, ?pathname: String, ?search: String) -> ::Selenium::WebDriver::BiDi::Protocol::Network::UrlPatternPattern + def self.string: (?type: String, pattern: String) -> ::Selenium::WebDriver::BiDi::Protocol::Network::UrlPatternString end class UrlPatternPattern < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -216,7 +224,7 @@ module Selenium attr_reader phases: Array[Symbol] attr_reader contexts: untyped attr_reader url_patterns: untyped - def self.new: (phases: Array[Symbol], ?contexts: Array[String], ?url_patterns: Array[::Selenium::WebDriver::BiDi::Protocol::Network::UrlPattern]) -> instance + def self.new: (phases: Array[Symbol], ?contexts: Array[String], ?url_patterns: Array[::Selenium::WebDriver::BiDi::Protocol::Network::url_pattern]) -> instance end class AddInterceptResult < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -231,7 +239,7 @@ module Selenium attr_reader headers: untyped attr_reader method_: untyped attr_reader url: untyped - def self.new: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::CookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?method_: String, ?url: String) -> instance + def self.new: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::CookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?method_: String, ?url: String) -> instance end class ContinueResponseParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -256,6 +264,7 @@ module Selenium attr_reader action: Symbol def self.new: (request: String, action: Symbol) -> instance end + def self.provide_credentials: (?action: String, request: String, credentials: ::Selenium::WebDriver::BiDi::Protocol::Network::AuthCredentials) -> ::Selenium::WebDriver::BiDi::Protocol::Network::ContinueWithAuthParameters::Credentials end class DisownDataParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -279,8 +288,8 @@ module Selenium end class GetDataResult < ::Selenium::WebDriver::BiDi::Serialization::Record - attr_reader bytes: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue - def self.new: (bytes: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue) -> instance + attr_reader bytes: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value + def self.new: (bytes: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value) -> instance end class ProvideResponseParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -290,7 +299,7 @@ module Selenium attr_reader headers: untyped attr_reader reason_phrase: untyped attr_reader status_code: untyped - def self.new: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::SetCookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?reason_phrase: String, ?status_code: Integer) -> instance + def self.new: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::SetCookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?reason_phrase: String, ?status_code: Integer) -> instance end class RemoveDataCollectorParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -383,15 +392,25 @@ module Selenium EVENT_TYPES: Hash[String, untyped] + def auth_credentials: (?type: String, username: String, password: String) -> AuthCredentials + def bytes_value: () -> singleton(BytesValue) + def string_value: (?type: String, value: String) -> StringValue + def base64_value: (?type: String, value: String) -> Base64Value + def cookie_header: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value) -> CookieHeader + def header: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value) -> Header + def set_cookie_header: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?domain: String, ?http_only: bool, ?expiry: String, ?max_age: Integer, ?path: String, ?same_site: Symbol, ?secure: bool) -> SetCookieHeader + def url_pattern: () -> singleton(UrlPattern) + def url_pattern_pattern: (?type: String, ?protocol: String, ?hostname: String, ?port: String, ?pathname: String, ?search: String) -> UrlPatternPattern + def url_pattern_string: (?type: String, pattern: String) -> UrlPatternString def add_data_collector: (data_types: Array[Symbol], max_encoded_data_size: Integer, ?collector_type: Symbol, ?contexts: Array[String], ?user_contexts: Array[String]) -> ::Selenium::WebDriver::BiDi::Protocol::Network::AddDataCollectorResult - def add_intercept: (phases: Array[Symbol], ?contexts: Array[String], ?url_patterns: Array[::Selenium::WebDriver::BiDi::Protocol::Network::UrlPattern]) -> ::Selenium::WebDriver::BiDi::Protocol::Network::AddInterceptResult - def continue_request: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::CookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?method_: String, ?url: String) -> untyped + def add_intercept: (phases: Array[Symbol], ?contexts: Array[String], ?url_patterns: Array[::Selenium::WebDriver::BiDi::Protocol::Network::url_pattern]) -> ::Selenium::WebDriver::BiDi::Protocol::Network::AddInterceptResult + def continue_request: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::CookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?method_: String, ?url: String) -> untyped def continue_response: (request: String, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::SetCookieHeader], ?credentials: ::Selenium::WebDriver::BiDi::Protocol::Network::AuthCredentials, ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?reason_phrase: String, ?status_code: Integer) -> untyped def continue_with_auth: (request: String, action: Symbol, ?credentials: ::Selenium::WebDriver::BiDi::Protocol::Network::AuthCredentials) -> untyped def disown_data: (data_type: Symbol, collector: String, request: String) -> untyped def fail_request: (request: String) -> untyped def get_data: (data_type: Symbol, request: String, ?collector: String, ?disown: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Network::GetDataResult - def provide_response: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::SetCookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?reason_phrase: String, ?status_code: Integer) -> untyped + def provide_response: (request: String, ?body: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?cookies: Array[::Selenium::WebDriver::BiDi::Protocol::Network::SetCookieHeader], ?headers: Array[::Selenium::WebDriver::BiDi::Protocol::Network::Header], ?reason_phrase: String, ?status_code: Integer) -> untyped def remove_data_collector: (collector: String) -> untyped def remove_intercept: (intercept: String) -> untyped def set_cache_behavior: (cache_behavior: Symbol, ?contexts: Array[String]) -> untyped diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/permissions.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/permissions.rbs index 6319c127c58ce..8f73d798be1c2 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/permissions.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/permissions.rbs @@ -39,6 +39,7 @@ module Selenium def self.new: (descriptor: ::Selenium::WebDriver::BiDi::Protocol::Permissions::PermissionDescriptor, state: Symbol, origin: String, ?embedded_origin: String, ?user_context: String) -> instance end + def permission_descriptor: (name: String) -> PermissionDescriptor def set_permission: (descriptor: ::Selenium::WebDriver::BiDi::Protocol::Permissions::PermissionDescriptor, state: Symbol, origin: String, ?embedded_origin: String, ?user_context: String) -> untyped end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs index 7f734e5c521df..633e063b3e2c9 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/script.rbs @@ -35,6 +35,14 @@ module Selenium SERIALIZATION_OPTIONS_INCLUDE_SHADOW_TREE: Hash[Symbol, String] + type evaluate_result = ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResultSuccess | ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResultException + type local_value = ::Selenium::WebDriver::BiDi::Protocol::Script::UndefinedValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NullValue | ::Selenium::WebDriver::BiDi::Protocol::Script::StringValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NumberValue | ::Selenium::WebDriver::BiDi::Protocol::Script::BooleanValue | ::Selenium::WebDriver::BiDi::Protocol::Script::BigIntValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ChannelValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ArrayLocalValue | ::Selenium::WebDriver::BiDi::Protocol::Script::DateLocalValue | ::Selenium::WebDriver::BiDi::Protocol::Script::MapLocalValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ObjectLocalValue | ::Selenium::WebDriver::BiDi::Protocol::Script::RegExpLocalValue | ::Selenium::WebDriver::BiDi::Protocol::Script::SetLocalValue | ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteReference + type primitive_protocol_value = ::Selenium::WebDriver::BiDi::Protocol::Script::UndefinedValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NullValue | ::Selenium::WebDriver::BiDi::Protocol::Script::StringValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NumberValue | ::Selenium::WebDriver::BiDi::Protocol::Script::BooleanValue | ::Selenium::WebDriver::BiDi::Protocol::Script::BigIntValue + type realm_info = ::Selenium::WebDriver::BiDi::Protocol::Script::WindowRealmInfo | ::Selenium::WebDriver::BiDi::Protocol::Script::DedicatedWorkerRealmInfo | ::Selenium::WebDriver::BiDi::Protocol::Script::SharedWorkerRealmInfo | ::Selenium::WebDriver::BiDi::Protocol::Script::ServiceWorkerRealmInfo | ::Selenium::WebDriver::BiDi::Protocol::Script::WorkerRealmInfo | ::Selenium::WebDriver::BiDi::Protocol::Script::PaintWorkletRealmInfo | ::Selenium::WebDriver::BiDi::Protocol::Script::AudioWorkletRealmInfo | ::Selenium::WebDriver::BiDi::Protocol::Script::WorkletRealmInfo + type remote_reference = ::Selenium::WebDriver::BiDi::Protocol::Script::SharedReference | ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteObjectReference + type remote_value = ::Selenium::WebDriver::BiDi::Protocol::Script::UndefinedValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NullValue | ::Selenium::WebDriver::BiDi::Protocol::Script::StringValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NumberValue | ::Selenium::WebDriver::BiDi::Protocol::Script::BooleanValue | ::Selenium::WebDriver::BiDi::Protocol::Script::BigIntValue | ::Selenium::WebDriver::BiDi::Protocol::Script::SymbolRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ArrayRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ObjectRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::FunctionRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::RegExpRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::DateRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::MapRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::SetRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::WeakMapRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::WeakSetRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::GeneratorRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ErrorRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ProxyRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::PromiseRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::TypedArrayRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::ArrayBufferRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NodeListRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::HTMLCollectionRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::NodeRemoteValue | ::Selenium::WebDriver::BiDi::Protocol::Script::WindowProxyRemoteValue + type target = ::Selenium::WebDriver::BiDi::Protocol::Script::ContextTarget | ::Selenium::WebDriver::BiDi::Protocol::Script::RealmTarget + class ChannelValue < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Script::ChannelProperties @@ -49,13 +57,15 @@ module Selenium end class EvaluateResult < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.success: (?type: String, result: ::Selenium::WebDriver::BiDi::Protocol::Script::remote_value, realm: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResultSuccess + def self.exception: (?type: String, exception_details: ::Selenium::WebDriver::BiDi::Protocol::Script::ExceptionDetails, realm: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResultException end class EvaluateResultSuccess < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String - attr_reader result: ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue + attr_reader result: ::Selenium::WebDriver::BiDi::Protocol::Script::remote_value attr_reader realm: String - def self.new: (?type: String, result: ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue, realm: String) -> instance + def self.new: (?type: String, result: ::Selenium::WebDriver::BiDi::Protocol::Script::remote_value, realm: String) -> instance end class EvaluateResultException < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -67,20 +77,33 @@ module Selenium class ExceptionDetails < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader column_number: Integer - attr_reader exception: ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue + attr_reader exception: ::Selenium::WebDriver::BiDi::Protocol::Script::remote_value attr_reader line_number: Integer attr_reader stack_trace: ::Selenium::WebDriver::BiDi::Protocol::Script::StackTrace attr_reader text: String - def self.new: (column_number: Integer, exception: ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue, line_number: Integer, stack_trace: ::Selenium::WebDriver::BiDi::Protocol::Script::StackTrace, text: String) -> instance + def self.new: (column_number: Integer, exception: ::Selenium::WebDriver::BiDi::Protocol::Script::remote_value, line_number: Integer, stack_trace: ::Selenium::WebDriver::BiDi::Protocol::Script::StackTrace, text: String) -> instance end class LocalValue < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.undefined: (?type: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::UndefinedValue + def self.null: (?type: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NullValue + def self.string: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::StringValue + def self.number: (?type: String, value: untyped) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NumberValue + def self.boolean: (?type: String, value: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Script::BooleanValue + def self.bigint: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::BigIntValue + def self.channel: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::Script::ChannelProperties) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ChannelValue + def self.array: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ArrayLocalValue + def self.date: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::DateLocalValue + def self.map: (?type: String, value: Array[Array[untyped]]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::MapLocalValue + def self.object: (?type: String, value: Array[Array[untyped]]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ObjectLocalValue + def self.regexp: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::Script::RegExpValue) -> ::Selenium::WebDriver::BiDi::Protocol::Script::RegExpLocalValue + def self.set: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::SetLocalValue end class ArrayLocalValue < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String - attr_reader value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue] - def self.new: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue]) -> instance + attr_reader value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value] + def self.new: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value]) -> instance end class DateLocalValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -115,11 +138,17 @@ module Selenium class SetLocalValue < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader type: String - attr_reader value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue] - def self.new: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue]) -> instance + attr_reader value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value] + def self.new: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value]) -> instance end class PrimitiveProtocolValue < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.undefined: (?type: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::UndefinedValue + def self.null: (?type: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NullValue + def self.string: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::StringValue + def self.number: (?type: String, value: untyped) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NumberValue + def self.boolean: (?type: String, value: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Script::BooleanValue + def self.bigint: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::BigIntValue end class UndefinedValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -157,6 +186,14 @@ module Selenium end class RealmInfo < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.window: (?type: String, realm: String, origin: String, context: String, ?user_context: String, ?sandbox: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::WindowRealmInfo + def self.dedicated_worker: (?type: String, realm: String, origin: String, owners: Array[String]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::DedicatedWorkerRealmInfo + def self.shared_worker: (?type: String, realm: String, origin: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::SharedWorkerRealmInfo + def self.service_worker: (?type: String, realm: String, origin: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ServiceWorkerRealmInfo + def self.worker: (?type: String, realm: String, origin: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::WorkerRealmInfo + def self.paint_worklet: (?type: String, realm: String, origin: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::PaintWorkletRealmInfo + def self.audio_worklet: (?type: String, realm: String, origin: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::AudioWorkletRealmInfo + def self.worklet: (?type: String, realm: String, origin: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::WorkletRealmInfo end class BaseRealmInfo < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -243,6 +280,32 @@ module Selenium end class RemoteValue < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.undefined: (?type: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::UndefinedValue + def self.null: (?type: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NullValue + def self.string: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::StringValue + def self.number: (?type: String, value: untyped) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NumberValue + def self.boolean: (?type: String, value: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Script::BooleanValue + def self.bigint: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::BigIntValue + def self.symbol: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::SymbolRemoteValue + def self.array: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ArrayRemoteValue + def self.object: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[Array[untyped]]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ObjectRemoteValue + def self.function: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::FunctionRemoteValue + def self.regexp: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::Script::RegExpValue, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::RegExpRemoteValue + def self.date: (?type: String, value: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::DateRemoteValue + def self.map: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[Array[untyped]]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::MapRemoteValue + def self.set: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::SetRemoteValue + def self.weakmap: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::WeakMapRemoteValue + def self.weakset: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::WeakSetRemoteValue + def self.generator: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::GeneratorRemoteValue + def self.error: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ErrorRemoteValue + def self.proxy: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ProxyRemoteValue + def self.promise: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::PromiseRemoteValue + def self.typedarray: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::TypedArrayRemoteValue + def self.arraybuffer: (?type: String, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::ArrayBufferRemoteValue + def self.nodelist: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NodeListRemoteValue + def self.htmlcollection: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> ::Selenium::WebDriver::BiDi::Protocol::Script::HTMLCollectionRemoteValue + def self.node: (?type: String, ?shared_id: String, ?handle: String, ?internal_id: String, ?value: ::Selenium::WebDriver::BiDi::Protocol::Script::NodeProperties) -> ::Selenium::WebDriver::BiDi::Protocol::Script::NodeRemoteValue + def self.window: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::Script::WindowProxyProperties, ?handle: String, ?internal_id: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::WindowProxyRemoteValue end class SymbolRemoteValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -257,7 +320,7 @@ module Selenium attr_reader handle: untyped attr_reader internal_id: untyped attr_reader value: untyped - def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue]) -> instance + def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> instance end class ObjectRemoteValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -304,7 +367,7 @@ module Selenium attr_reader handle: untyped attr_reader internal_id: untyped attr_reader value: untyped - def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue]) -> instance + def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> instance end class WeakMapRemoteValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -368,7 +431,7 @@ module Selenium attr_reader handle: untyped attr_reader internal_id: untyped attr_reader value: untyped - def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue]) -> instance + def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> instance end class HTMLCollectionRemoteValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -376,7 +439,7 @@ module Selenium attr_reader handle: untyped attr_reader internal_id: untyped attr_reader value: untyped - def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue]) -> instance + def self.new: (?type: String, ?handle: String, ?internal_id: String, ?value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::remote_value]) -> instance end class NodeRemoteValue < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -471,30 +534,30 @@ module Selenium class DisownParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader handles: Array[String] - attr_reader target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target - def self.new: (handles: Array[String], target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target) -> instance + attr_reader target: ::Selenium::WebDriver::BiDi::Protocol::Script::target + def self.new: (handles: Array[String], target: ::Selenium::WebDriver::BiDi::Protocol::Script::target) -> instance end class CallFunctionParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader function_declaration: String attr_reader await_promise: bool - attr_reader target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target + attr_reader target: ::Selenium::WebDriver::BiDi::Protocol::Script::target attr_reader arguments: untyped attr_reader result_ownership: untyped attr_reader serialization_options: untyped attr_reader this: untyped attr_reader user_activation: untyped - def self.new: (function_declaration: String, await_promise: bool, target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target, ?arguments: Array[::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue], ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?this: ::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue, ?user_activation: bool) -> instance + def self.new: (function_declaration: String, await_promise: bool, target: ::Selenium::WebDriver::BiDi::Protocol::Script::target, ?arguments: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value], ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?this: ::Selenium::WebDriver::BiDi::Protocol::Script::local_value, ?user_activation: bool) -> instance end class EvaluateParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader expression: String - attr_reader target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target + attr_reader target: ::Selenium::WebDriver::BiDi::Protocol::Script::target attr_reader await_promise: bool attr_reader result_ownership: untyped attr_reader serialization_options: untyped attr_reader user_activation: untyped - def self.new: (expression: String, target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target, await_promise: bool, ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?user_activation: bool) -> instance + def self.new: (expression: String, target: ::Selenium::WebDriver::BiDi::Protocol::Script::target, await_promise: bool, ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?user_activation: bool) -> instance end class GetRealmsParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -504,8 +567,8 @@ module Selenium end class GetRealmsResult < ::Selenium::WebDriver::BiDi::Serialization::Record - attr_reader realms: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RealmInfo] - def self.new: (realms: Array[::Selenium::WebDriver::BiDi::Protocol::Script::RealmInfo]) -> instance + attr_reader realms: Array[::Selenium::WebDriver::BiDi::Protocol::Script::realm_info] + def self.new: (realms: Array[::Selenium::WebDriver::BiDi::Protocol::Script::realm_info]) -> instance end class RemovePreloadScriptParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -515,9 +578,9 @@ module Selenium class MessageParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader channel: String - attr_reader data: ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue + attr_reader data: ::Selenium::WebDriver::BiDi::Protocol::Script::remote_value attr_reader source: ::Selenium::WebDriver::BiDi::Protocol::Script::Source - def self.new: (channel: String, data: ::Selenium::WebDriver::BiDi::Protocol::Script::RemoteValue, source: ::Selenium::WebDriver::BiDi::Protocol::Script::Source) -> instance + def self.new: (channel: String, data: ::Selenium::WebDriver::BiDi::Protocol::Script::remote_value, source: ::Selenium::WebDriver::BiDi::Protocol::Script::Source) -> instance end class RealmDestroyedParameters < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -527,10 +590,34 @@ module Selenium EVENT_TYPES: Hash[String, untyped] + def channel_value: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::Script::ChannelProperties) -> ChannelValue + def channel_properties: (channel: String, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?ownership: Symbol) -> ChannelProperties + def local_value: () -> singleton(LocalValue) + def array_local_value: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value]) -> ArrayLocalValue + def date_local_value: (?type: String, value: String) -> DateLocalValue + def map_local_value: (?type: String, value: Array[Array[untyped]]) -> MapLocalValue + def object_local_value: (?type: String, value: Array[Array[untyped]]) -> ObjectLocalValue + def reg_exp_value: (pattern: String, ?flags: String) -> RegExpValue + def reg_exp_local_value: (?type: String, value: ::Selenium::WebDriver::BiDi::Protocol::Script::RegExpValue) -> RegExpLocalValue + def set_local_value: (?type: String, value: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value]) -> SetLocalValue + def primitive_protocol_value: () -> singleton(PrimitiveProtocolValue) + def undefined_value: (?type: String) -> UndefinedValue + def null_value: (?type: String) -> NullValue + def string_value: (?type: String, value: String) -> StringValue + def number_value: (?type: String, value: untyped) -> NumberValue + def boolean_value: (?type: String, value: bool) -> BooleanValue + def big_int_value: (?type: String, value: String) -> BigIntValue + def remote_reference: () -> singleton(RemoteReference) + def shared_reference: (shared_id: String, ?handle: String, ?extensions: Hash[String, untyped]) -> SharedReference + def remote_object_reference: (handle: String, ?shared_id: String, ?extensions: Hash[String, untyped]) -> RemoteObjectReference + def serialization_options: (?max_dom_depth: Integer?, ?max_object_depth: Integer?, ?include_shadow_tree: Symbol) -> SerializationOptions + def realm_target: (realm: String) -> RealmTarget + def context_target: (context: String, ?sandbox: String) -> ContextTarget + def target: () -> singleton(Target) def add_preload_script: (function_declaration: String, ?arguments: Array[::Selenium::WebDriver::BiDi::Protocol::Script::ChannelValue], ?contexts: Array[String], ?user_contexts: Array[String], ?sandbox: String) -> ::Selenium::WebDriver::BiDi::Protocol::Script::AddPreloadScriptResult - def call_function: (function_declaration: String, await_promise: bool, target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target, ?arguments: Array[::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue], ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?this: ::Selenium::WebDriver::BiDi::Protocol::Script::LocalValue, ?user_activation: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResult - def disown: (handles: Array[String], target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target) -> untyped - def evaluate: (expression: String, target: ::Selenium::WebDriver::BiDi::Protocol::Script::Target, await_promise: bool, ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?user_activation: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResult + def call_function: (function_declaration: String, await_promise: bool, target: ::Selenium::WebDriver::BiDi::Protocol::Script::target, ?arguments: Array[::Selenium::WebDriver::BiDi::Protocol::Script::local_value], ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?this: ::Selenium::WebDriver::BiDi::Protocol::Script::local_value, ?user_activation: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResult + def disown: (handles: Array[String], target: ::Selenium::WebDriver::BiDi::Protocol::Script::target) -> untyped + def evaluate: (expression: String, target: ::Selenium::WebDriver::BiDi::Protocol::Script::target, await_promise: bool, ?result_ownership: Symbol, ?serialization_options: ::Selenium::WebDriver::BiDi::Protocol::Script::SerializationOptions, ?user_activation: bool) -> ::Selenium::WebDriver::BiDi::Protocol::Script::EvaluateResult def get_realms: (?context: String, ?type: Symbol) -> ::Selenium::WebDriver::BiDi::Protocol::Script::GetRealmsResult def remove_preload_script: (script: String) -> untyped end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs index a0ec23ebdc033..de6b4e8afcbc3 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/session.rbs @@ -25,6 +25,9 @@ module Selenium class Session < ::Selenium::WebDriver::BiDi::Protocol::Domain USER_PROMPT_HANDLER_TYPE: Hash[Symbol, String] + type proxy_configuration = ::Selenium::WebDriver::BiDi::Protocol::Session::AutodetectProxyConfiguration | ::Selenium::WebDriver::BiDi::Protocol::Session::DirectProxyConfiguration | ::Selenium::WebDriver::BiDi::Protocol::Session::ManualProxyConfiguration | ::Selenium::WebDriver::BiDi::Protocol::Session::PacProxyConfiguration | ::Selenium::WebDriver::BiDi::Protocol::Session::SystemProxyConfiguration + type unsubscribe_parameters = ::Selenium::WebDriver::BiDi::Protocol::Session::UnsubscribeByAttributesRequest | ::Selenium::WebDriver::BiDi::Protocol::Session::UnsubscribeByIDRequest + class CapabilitiesRequest < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader always_match: untyped attr_reader first_match: untyped @@ -39,10 +42,15 @@ module Selenium attr_reader proxy: untyped attr_reader unhandled_prompt_behavior: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (?accept_insecure_certs: bool, ?browser_name: String, ?browser_version: String, ?platform_name: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?extensions: Hash[String, untyped]) -> instance + def self.new: (?accept_insecure_certs: bool, ?browser_name: String, ?browser_version: String, ?platform_name: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::proxy_configuration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?extensions: Hash[String, untyped]) -> instance end class ProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.autodetect: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> ::Selenium::WebDriver::BiDi::Protocol::Session::AutodetectProxyConfiguration + def self.direct: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> ::Selenium::WebDriver::BiDi::Protocol::Session::DirectProxyConfiguration + def self.manual: (?proxy_type: String, ?http_proxy: String, ?ssl_proxy: String, socks_proxy: String, socks_version: Integer, ?no_proxy: Array[String], ?extensions: Hash[String, untyped]) -> ::Selenium::WebDriver::BiDi::Protocol::Session::ManualProxyConfiguration + def self.pac: (?proxy_type: String, proxy_autoconfig_url: String, ?extensions: Hash[String, untyped]) -> ::Selenium::WebDriver::BiDi::Protocol::Session::PacProxyConfiguration + def self.system: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> ::Selenium::WebDriver::BiDi::Protocol::Session::SystemProxyConfiguration end class AutodetectProxyConfiguration < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -141,7 +149,7 @@ module Selenium attr_reader unhandled_prompt_behavior: untyped attr_reader web_socket_url: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (accept_insecure_certs: bool, browser_name: String, browser_version: String, platform_name: String, set_window_rect: bool, user_agent: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::ProxyConfiguration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?web_socket_url: String, ?extensions: Hash[String, untyped]) -> instance + def self.new: (accept_insecure_certs: bool, browser_name: String, browser_version: String, platform_name: String, set_window_rect: bool, user_agent: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::proxy_configuration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?web_socket_url: String, ?extensions: Hash[String, untyped]) -> instance end end @@ -153,6 +161,17 @@ module Selenium class UnsubscribeParameters < ::Selenium::WebDriver::BiDi::Serialization::Union end + def capabilities_request: (?always_match: ::Selenium::WebDriver::BiDi::Protocol::Session::CapabilityRequest, ?first_match: Array[::Selenium::WebDriver::BiDi::Protocol::Session::CapabilityRequest]) -> CapabilitiesRequest + def capability_request: (?accept_insecure_certs: bool, ?browser_name: String, ?browser_version: String, ?platform_name: String, ?proxy: ::Selenium::WebDriver::BiDi::Protocol::Session::proxy_configuration, ?unhandled_prompt_behavior: ::Selenium::WebDriver::BiDi::Protocol::Session::UserPromptHandler, ?extensions: Hash[String, untyped]) -> CapabilityRequest + def proxy_configuration: () -> singleton(ProxyConfiguration) + def autodetect_proxy_configuration: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> AutodetectProxyConfiguration + def direct_proxy_configuration: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> DirectProxyConfiguration + def manual_proxy_configuration: (?proxy_type: String, ?http_proxy: String, ?ssl_proxy: String, socks_proxy: String, socks_version: Integer, ?no_proxy: Array[String], ?extensions: Hash[String, untyped]) -> ManualProxyConfiguration + def pac_proxy_configuration: (?proxy_type: String, proxy_autoconfig_url: String, ?extensions: Hash[String, untyped]) -> PacProxyConfiguration + def system_proxy_configuration: (?proxy_type: String, ?extensions: Hash[String, untyped]) -> SystemProxyConfiguration + def user_prompt_handler: (?alert: Symbol, ?before_unload: Symbol, ?confirm: Symbol, ?default: Symbol, ?file: Symbol, ?prompt: Symbol) -> UserPromptHandler + def unsubscribe_by_id_request: (subscriptions: Array[String]) -> UnsubscribeByIDRequest + def unsubscribe_by_attributes_request: (events: Array[String]) -> UnsubscribeByAttributesRequest def end_: () -> untyped def new: (capabilities: ::Selenium::WebDriver::BiDi::Protocol::Session::CapabilitiesRequest) -> ::Selenium::WebDriver::BiDi::Protocol::Session::NewResult def status: () -> ::Selenium::WebDriver::BiDi::Protocol::Session::StatusResult diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs index a0270089446d4..95a275e12e249 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/storage.rbs @@ -23,6 +23,8 @@ module Selenium class BiDi module Protocol class Storage < ::Selenium::WebDriver::BiDi::Protocol::Domain + type partition_descriptor = ::Selenium::WebDriver::BiDi::Protocol::Storage::BrowsingContextPartitionDescriptor | ::Selenium::WebDriver::BiDi::Protocol::Storage::StorageKeyPartitionDescriptor + class PartitionKey < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader user_context: untyped attr_reader source_origin: untyped @@ -41,7 +43,7 @@ module Selenium attr_reader same_site: untyped attr_reader expiry: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (?name: String, ?value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, ?domain: String, ?path: String, ?size: Integer, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance + def self.new: (?name: String, ?value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?domain: String, ?path: String, ?size: Integer, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance end class BrowsingContextPartitionDescriptor < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -59,12 +61,14 @@ module Selenium end class PartitionDescriptor < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.context: (?type: String, context: String) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::BrowsingContextPartitionDescriptor + def self.storage_key: (?type: String, ?user_context: String, ?source_origin: String, ?extensions: Hash[String, untyped]) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::StorageKeyPartitionDescriptor end class GetCookiesParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader filter: untyped attr_reader partition: untyped - def self.new: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartitionDescriptor) -> instance + def self.new: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::partition_descriptor) -> instance end class GetCookiesResult < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -75,7 +79,7 @@ module Selenium class PartialCookie < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader name: String - attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue + attr_reader value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value attr_reader domain: String attr_reader path: untyped attr_reader http_only: untyped @@ -83,13 +87,13 @@ module Selenium attr_reader same_site: untyped attr_reader expiry: untyped attr_reader extensions: Hash[String, untyped] - def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::BytesValue, domain: String, ?path: String, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance + def self.new: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, domain: String, ?path: String, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> instance end class SetCookieParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader cookie: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartialCookie attr_reader partition: untyped - def self.new: (cookie: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartialCookie, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartitionDescriptor) -> instance + def self.new: (cookie: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartialCookie, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::partition_descriptor) -> instance end class SetCookieResult < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -100,7 +104,7 @@ module Selenium class DeleteCookiesParameters < ::Selenium::WebDriver::BiDi::Serialization::Record attr_reader filter: untyped attr_reader partition: untyped - def self.new: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartitionDescriptor) -> instance + def self.new: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::partition_descriptor) -> instance end class DeleteCookiesResult < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -108,9 +112,14 @@ module Selenium def self.new: (partition_key: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartitionKey) -> instance end - def delete_cookies: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartitionDescriptor) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::DeleteCookiesResult - def get_cookies: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartitionDescriptor) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::GetCookiesResult - def set_cookie: (cookie: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartialCookie, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartitionDescriptor) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::SetCookieResult + def cookie_filter: (?name: String, ?value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, ?domain: String, ?path: String, ?size: Integer, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> CookieFilter + def browsing_context_partition_descriptor: (?type: String, context: String) -> BrowsingContextPartitionDescriptor + def storage_key_partition_descriptor: (?type: String, ?user_context: String, ?source_origin: String, ?extensions: Hash[String, untyped]) -> StorageKeyPartitionDescriptor + def partition_descriptor: () -> singleton(PartitionDescriptor) + def partial_cookie: (name: String, value: ::Selenium::WebDriver::BiDi::Protocol::Network::bytes_value, domain: String, ?path: String, ?http_only: bool, ?secure: bool, ?same_site: Symbol, ?expiry: Integer, ?extensions: Hash[String, untyped]) -> PartialCookie + def delete_cookies: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::partition_descriptor) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::DeleteCookiesResult + def get_cookies: (?filter: ::Selenium::WebDriver::BiDi::Protocol::Storage::CookieFilter, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::partition_descriptor) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::GetCookiesResult + def set_cookie: (cookie: ::Selenium::WebDriver::BiDi::Protocol::Storage::PartialCookie, ?partition: ::Selenium::WebDriver::BiDi::Protocol::Storage::partition_descriptor) -> ::Selenium::WebDriver::BiDi::Protocol::Storage::SetCookieResult end end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rbs index e293e010c6888..09611afb5431f 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rbs @@ -50,6 +50,8 @@ module Selenium def self.new: (client_hints: ::Selenium::WebDriver::BiDi::Protocol::UserAgentClientHints::ClientHintsMetadata?, ?contexts: Array[String], ?user_contexts: Array[String]) -> instance end + def client_hints_metadata: (?brands: Array[::Selenium::WebDriver::BiDi::Protocol::UserAgentClientHints::BrandVersion], ?full_version_list: Array[::Selenium::WebDriver::BiDi::Protocol::UserAgentClientHints::BrandVersion], ?platform: String, ?platform_version: String, ?architecture: String, ?model: String, ?mobile: bool, ?bitness: String, ?wow64: bool, ?form_factors: Array[String]) -> ClientHintsMetadata + def brand_version: (brand: String, version: String) -> BrandVersion def set_client_hints_override: (client_hints: ::Selenium::WebDriver::BiDi::Protocol::UserAgentClientHints::ClientHintsMetadata?, ?contexts: Array[String], ?user_contexts: Array[String]) -> untyped end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs b/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs index 78b4faa84ba19..81b91da6349a7 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/protocol/web_extension.rbs @@ -23,7 +23,12 @@ module Selenium class BiDi module Protocol class WebExtension < ::Selenium::WebDriver::BiDi::Protocol::Domain + type extension_data = ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionArchivePath | ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionBase64Encoded | ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionPath + class ExtensionData < ::Selenium::WebDriver::BiDi::Serialization::Union + def self.archive_path: (?type: String, path: String) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionArchivePath + def self.base64: (?type: String, value: String) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionBase64Encoded + def self.path: (?type: String, path: String) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionPath end class ExtensionPath < ::Selenium::WebDriver::BiDi::Serialization::Record @@ -55,16 +60,21 @@ module Selenium end class InstallParameters < ::Selenium::WebDriver::BiDi::Serialization::Record - attr_reader extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData + attr_reader extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::extension_data attr_reader extensions: Hash[String, untyped] - def self.new: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData, ?extensions: Hash[String, untyped]) -> instance + def self.new: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::extension_data, ?extensions: Hash[String, untyped]) -> instance end - def install: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::InstallResult + def extension_data: () -> singleton(ExtensionData) + def extension_path: (?type: String, path: String) -> ExtensionPath + def extension_archive_path: (?type: String, path: String) -> ExtensionArchivePath + def extension_base64_encoded: (?type: String, value: String) -> ExtensionBase64Encoded + def moz: () -> Moz + def install: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::extension_data) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::InstallResult def uninstall: (extension: String) -> untyped class Moz < ::Selenium::WebDriver::BiDi::Protocol::WebExtension - def install: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::ExtensionData, ?allow_private_browsing: bool, ?permanent: bool) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::InstallResult + def install: (extension_data: ::Selenium::WebDriver::BiDi::Protocol::WebExtension::extension_data, ?allow_private_browsing: bool, ?permanent: bool) -> ::Selenium::WebDriver::BiDi::Protocol::WebExtension::InstallResult end end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/transport.rbs b/rb/sig/lib/selenium/webdriver/bidi/transport.rbs index 68753987e2cb6..6b932ba95322e 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/transport.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/transport.rbs @@ -22,6 +22,8 @@ module Selenium class Transport @connection: untyped + attr_reader connection: untyped + def initialize: (untyped connection) -> void def execute: (cmd: String, ?params: untyped, ?result: untyped) -> untyped diff --git a/rb/spec/unit/selenium/webdriver/bidi/protocol_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/protocol_spec.rb index e0bb07d1c50f7..e12fecb4fbad9 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/protocol_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/protocol_spec.rb @@ -92,6 +92,65 @@ module Protocol expect(parsed.url).to eq('https://x') end end + + describe 'outbound domain type accessors' do + it 'exposes an outbound union as an accessor returning the class (variant factories dispatch)' do + expect(WebExtension.new(connection).extension_data).to eq(WebExtension::ExtensionData) + end + + it 'constructs an outbound record directly through its accessor' do + path = WebExtension.new(connection).extension_path(path: '/tmp/ext') + + expect(path).to be_a(WebExtension::ExtensionPath) + expect(path.as_json).to eq('type' => 'path', 'path' => '/tmp/ext') + end + + it 'builds a variant end-to-end through a union accessor and its factory' do + built = WebExtension.new(connection).extension_data.path(path: '/tmp/ext') + + expect(built).to be_a(WebExtension::ExtensionPath) + expect(built.as_json).to eq('type' => 'path', 'path' => '/tmp/ext') + end + + it 'dispatches a locator variant through a union accessor factory' do + built = BrowsingContext.new(connection).locator.css(value: '.submit') + + expect(built).to be_a(BrowsingContext::CssLocator) + expect(built.as_json).to eq('type' => 'css', 'value' => '.submit') + end + + it 'exposes a vendor variant over the same connection, driving its overridden command' do + moz = WebExtension.new(connection).moz + expect(moz).to be_a(WebExtension::Moz) + + allow(connection).to receive(:send_cmd).and_return('result' => {'extension' => 'ext-id'}) + moz.install(extension_data: WebExtension.new(connection).extension_path(path: '/tmp/ext'), + allow_private_browsing: true) + + expect(connection).to have_received(:send_cmd) + .with(method: 'webExtension.install', + params: hash_including('moz:allowPrivateBrowsing' => true)) + end + + it 'does not expose an inbound-only type (script.RemoteValue is received, never sent)' do + expect(Script.new(connection)).not_to respond_to(:remote_value) + end + + it 'does not expose a command param wrapper (the command method builds it)' do + expect(WebExtension.new(connection)).not_to respond_to(:install_parameters) + end + + it 'exposes a nested type reached as a plain field ref (a locator value a caller fills in)' do + value = BrowsingContext.new(connection).accessibility_locator_value(name: 'submit', role: 'button') + + expect(value).to be_a(BrowsingContext::AccessibilityLocator::Value) + expect(value.as_json).to eq('name' => 'submit', 'role' => 'button') + end + + it 'does not expose a synthetic reached only as a union arm (built through its union)' do + expect(Network.new(connection)).not_to respond_to(:continue_with_auth_parameters_credentials) + end + end end end # Protocol end # BiDi diff --git a/rb/spec/unit/selenium/webdriver/bidi/support/bidi_generate_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/support/bidi_generate_spec.rb index 6e85cf2a1f6af..1fcf383ebfeb1 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/support/bidi_generate_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/support/bidi_generate_spec.rb @@ -44,4 +44,40 @@ module BiDiGenerate expect(BiDiGenerate.enum_key('dedicated-worker')).to eq('dedicated_worker') end end + + describe '.check_accessor_collisions!' do + def accessor(name) + BiDiGenerate::Accessor.new(method_name: name, type_name: 'T', union: false) + end + + def command(name) + BiDiGenerate::Command.new(wire_name: "x.#{name}", method_name: name, params: [], result_ref: nil, + params_class: nil, union_params: false, spec_href: nil) + end + + def mod(accessors:, commands: []) + BiDiGenerate::Module.new(name: 'x', ruby_class: 'X', filename: 'x', commands: commands, events: [], + enums: [], types: [], accessors: accessors, vendor_modules: [], spec_href: nil) + end + + it 'passes when accessor names are unique and unshadowed' do + expect { BiDiGenerate.check_accessor_collisions!(mod(accessors: [accessor('extension_path')])) } + .not_to raise_error + end + + it 'fails when an accessor shadows a command method' do + expect { BiDiGenerate.check_accessor_collisions!(mod(accessors: [accessor('foo')], commands: [command('foo')])) } + .to raise_error(/collides with a command method/) + end + + it 'fails when an accessor shadows an inherited method' do + expect { BiDiGenerate.check_accessor_collisions!(mod(accessors: [accessor('hash')])) } + .to raise_error(/collides with an inherited method/) + end + + it 'fails when two accessors share a name' do + expect { BiDiGenerate.check_accessor_collisions!(mod(accessors: [accessor('dupe'), accessor('dupe')])) } + .to raise_error(/collides with the accessor/) + end + end end From 5ae1a8e18a7fc9fcf9f7453e7dde7f5b08eb2d44 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Mon, 3 Aug 2026 17:01:33 -0500 Subject: [PATCH 33/56] [build] update node versioning for testing and publishing (#17866) * [build] test JavaScript on oldest and newest supported Node and publish on npm 11 * [build] run JavaScript unit tests on pull requests --- .github/workflows/bazel.yml | 8 +++--- .github/workflows/ci-javascript.yml | 30 ++++++++++++++++++++++ .github/workflows/ci.yml | 12 +++++++-- .github/workflows/release.yml | 2 +- .nvmrc | 1 + MODULE.bazel | 2 +- javascript/selenium-webdriver/README.md | 23 +++++++++-------- javascript/selenium-webdriver/package.json | 2 +- 8 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci-javascript.yml create mode 100644 .nvmrc diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 9796ce323cfb0..a8e5c2a96da1c 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -32,7 +32,7 @@ on: type: boolean default: false node-version: - description: Custom Node version to install + description: Custom Node version to use required: false type: string default: '' @@ -182,11 +182,9 @@ jobs: - name: Set Ruby version if: inputs.ruby-version != '' run: echo '${{ inputs.ruby-version }}' > rb/.ruby-version - - name: Setup Node + - name: Set Node version if: inputs.node-version != '' - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} + run: echo '${{ inputs.node-version }}' > .nvmrc - name: Setup Bazel with caching continue-on-error: true timeout-minutes: 10 diff --git a/.github/workflows/ci-javascript.yml b/.github/workflows/ci-javascript.yml new file mode 100644 index 0000000000000..883e8ee5f379b --- /dev/null +++ b/.github/workflows/ci-javascript.yml @@ -0,0 +1,30 @@ +name: CI - JavaScript + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build + uses: ./.github/workflows/bazel.yml + with: + name: Build + run: bazel build //javascript/selenium-webdriver + + unit-tests: + name: Unit Tests + uses: ./.github/workflows/bazel.yml + strategy: + fail-fast: false + matrix: + node-version: ['22.22.0', '24.14.1'] + os: [ubuntu] + with: + name: Unit Tests (${{ matrix.node-version }}, ${{ matrix.os }}) + os: ${{ matrix.os }} + node-version: ${{ matrix.node-version }} + run: bazel test --local_test_jobs 1 //javascript/selenium-webdriver:small-tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11f1d413e2a34..c81ef66b4588f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: [ "${{ github.event_name }}" == "workflow_call" ] || \ [ "${{ github.event_name }}" == "workflow_dispatch" ]; then echo "Running all targets for ${{ github.event_name }} event" - echo "//java/... //py/... //rb/... //dotnet/... //rust/..." > bazel-targets.txt + echo "//java/... //py/... //rb/... //dotnet/... //rust/... //javascript/..." > bazel-targets.txt else if [ -n "${{ github.event.pull_request.base.sha }}" ]; then BASE_SHA="HEAD^1" @@ -70,6 +70,7 @@ jobs: rb: ${{ steps.read.outputs.rb }} dotnet: ${{ steps.read.outputs.dotnet }} rust: ${{ steps.read.outputs.rust }} + js: ${{ steps.read.outputs.js }} steps: - name: Download targets uses: actions/download-artifact@v8 @@ -101,6 +102,7 @@ jobs: check_binding "openqa/selenium/grid" "grid" check_binding "//dotnet" "dotnet" check_binding "//rust" "rust" + check_binding "//javascript" "js" process_binding "//rb" "rb" process_binding "//py" "py" - name: Upload target files @@ -147,10 +149,16 @@ jobs: SELENIUM_CI_TOKEN: ${{ secrets.SELENIUM_CI_TOKEN }} if: needs.read-targets.outputs.rust != '' + javascript: + name: JavaScript + needs: read-targets + uses: ./.github/workflows/ci-javascript.yml + if: needs.read-targets.outputs.js != '' + ci-success: name: CI Success if: always() - needs: [check, read-targets, dotnet, java, grid, python, ruby, rust] + needs: [check, read-targets, dotnet, java, grid, python, ruby, rust, javascript] runs-on: ubuntu-latest steps: - name: Verify required jobs succeeded diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0517988819704..b5ddc3ddd9eb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,7 +141,7 @@ jobs: name: Publish ${{ matrix.language }} gpg-sign: ${{ matrix.language == 'java' }} gem-trusted-publishing: ${{ matrix.language == 'ruby' }} - node-version: ${{ matrix.language == 'javascript' && '24' || '' }} + node-version: ${{ matrix.language == 'javascript' && '24.14.1' || '' }} run: | if [ "${{ matrix.language == 'java' && (needs.parse-tag.outputs.language == 'all' || needs.parse-tag.outputs.language == 'java') && github.run_attempt > 1 }}" = "true" ]; then echo "::error::Java release is not yet rerun-safe — check/drop the staging repo at https://central.sonatype.com/publishing/deployments and publish manually" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000000..85e502778f623 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.22.0 diff --git a/MODULE.bazel b/MODULE.bazel index 6ed26168a30d2..031db0abc184c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -76,7 +76,7 @@ linter.configure( linter.register(name = "rust-rustfmt") node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") -node.toolchain(node_version = "22.22.0") +node.toolchain(node_version_from_nvmrc = "//:.nvmrc") pnpm = use_extension( "@aspect_rules_js//npm:extensions.bzl", diff --git a/javascript/selenium-webdriver/README.md b/javascript/selenium-webdriver/README.md index bc09c868b97df..6616cb0295469 100644 --- a/javascript/selenium-webdriver/README.md +++ b/javascript/selenium-webdriver/README.md @@ -3,7 +3,7 @@ JavaScript language bindings for [Selenium WebDriver](https://www.selenium.dev). Selenium automates browsers for testing and web-based task automation. -Requires Node.js >= 20. +Requires Node.js >= 22. ## Installation @@ -63,17 +63,20 @@ SELENIUM_REMOTE_URL="http://localhost:4444" node script.js ## Node Support Policy -Each `selenium-webdriver` release targets the latest _semver-minor_ of Node's -[LTS and Current releases](https://github.com/nodejs/release#release-schedule). +`selenium-webdriver` supports the Node.js versions under active upstream +support. Each is supported until its +[end-of-life](https://github.com/nodejs/release#release-schedule) date; after +that it is unsupported. -| Level | Guarantee | -| :------------ | :------------------------------------------------------------------------ | -| _supported_ | API compatible without runtime flags; bugs investigated and fixed. | -| _best effort_ | Bugs investigated as time permits; API compatibility only where required. | -| _unsupported_ | Bug reports closed as will-not-fix; API compatibility not guaranteed. | +| Node.js | Support ends | +| :------ | :----------- | +| 22 | 2027-04-30 | +| 24 | 2028-04-30 | +| 26 | 2029-04-30 | -Versions older than the active LTS, unstable release branches (e.g. `v.Next`), -and _semver-major_ Node releases outside the LTS / Current pair are _unsupported_. +CI tests the earliest and latest supported versions available in +[rules_nodejs](https://github.com/bazel-contrib/rules_nodejs); issues and pull +requests are welcome for any supported version. ## Documentation diff --git a/javascript/selenium-webdriver/package.json b/javascript/selenium-webdriver/package.json index 9689ea5e9facb..f9eb08d94a7dc 100644 --- a/javascript/selenium-webdriver/package.json +++ b/javascript/selenium-webdriver/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/SeleniumHQ/selenium.git" }, "engines": { - "node": ">= 20.0.0" + "node": ">= 22.0.0" }, "dependencies": { "@bazel/runfiles": "^6.5.0", From d9b34242b65b04aa704c70ed28123bde4fdd7fb3 Mon Sep 17 00:00:00 2001 From: Selenium CI Bot Date: Tue, 4 Aug 2026 11:26:25 +0200 Subject: [PATCH 34/56] [build] Automated Browser Version Update (#17868) Update pinned browser versions Co-authored-by: Selenium CI Bot --- common/repositories.bzl | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/common/repositories.bzl b/common/repositories.bzl index c7cb3ba74a4af..0a00d2a7b7945 100644 --- a/common/repositories.bzl +++ b/common/repositories.bzl @@ -51,8 +51,8 @@ js_library( http_archive( name = "linux_beta_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b5/linux-x86_64/en-US/firefox-154.0b5.tar.xz", - sha256 = "1a3db36dcfab84c6e08f4c75ddb83ad03c6af7f15e4d6e99eba9210dd9c267d1", + url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b6/linux-x86_64/en-US/firefox-154.0b6.tar.xz", + sha256 = "48c55729fe4cad59fde12c0e0c12d140806d9057b920077144bf523424da29bd", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -73,8 +73,8 @@ js_library( dmg_archive( name = "mac_beta_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b5/mac/en-US/Firefox%20154.0b5.dmg", - sha256 = "100d718aed667c87af912adef24ef2b30d0e9525e2f8eec5f757bd5fa6aa673d", + url = "https://ftp.mozilla.org/pub/firefox/releases/154.0b6/mac/en-US/Firefox%20154.0b6.dmg", + sha256 = "d2a0f37a8c12c8deb8363b1d6262a55bd107f89a29198152edd104e06d38ba74", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -278,8 +278,8 @@ js_library( http_archive( name = "linux_beta_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/linux64/chrome-linux64.zip", - sha256 = "931865951a28fccf0491a7f5e0a2fe1a0605765210a4ce4a4758d9d3d97d0b77", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.13/linux64/chrome-linux64.zip", + sha256 = "439820cfcd5ca98a95db493be6f50c4f69eed338f5c3d5b0c09fdc786c824671", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -299,8 +299,8 @@ js_library( ) http_archive( name = "mac_beta_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/mac-arm64/chrome-mac-arm64.zip", - sha256 = "78570bb23c7442f581c2b5f1c86c3e83b68b6cd35424cf390add51a1e305398b", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.13/mac-arm64/chrome-mac-arm64.zip", + sha256 = "d1b69f54209e4302898497061af060464d13338e005dadb4a4c7b61bda9649ef", strip_prefix = "chrome-mac-arm64", patch_cmds = [ "mv 'Google Chrome for Testing.app' Chrome.app", @@ -320,8 +320,8 @@ js_library( ) http_archive( name = "linux_beta_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/linux64/chromedriver-linux64.zip", - sha256 = "3561dff6eb2126862418182cf2bff617230d8122b3d7f202b2ba781e35842caf", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.13/linux64/chromedriver-linux64.zip", + sha256 = "6a9e7da2c1361e244c837c9fd43170244408aa021002668783ad5761705cde72", strip_prefix = "chromedriver-linux64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") @@ -338,8 +338,8 @@ js_library( http_archive( name = "mac_beta_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.8/mac-arm64/chromedriver-mac-arm64.zip", - sha256 = "39fa1eb272ce2d6cb98515b01e5d1775ff0d66922f72663a75d982045cdd19f6", + url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.13/mac-arm64/chromedriver-mac-arm64.zip", + sha256 = "d913610a751147faedc996391546310627deb5e27014efb6dc9dd4e50ba19f82", strip_prefix = "chromedriver-mac-arm64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") From 778cc7a1731dc9c19a6c0e035cde7fba711de00e Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 4 Aug 2026 13:02:51 -0500 Subject: [PATCH 35/56] [py] accept By in find_element/find_elements type hints (#17870) [py] accept By in find_element/find_elements type hints (#17867) --- py/selenium/webdriver/remote/shadowroot.py | 4 ++-- py/selenium/webdriver/remote/webdriver.py | 4 ++-- py/selenium/webdriver/remote/webelement.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/py/selenium/webdriver/remote/shadowroot.py b/py/selenium/webdriver/remote/shadowroot.py index d1c381cb361e6..452967958f2c6 100644 --- a/py/selenium/webdriver/remote/shadowroot.py +++ b/py/selenium/webdriver/remote/shadowroot.py @@ -52,7 +52,7 @@ def __repr__(self) -> str: def id(self) -> str: return self._id - def find_element(self, by: str = By.ID, value: str | None = None) -> WebElement: + def find_element(self, by: str | By = By.ID, value: str | None = None) -> WebElement: """Find an element inside a shadow root given a By strategy and locator. Args: @@ -87,7 +87,7 @@ def find_element(self, by: str = By.ID, value: str | None = None) -> WebElement: return self._execute(Command.FIND_ELEMENT_FROM_SHADOW_ROOT, {"using": by, "value": value})["value"] - def find_elements(self, by: str = By.ID, value: str | None = None) -> list[WebElement]: + def find_elements(self, by: str | By = By.ID, value: str | None = None) -> list[WebElement]: """Find elements inside a shadow root given a By strategy and locator. Args: diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 4cfb788e26039..e88a36a6b6749 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -884,7 +884,7 @@ def timeouts(self, timeouts) -> None: """ _ = self.execute(Command.SET_TIMEOUTS, timeouts._to_json())["value"] - def find_element(self, by: str | RelativeBy = By.ID, value: str | None = None) -> WebElement: + def find_element(self, by: str | By | RelativeBy = By.ID, value: str | None = None) -> WebElement: """Find an element given a By strategy and locator. Args: @@ -910,7 +910,7 @@ def find_element(self, by: str | RelativeBy = By.ID, value: str | None = None) - return self.execute(Command.FIND_ELEMENT, {"using": by, "value": value})["value"] - def find_elements(self, by: str | RelativeBy = By.ID, value: str | None = None) -> list[WebElement]: + def find_elements(self, by: str | By | RelativeBy = By.ID, value: str | None = None) -> list[WebElement]: """Find elements given a By strategy and locator. Args: diff --git a/py/selenium/webdriver/remote/webelement.py b/py/selenium/webdriver/remote/webelement.py index 09674312ae7b2..576091e4b15e8 100644 --- a/py/selenium/webdriver/remote/webelement.py +++ b/py/selenium/webdriver/remote/webelement.py @@ -507,7 +507,7 @@ def _execute(self, command, params=None): params["id"] = self._id return self._parent.execute(command, params) - def find_element(self, by: str = By.ID, value: str | None = None) -> WebElement: + def find_element(self, by: str | By = By.ID, value: str | None = None) -> WebElement: """Find an element given a By strategy and locator. Args: @@ -531,7 +531,7 @@ def find_element(self, by: str = By.ID, value: str | None = None) -> WebElement: by, value = self._parent.locator_converter.convert(by, value) return self._execute(Command.FIND_CHILD_ELEMENT, {"using": by, "value": value})["value"] - def find_elements(self, by: str = By.ID, value: str | None = None) -> list[WebElement]: + def find_elements(self, by: str | By = By.ID, value: str | None = None) -> list[WebElement]: """Find elements given a By strategy and locator. Args: From a4210147da773db0eec6d6d21fffb2535ce6862b Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 4 Aug 2026 17:09:19 -0500 Subject: [PATCH 36/56] [build] update CDP from daily pin browser workflow (#17872) * [build] regenerate CDP in the daily browser workflow if chrome stable version missing directory * [build] run full Ruby tests on major browser PRs --- .github/workflows/bazel.yml | 6 +++ .github/workflows/ci-ruby.yml | 12 +++-- .github/workflows/ci.yml | 2 + .github/workflows/pin-browsers.yml | 6 +-- scripts/github-actions/update_browsers.sh | 66 +++++++++++++++++++++++ 5 files changed, 86 insertions(+), 6 deletions(-) create mode 100755 scripts/github-actions/update_browsers.sh diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index a8e5c2a96da1c..86fd044141fef 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -91,11 +91,17 @@ on: required: false type: boolean default: false + outputs: + output: + description: Value the run script writes to $GITHUB_OUTPUT as "output=..."; empty when unused + value: ${{ jobs.bazel.outputs.output }} jobs: bazel: name: ${{ inputs.name }} runs-on: ${{ contains(inputs.os, '-') && inputs.os || format('{0}-latest', inputs.os) }} + outputs: + output: ${{ steps.run-bazel.outputs.output }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SEL_M2_USER: ${{ secrets.SEL_M2_USER }} diff --git a/.github/workflows/ci-ruby.yml b/.github/workflows/ci-ruby.yml index 192de97107386..30dcc3652cd4b 100644 --- a/.github/workflows/ci-ruby.yml +++ b/.github/workflows/ci-ruby.yml @@ -2,6 +2,12 @@ name: CI - Ruby on: workflow_call: + inputs: + smoke: + description: Run smoke tests only (callers pass false to run the full matrix) + required: false + type: boolean + default: true workflow_dispatch: inputs: smoke: @@ -25,7 +31,7 @@ jobs: # covers truffleruby and the most recent MRI release. unit-tests: name: Unit Tests - if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && !inputs.smoke) + if: github.event_name == 'schedule' || !inputs.smoke uses: ./.github/workflows/bazel.yml strategy: fail-fast: false @@ -48,7 +54,7 @@ jobs: smoke: name: ${{ matrix.os }}-smoke - if: github.event_name != 'schedule' && (github.event_name != 'workflow_dispatch' || inputs.smoke) + if: github.event_name != 'schedule' && inputs.smoke uses: ./.github/workflows/bazel.yml strategy: fail-fast: false @@ -79,7 +85,7 @@ jobs: os-tests-full: name: ${{ matrix.os }}-full - if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && !inputs.smoke) + if: github.event_name == 'schedule' || !inputs.smoke uses: ./.github/workflows/bazel.yml strategy: fail-fast: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c81ef66b4588f..eb8baea7fdd9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,8 @@ jobs: needs: read-targets uses: ./.github/workflows/ci-ruby.yml if: needs.read-targets.outputs.rb != '' + with: + smoke: ${{ !(github.head_ref == 'pinned-browser-updates' && contains(github.event.pull_request.title, '(major)')) }} rust: name: Rust diff --git a/.github/workflows/pin-browsers.yml b/.github/workflows/pin-browsers.yml index 896c3019d082f..3870a3b451386 100644 --- a/.github/workflows/pin-browsers.yml +++ b/.github/workflows/pin-browsers.yml @@ -13,7 +13,7 @@ jobs: uses: ./.github/workflows/bazel.yml with: name: Pin Browsers - run: bazel run //scripts:pinned_browsers + run: ./scripts/github-actions/update_browsers.sh artifact-name: pinned-browsers create-pr: @@ -49,9 +49,9 @@ jobs: commit-message: "Update pinned browser versions" author: Selenium CI Bot base: trunk - title: "[build] Automated Browser Version Update" + title: "[build] Automated Browser Version Update${{ contains(needs.update.outputs.output, 'major') && ' (major)' || '' }}${{ contains(needs.update.outputs.output, 'cdp') && ' with CDP' || '' }}" body: | - This is an automated pull request to update pinned browsers and drivers + This is an automated pull request to update pinned browsers and drivers.${{ contains(needs.update.outputs.output, 'major') && ' Major Chrome/Firefox bump: CI runs the full Ruby matrix.' || '' }}${{ contains(needs.update.outputs.output, 'cdp') && ' Chrome DevTools (CDP) was regenerated to match.' || '' }} Merge after verifying the new browser versions are properly passing the tests branch: "pinned-browser-updates" diff --git a/scripts/github-actions/update_browsers.sh b/scripts/github-actions/update_browsers.sh new file mode 100755 index 0000000000000..6b6167888c570 --- /dev/null +++ b/scripts/github-actions/update_browsers.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Refresh pinned browsers/drivers; when a new stable Chrome major has no checked-in DevTools +# version, regenerate CDP so the two never drift. Writes the job "output" as space-separated tags +# the PR reports: "major" on a Chrome/Firefox major bump (CI then runs the full Ruby matrix) and +# "cdp" when DevTools was regenerated. +# +# set -e is load-bearing: if update_cdp fails the job fails before "output" is written, so +# create-pr is skipped and a Chrome bump can never land ahead of its CDP. +set -euo pipefail + +old="$RUNNER_TEMP/repositories-old.bzl" +git show HEAD:common/repositories.bzl > "$old" + +bazel run //scripts:pinned_browsers + +# Sorted-unique major versions of one family in a repositories.bzl; $1 = file, $2 = ERE matching +# "". +majors_for() { grep -oE "$2" "$1" | grep -oE '[0-9]+$' | sort -un; } + +# Only Chrome and Firefox majors warrant the full matrix: they ship ~monthly and are the likeliest +# to break the bindings. Edge tracks Chromium; driver-only and build/patch bumps do not count. Each +# ERE matches the marker preceding the major in the download URLs pinned in repositories.bzl. +declare -A families=( + [chrome]='chrome-for-testing-public/[0-9]+' + [firefox]='(firefox/releases/|Firefox%20)[0-9]+' +) + +# New stable Chrome is the lowest pinned major (beta runs ahead). Tolerate a no-match (|| true) so a +# marker/format change in repositories.bzl fails with a clear message, not a bare pipefail exit. +chrome_majors=$(majors_for common/repositories.bzl "${families[chrome]}") || true +chrome=${chrome_majors%%$'\n'*} +if [ -z "$chrome" ]; then + echo "::error::Could not parse a stable Chrome major from common/repositories.bzl (pattern: ${families[chrome]})" >&2 + exit 1 +fi +echo "Stable Chrome major: v${chrome}" + +# Regenerate CDP when the stable Chrome major has no checked-in devtools dir. +regen_cdp=false +if [ -d "common/devtools/chromium/v${chrome}" ]; then + echo "DevTools for Chrome v${chrome} already present; skipping CDP regeneration" +else + echo "No DevTools for Chrome v${chrome}; regenerating CDP" + bazel run //scripts:update_cdp -- --chrome_channel=Stable + # update_cdp resolves the Stable channel itself; verify it produced this major's dir so a Stable + # release mid-run can't leave us pinning Chrome ahead of its DevTools (blocks the PR if it did). + if [ ! -d "common/devtools/chromium/v${chrome}" ]; then + echo "::error::CDP regeneration did not produce common/devtools/chromium/v${chrome}; refusing to pin Chrome ahead of its DevTools" >&2 + exit 1 + fi + regen_cdp=true +fi + +major=false +for pattern in "${families[@]}"; do + if [ "$(majors_for "$old" "$pattern")" != "$(majors_for common/repositories.bzl "$pattern")" ]; then + major=true + break + fi +done + +output="" +[ "$major" = true ] && output="major" +[ "$regen_cdp" = true ] && output="${output:+$output }cdp" +echo "Update tags: ${output:-none}" +echo "output=$output" >> "$GITHUB_OUTPUT" From 7e1c3fdb2d5d5079532f33ac87e2a4435853bc32 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 4 Aug 2026 18:15:07 -0500 Subject: [PATCH 37/56] [build] re-lock Gemfile after CDP update --- scripts/github-actions/update_browsers.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/github-actions/update_browsers.sh b/scripts/github-actions/update_browsers.sh index 6b6167888c570..5be91df743602 100755 --- a/scripts/github-actions/update_browsers.sh +++ b/scripts/github-actions/update_browsers.sh @@ -48,6 +48,8 @@ else echo "::error::CDP regeneration did not produce common/devtools/chromium/v${chrome}; refusing to pin Chrome ahead of its DevTools" >&2 exit 1 fi + # the selenium-devtools version has updated, need to re-resolve the lockfile + bazel run //rb:bundle-lock regen_cdp=true fi From 88e2d0cc0e9adf79712d0d15ebfff70d138c7427 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 4 Aug 2026 22:13:14 -0500 Subject: [PATCH 38/56] [js] Wait for async BiDi events and window resize in flaky tests (#17874) * [js] wait for async BiDi log/mutation events and window resize in flaky tests * [js] skip window resize polling on IE where getSize is approximate --- javascript/atoms/test/window_size_test.html | 15 ++++++++++++++- .../test/lib/webdriver_script_test.js | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/javascript/atoms/test/window_size_test.html b/javascript/atoms/test/window_size_test.html index 8c9c6acd1a199..2b85a1ff5b9d6 100644 --- a/javascript/atoms/test/window_size_test.html +++ b/javascript/atoms/test/window_size_test.html @@ -69,8 +69,21 @@ return; } var size = new goog.math.Size(350, 450); + var done = assert.async(); + var start = new Date().getTime(); bot.window.setSize(size); - verifySize(assert, size); + var isIE = bot.userAgent.IE_DOC_9 || bot.userAgent.IE_DOC_PRE9; + (function poll() { + var actual = bot.window.getSize(); + if (isIE || + (actual.width === size.width && actual.height === size.height) || + new Date().getTime() - start > 2000) { + verifySize(assert, size); + done(); + return; + } + window.setTimeout(poll, 50); + })(); }); QUnit.test('setSizeUsingGetSize', function(assert) { diff --git a/javascript/selenium-webdriver/test/lib/webdriver_script_test.js b/javascript/selenium-webdriver/test/lib/webdriver_script_test.js index f1e4c632f1ad1..a1d260ae32e18 100644 --- a/javascript/selenium-webdriver/test/lib/webdriver_script_test.js +++ b/javascript/selenium-webdriver/test/lib/webdriver_script_test.js @@ -36,6 +36,14 @@ suite( }) describe('script()', function () { + async function waitForLogEntry(getLogEntry, message) { + await driver.wait(() => getLogEntry() != null, 5000, message) + } + + async function waitForLogText(logs, text) { + await driver.wait(() => logs.includes(text), 5000, `Timed out waiting for console log "${text}"`) + } + it('can listen to console log', async function () { let log = null const handler = await driver.script().addConsoleMessageHandler((logEntry) => { @@ -44,6 +52,7 @@ suite( await driver.get(Pages.logEntryAdded) await driver.findElement({ id: 'consoleLog' }).click() + await waitForLogEntry(() => log, 'Timed out waiting for console log entry') assert.equal(log.text, 'Hello, world!') assert.equal(log.realm, null) @@ -62,6 +71,7 @@ suite( await driver.get(Pages.logEntryAdded) await driver.findElement({ id: 'jsException' }).click() + await waitForLogEntry(() => log, 'Timed out waiting for JavaScript error log entry') assert.equal(log.text, 'Error: Not working') assert.equal(log.type, 'javascript') @@ -91,6 +101,7 @@ suite( await element.click() let revealed = driver.findElement({ id: 'revealed' }) await driver.wait(until.elementIsVisible(revealed), 5000) + await waitForLogEntry(() => message, 'Timed out waiting for DOM mutation') assert.strictEqual(message['attribute_name'], 'style') assert.strictEqual(message['current_value'], '') @@ -124,6 +135,7 @@ suite( }) await driver.get(Pages.logEntryAdded) + await waitForLogEntry(() => log, 'Timed out waiting for pinned script log entry') assert.equal(log.text, 'Hello!') }) @@ -138,6 +150,8 @@ suite( }) await driver.get(Pages.logEntryAdded) + await waitForLogText(logs, 'Hello') + await waitForLogText(logs, 'World') assert.ok(logs.includes('Hello'), `[${logs}] should contain "Hello"`) assert.ok(logs.includes('World'), `[${logs}] should contain "World"`) @@ -145,6 +159,7 @@ suite( logs.length = 0 await driver.get(Pages.logEntryAdded) + await waitForLogText(logs, 'World') assert.ok(logs.includes('World'), `[${logs}] should contain "World"`) assert.ok(!logs.includes('Hello'), `[${logs}] should not contain "Hello"`) }) From 05a002e174452005271d46830f934ed9578a39d1 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Tue, 4 Aug 2026 22:13:51 -0500 Subject: [PATCH 39/56] [dotnet] fix flaky cancellation test to accept OperationCanceledException subclasses --- dotnet/test/webdriver/BiDi/Session/SessionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/test/webdriver/BiDi/Session/SessionTests.cs b/dotnet/test/webdriver/BiDi/Session/SessionTests.cs index d7dacd3ef16c3..c27f3421f7328 100644 --- a/dotnet/test/webdriver/BiDi/Session/SessionTests.cs +++ b/dotnet/test/webdriver/BiDi/Session/SessionTests.cs @@ -137,10 +137,10 @@ public async Task EventStreamCancellationTokenFiresDuringEnumeration() await using var sub = await bidi.Log.EntryAdded.StreamAsync(); - Assert.ThrowsAsync(async () => + Assert.That(async () => { await foreach (var _ in sub.ReadAllAsync(cts.Token)) { } - }); + }, Throws.InstanceOf()); } [Test] From 960abd2f061f698f3d134303a6f4b82f04733332 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 5 Aug 2026 07:04:10 -0500 Subject: [PATCH 40/56] [dotnet][java][js][rb] deprecate invalid Firefox profile code (#17871) * [dotnet][java][js][rb] deprecate dead Firefox profile members and add reuse specs --- dotnet/AGENTS.md | 8 +++++++ .../src/webdriver/Firefox/FirefoxExtension.cs | 1 + .../src/webdriver/Firefox/FirefoxProfile.cs | 7 ++++++ .../selenium/firefox/ClasspathExtension.java | 4 ++++ .../openqa/selenium/firefox/Extension.java | 4 ++++ .../selenium/firefox/FileExtension.java | 4 ++++ .../selenium/firefox/FirefoxProfile.java | 22 +++++++++++++++++++ javascript/selenium-webdriver/firefox.js | 1 + rb/lib/selenium/webdriver/firefox/profile.rb | 18 ++++++++++----- .../webdriver/firefox/profile_spec.rb | 6 +++++ .../webdriver/firefox/profile_spec.rb | 22 ------------------- 11 files changed, 70 insertions(+), 27 deletions(-) diff --git a/dotnet/AGENTS.md b/dotnet/AGENTS.md index 81c7b1766a874..2c48e93d2f7db 100644 --- a/dotnet/AGENTS.md +++ b/dotnet/AGENTS.md @@ -28,6 +28,14 @@ _logger.Debug("diagnostic: request details for debugging"); [Obsolete("Use NewMethod instead")] public void OldMethod() { } ``` +When code inside the assembly must still reference an obsolete member (e.g. a field +or method the obsolete API is built on), wrap just that usage to keep the build +warning-clean (see `UserPromptHandler.cs`): +```csharp +#pragma warning disable CS0618 // Type or member is obsolete +this.legacyThing.DoWork(); +#pragma warning restore CS0618 // Type or member is obsolete +``` ### Async patterns The codebase is migrating to async diff --git a/dotnet/src/webdriver/Firefox/FirefoxExtension.cs b/dotnet/src/webdriver/Firefox/FirefoxExtension.cs index be47e57e8a8f5..ce75367008141 100644 --- a/dotnet/src/webdriver/Firefox/FirefoxExtension.cs +++ b/dotnet/src/webdriver/Firefox/FirefoxExtension.cs @@ -29,6 +29,7 @@ namespace OpenQA.Selenium.Firefox; /// /// Provides the ability to install extensions into a . /// +[Obsolete("Use FirefoxDriver.InstallAddOnFromFile instead.")] public class FirefoxExtension { private const string EmNamespaceUri = "http://www.mozilla.org/2004/em-rdf#"; diff --git a/dotnet/src/webdriver/Firefox/FirefoxProfile.cs b/dotnet/src/webdriver/Firefox/FirefoxProfile.cs index 17619687051e2..ce0547d767450 100644 --- a/dotnet/src/webdriver/Firefox/FirefoxProfile.cs +++ b/dotnet/src/webdriver/Firefox/FirefoxProfile.cs @@ -33,7 +33,9 @@ public class FirefoxProfile private readonly string? sourceProfileDir; private readonly bool deleteSource; private readonly Preferences profilePreferences; +#pragma warning disable CS0618 // Type or member is obsolete private readonly Dictionary extensions = new Dictionary(); +#pragma warning restore CS0618 // Type or member is obsolete /// /// Initializes a new instance of the class. @@ -103,11 +105,14 @@ public static FirefoxProfile FromBase64String(string base64) /// /// The path to the new extension /// If is . + [Obsolete("Use FirefoxDriver.InstallAddOnFromFile instead.")] public void AddExtension(string extensionToInstall) { ArgumentNullException.ThrowIfNull(extensionToInstall); +#pragma warning disable CS0618 // Type or member is obsolete this.extensions.Add(Path.GetFileNameWithoutExtension(extensionToInstall), new FirefoxExtension(extensionToInstall)); +#pragma warning restore CS0618 // Type or member is obsolete } /// @@ -236,10 +241,12 @@ private void DeleteLockFiles(string profileDirectory) /// private void InstallExtensions(string profileDirectory) { +#pragma warning disable CS0618 // Type or member is obsolete foreach (string extensionKey in this.extensions.Keys) { this.extensions[extensionKey].Install(profileDirectory); } +#pragma warning restore CS0618 // Type or member is obsolete } /// diff --git a/java/src/org/openqa/selenium/firefox/ClasspathExtension.java b/java/src/org/openqa/selenium/firefox/ClasspathExtension.java index ce2dc1283dd5a..0a5d68e4cc4af 100644 --- a/java/src/org/openqa/selenium/firefox/ClasspathExtension.java +++ b/java/src/org/openqa/selenium/firefox/ClasspathExtension.java @@ -25,6 +25,10 @@ import org.openqa.selenium.WebDriverException; import org.openqa.selenium.io.FileHandler; +/** + * @deprecated Use {@link HasExtensions#installExtension} instead. + */ +@Deprecated(forRemoval = true) public class ClasspathExtension implements Extension { private final Class loadResourcesUsing; private final String loadFrom; diff --git a/java/src/org/openqa/selenium/firefox/Extension.java b/java/src/org/openqa/selenium/firefox/Extension.java index 629adb4762724..86a8d417e9a5b 100644 --- a/java/src/org/openqa/selenium/firefox/Extension.java +++ b/java/src/org/openqa/selenium/firefox/Extension.java @@ -20,6 +20,10 @@ import java.io.File; import java.io.IOException; +/** + * @deprecated Use {@link HasExtensions#installExtension} instead. + */ +@Deprecated(forRemoval = true) public interface Extension { void writeTo(File parentDirectory) throws IOException; } diff --git a/java/src/org/openqa/selenium/firefox/FileExtension.java b/java/src/org/openqa/selenium/firefox/FileExtension.java index db9e755f00527..57746c152750a 100644 --- a/java/src/org/openqa/selenium/firefox/FileExtension.java +++ b/java/src/org/openqa/selenium/firefox/FileExtension.java @@ -47,6 +47,10 @@ import org.w3c.dom.Document; import org.w3c.dom.Node; +/** + * @deprecated Use {@link HasExtensions#installExtension} instead. + */ +@Deprecated(forRemoval = true) public class FileExtension implements Extension { private static final String EM_NAMESPACE_URI = "http://www.mozilla.org/2004/em-rdf#"; diff --git a/java/src/org/openqa/selenium/firefox/FirefoxProfile.java b/java/src/org/openqa/selenium/firefox/FirefoxProfile.java index 023bf8571cf40..7fbfe921b6c21 100644 --- a/java/src/org/openqa/selenium/firefox/FirefoxProfile.java +++ b/java/src/org/openqa/selenium/firefox/FirefoxProfile.java @@ -38,7 +38,10 @@ public class FirefoxProfile { private static final String ACCEPT_UNTRUSTED_CERTS_PREF = "webdriver_accept_untrusted_certs"; private static final String ASSUME_UNTRUSTED_ISSUER_PREF = "webdriver_assume_untrusted_issuer"; private final Preferences additionalPrefs; + + @SuppressWarnings("deprecation") private final Map extensions = new HashMap<>(); + private @Nullable final File model; private boolean loadNoFocusLib; private boolean acceptUntrustedCerts; @@ -145,6 +148,10 @@ public boolean containsWebDriverExtension() { return extensions.containsKey("webdriver"); } + /** + * @deprecated Use {@link HasExtensions#installExtension} instead. + */ + @Deprecated(forRemoval = true) public void addExtension(Class loadResourcesUsing, String loadFrom) { // Is loadFrom a file? File file = new File(loadFrom); @@ -160,11 +167,17 @@ public void addExtension(Class loadResourcesUsing, String loadFrom) { * Attempt to add an extension to install into this instance. * * @param extensionToInstall File pointing to the extension + * @deprecated Use {@link HasExtensions#installExtension} instead. */ + @Deprecated(forRemoval = true) public void addExtension(File extensionToInstall) { addExtension(extensionToInstall.getName(), new FileExtension(extensionToInstall)); } + /** + * @deprecated Use {@link HasExtensions#installExtension} instead. + */ + @Deprecated(forRemoval = true) public void addExtension(String key, Extension extension) { String name = deriveExtensionName(key); extensions.put(name, extension); @@ -248,7 +261,9 @@ public void deleteExtensionsCacheIfItExists(File profileDir) { * even if native events are disabled. * * @return Whether the no focus library should always be loaded for Firefox on Linux. + * @deprecated Native events are no longer supported. */ + @Deprecated(forRemoval = true) public boolean shouldLoadNoFocusLib() { return loadNoFocusLib; } @@ -257,7 +272,9 @@ public boolean shouldLoadNoFocusLib() { * Sets whether the no focus library should always be loaded on Linux. * * @param loadNoFocusLib Whether to always load the no focus library. + * @deprecated Native events are no longer supported. */ + @Deprecated(forRemoval = true) public FirefoxProfile setAlwaysLoadNoFocusLib(boolean loadNoFocusLib) { this.loadNoFocusLib = loadNoFocusLib; return this; @@ -268,7 +285,9 @@ public FirefoxProfile setAlwaysLoadNoFocusLib(boolean loadNoFocusLib) { * authority or are generally untrusted. This is set to true by default. * * @param acceptUntrustedSsl Whether untrusted SSL certificates should be accepted. + * @deprecated Use {@link FirefoxOptions#setAcceptInsecureCerts(boolean)} instead. */ + @Deprecated(forRemoval = true) public FirefoxProfile setAcceptUntrustedCertificates(boolean acceptUntrustedSsl) { this.acceptUntrustedCerts = acceptUntrustedSsl; return this; @@ -287,7 +306,9 @@ public FirefoxProfile setAcceptUntrustedCertificates(boolean acceptUntrustedSsl) * production certificate served in a testing environment) set this to false. * * @param untrustedIssuer whether to assume untrusted issuer or not. + * @deprecated Use {@link FirefoxOptions#setAcceptInsecureCerts(boolean)} instead. */ + @Deprecated(forRemoval = true) public FirefoxProfile setAssumeUntrustedCertificateIssuer(boolean untrustedIssuer) { this.untrustedCertIssuer = untrustedIssuer; return this; @@ -347,6 +368,7 @@ protected void copyModel(@Nullable File sourceDir, File profileDir) throws IOExc FileHandler.copy(sourceDir, profileDir); } + @SuppressWarnings("deprecation") protected void installExtensions(File parentDir) throws IOException { File extensionsDir = new File(parentDir, "extensions"); diff --git a/javascript/selenium-webdriver/firefox.js b/javascript/selenium-webdriver/firefox.js index d97500398fb99..e18d6712f9880 100644 --- a/javascript/selenium-webdriver/firefox.js +++ b/javascript/selenium-webdriver/firefox.js @@ -317,6 +317,7 @@ class Options extends Capabilities { * * @param {...string} paths The paths to the extension XPI files to install. * @return {!Options} A self reference. + * @deprecated Use {@link Driver#installAddon} instead. */ addExtensions(...paths) { this.profile_().addExtensions(paths) diff --git a/rb/lib/selenium/webdriver/firefox/profile.rb b/rb/lib/selenium/webdriver/firefox/profile.rb index 55d7d7bc1afb6..5166d211a7b0d 100644 --- a/rb/lib/selenium/webdriver/firefox/profile.rb +++ b/rb/lib/selenium/webdriver/firefox/profile.rb @@ -40,7 +40,6 @@ class Profile LOCK_FILES = %w[.parentlock parent.lock lock].freeze attr_reader :name, :log_file - attr_writer :secure_ssl, :load_no_focus_lib class << self def ini @@ -110,19 +109,28 @@ def []=(key, value) end def port=(port) + WebDriver.logger.deprecate('Firefox::Profile#port=', 'the Service class', id: :firefox_profile) self[WEBDRIVER_PREFS[:port]] = port end + def secure_ssl=(value) + WebDriver.logger.deprecate('Firefox::Profile#secure_ssl=', id: :firefox_profile) + @secure_ssl = value + end + + def load_no_focus_lib=(value) + WebDriver.logger.deprecate('Firefox::Profile#load_no_focus_lib=', id: :firefox_profile) + @load_no_focus_lib = value + end + def log_file=(file) @log_file = file self[WEBDRIVER_PREFS[:log_file]] = file end - # - # Add the extension (directory, .zip or .xpi) at the given path to the profile. - # - def add_extension(path, name = extension_name_for(path)) + WebDriver.logger.deprecate('Firefox::Profile#add_extension', 'Driver#install_addon', + id: :firefox_profile) @extensions[name] = Extension.new(path) end diff --git a/rb/spec/integration/selenium/webdriver/firefox/profile_spec.rb b/rb/spec/integration/selenium/webdriver/firefox/profile_spec.rb index ae54cd5167234..abf6586a8a20d 100644 --- a/rb/spec/integration/selenium/webdriver/firefox/profile_spec.rb +++ b/rb/spec/integration/selenium/webdriver/firefox/profile_spec.rb @@ -45,6 +45,12 @@ module Firefox end end end + + it 'ships preferences from an existing profile directory' do + reset_driver!(profile: described_class.new(profile.layout_on_disk)) do |driver| + expect { wait(5).until { driver.find_element(id: 'oneline') } }.not_to raise_error + end + end end end # Firefox end # WebDriver diff --git a/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb b/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb index 7a460764f227e..154133fc01861 100644 --- a/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb +++ b/rb/spec/unit/selenium/webdriver/firefox/profile_spec.rb @@ -122,28 +122,6 @@ def read_generated_prefs(from = nil) expect(read_generated_prefs).to include('user_pref("network.proxy.type", 4)') end - - it 'can install extension' do - firebug = File.expand_path('../../../../../../third_party/firebug/firebug-1.5.0-fx.xpi', __dir__) - profile.add_extension(firebug) - extension_directory = File.expand_path('extensions/firebug@software.joehewitt.com', profile.layout_on_disk) - expect(Dir.exist?(extension_directory)).to be(true) - end - - it 'can install web extension without id' do - mooltipass = File.expand_path('../../../../../../third_party/firebug/mooltipass-1.1.87.xpi', __dir__) - profile.add_extension(mooltipass) - extension_directory = File.expand_path('extensions/MooltipassExtension@1.1.87', profile.layout_on_disk) - expect(Dir.exist?(extension_directory)).to be(true) - end - - it 'can install web extension with id' do - ext = File.expand_path('../../../../../../third_party/firebug/favourite_colour-1.1-an+fx.xpi', __dir__) - profile.add_extension(ext) - extension_directory = File.expand_path('extensions/favourite-colour-examples@mozilla.org', - profile.layout_on_disk) - expect(Dir.exist?(extension_directory)).to be(true) - end end end # Firefox end # WebDriver From a25afbba6fee6e49ccaa4981b22afcc15505f812 Mon Sep 17 00:00:00 2001 From: Selenium CI Bot Date: Wed, 5 Aug 2026 14:05:40 +0200 Subject: [PATCH 41/56] [build] Automated Browser Version Update with CDP (#17873) Update pinned browser versions Co-authored-by: Selenium CI Bot Co-authored-by: Titus Fortner --- .../chromium/{v148 => v151}/BUILD.bazel | 0 .../{v148 => v151}/browser_protocol.pdl | 351 +++++++++++++++--- .../chromium/{v148 => v151}/js_protocol.pdl | 0 common/repositories.bzl | 24 +- .../src/webdriver/DevTools/DevToolsDomains.cs | 4 +- .../V148Domains.cs => v151/V151Domains.cs} | 30 +- .../V151JavaScript.cs} | 16 +- .../{v148/V148Log.cs => v151/V151Log.cs} | 14 +- .../V148Network.cs => v151/V151Network.cs} | 24 +- .../V148Target.cs => v151/V151Target.cs} | 14 +- .../DevTools/DevToolsConsoleTests.cs | 2 +- .../webdriver/DevTools/DevToolsLogTests.cs | 2 +- .../DevTools/DevToolsNetworkTests.cs | 2 +- .../DevTools/DevToolsPerformanceTests.cs | 2 +- .../DevTools/DevToolsProfilerTests.cs | 2 +- .../DevTools/DevToolsSecurityTests.cs | 2 +- .../webdriver/DevTools/DevToolsTabsTests.cs | 2 +- .../webdriver/DevTools/DevToolsTargetTests.cs | 4 +- dotnet/version.bzl | 2 +- .../devtools/{v148 => v151}/BUILD.bazel | 2 +- .../devtools/{v148 => v151}/package-info.java | 2 +- .../v151CdpInfo.java} | 8 +- .../v151Domains.java} | 26 +- .../v148Events.java => v151/v151Events.java} | 18 +- .../v151Javascript.java} | 14 +- .../{v148/v148Log.java => v151/v151Log.java} | 10 +- .../v151Network.java} | 30 +- .../v148Target.java => v151/v151Target.java} | 24 +- .../org/openqa/selenium/devtools/versions.bzl | 2 +- javascript/selenium-webdriver/BUILD.bazel | 2 +- py/BUILD.bazel | 2 +- rake_tasks/java.rake | 2 +- rb/Gemfile.lock | 2 +- rb/lib/selenium/devtools/BUILD.bazel | 2 +- rb/lib/selenium/devtools/version.rb | 2 +- 35 files changed, 445 insertions(+), 200 deletions(-) rename common/devtools/chromium/{v148 => v151}/BUILD.bazel (100%) rename common/devtools/chromium/{v148 => v151}/browser_protocol.pdl (97%) rename common/devtools/chromium/{v148 => v151}/js_protocol.pdl (100%) rename dotnet/src/webdriver/DevTools/{v148/V148Domains.cs => v151/V151Domains.cs} (74%) rename dotnet/src/webdriver/DevTools/{v148/V148JavaScript.cs => v151/V151JavaScript.cs} (94%) rename dotnet/src/webdriver/DevTools/{v148/V148Log.cs => v151/V151Log.cs} (88%) rename dotnet/src/webdriver/DevTools/{v148/V148Network.cs => v151/V151Network.cs} (95%) rename dotnet/src/webdriver/DevTools/{v148/V148Target.cs => v151/V151Target.cs} (94%) rename java/src/org/openqa/selenium/devtools/{v148 => v151}/BUILD.bazel (98%) rename java/src/org/openqa/selenium/devtools/{v148 => v151}/package-info.java (95%) rename java/src/org/openqa/selenium/devtools/{v148/v148CdpInfo.java => v151/v151CdpInfo.java} (86%) rename java/src/org/openqa/selenium/devtools/{v148/v148Domains.java => v151/v151Domains.java} (77%) rename java/src/org/openqa/selenium/devtools/{v148/v148Events.java => v151/v151Events.java} (86%) rename java/src/org/openqa/selenium/devtools/{v148/v148Javascript.java => v151/v151Javascript.java} (85%) rename java/src/org/openqa/selenium/devtools/{v148/v148Log.java => v151/v151Log.java} (89%) rename java/src/org/openqa/selenium/devtools/{v148/v148Network.java => v151/v151Network.java} (88%) rename java/src/org/openqa/selenium/devtools/{v148/v148Target.java => v151/v151Target.java} (83%) diff --git a/common/devtools/chromium/v148/BUILD.bazel b/common/devtools/chromium/v151/BUILD.bazel similarity index 100% rename from common/devtools/chromium/v148/BUILD.bazel rename to common/devtools/chromium/v151/BUILD.bazel diff --git a/common/devtools/chromium/v148/browser_protocol.pdl b/common/devtools/chromium/v151/browser_protocol.pdl similarity index 97% rename from common/devtools/chromium/v148/browser_protocol.pdl rename to common/devtools/chromium/v151/browser_protocol.pdl index 97a293ccc55c8..a3c85dd998a1c 100644 --- a/common/devtools/chromium/v148/browser_protocol.pdl +++ b/common/devtools/chromium/v151/browser_protocol.pdl @@ -313,6 +313,57 @@ experimental domain Accessibility # Updated node data. array of AXNode nodes +# Copyright 2026 The Chromium Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# A domain for ad-related metrics and data. +experimental domain Ads + depends on Network + depends on Page + depends on Runtime + + # Ad frame data. + type AdFrameData extends object + properties + # The DevTools frame token. + Page.FrameId frameId + # The initial origin of the frame. To minimize the payload size, this is + # only sent once per frame. + optional string initialOrigin + # The network bytes of the frame. + number networkBytes + # The CPU time of the frame, in milliseconds. + number cpuTime + + # Ad metrics for a page. + type AdMetrics extends object + properties + # The viewport ad density by area, represented as a percentage (an integer + # between 0 and 100). + integer viewportAdDensityByArea + # The time-weighted average of the viewport ad density by area, measured + # across the duration of the page. + number averageViewportAdDensityByArea + # The number of ads currently visible within the viewport. + integer viewportAdCount + # The time-weighted average of the viewport ad count, measured across the + # duration of the page. + number averageViewportAdCount + # The total ad CPU usage, in milliseconds. + number totalAdCpuTime + # The total ad network bytes. + number totalAdNetworkBytes + # The list of ad frames that have been updated since the last event. + array of AdFrameData updateAdFrames + # The list of ad frame IDs that have been removed since the last event. + array of Page.FrameId removeAdFrames + + # Retrieves ad metrics for the current page. + command getAdMetrics + returns + AdMetrics metrics + # Copyright 2017 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. @@ -827,6 +878,7 @@ experimental domain Audits WriteErrorNonSecureContext WriteErrorNonStringIdField WriteErrorNonStringInMatchDestList + WriteErrorInvalidMatchDestList WriteErrorNonStringMatchField WriteErrorNonTokenTypeField WriteErrorRequestAborted @@ -857,6 +909,10 @@ experimental domain Audits ValidationFailedInvalidLength ValidationFailedSignatureMismatch ValidationFailedIntegrityMismatch + SignatureBaseUnknownDerivedComponent + SignatureBaseMissingHeader + SignatureBaseInvalidUnencodedDigest + SignatureBaseUnsupportedComponent type UnencodedDigestError extends string enum @@ -936,10 +992,15 @@ experimental domain Audits FormInputHasWrongButWellIntendedAutocompleteValueError ResponseWasBlockedByORB NavigationEntryMarkedSkippable + BackUINavigationWouldSkipAd AutofillAndManualTextPolicyControlledFeaturesInfo AutofillPolicyControlledFeatureInfo ManualTextPolicyControlledFeatureInfo FormModelContextParameterMissingTitleAndDescription + FormModelContextMissingToolName + FormModelContextMissingToolDescription + FormModelContextRequiredParameterMissingName + FormModelContextParameterMissingName # Depending on the concrete errorType, different properties are set. type GenericIssueDetails extends object @@ -1062,6 +1123,73 @@ experimental domain Audits InvalidAccountsResponse NoReturningUserFromFetchedAccounts + type EmailVerificationRequestIssueDetails extends object + properties + EmailVerificationRequestIssueReason emailVerificationRequestIssueReason + + # Represents the failure reason when an email verification request fails. + # Should be updated alongside EmailVerificationRequestResult in + # third_party/blink/public/mojom/devtools/inspector_issue.mojom. + type EmailVerificationRequestIssueReason extends string + enum + InvalidEmail + DnsFetchFailed + DnsInvalidRecord + WellKnownHttpNotFound + WellKnownNoResponse + WellKnownInvalidResponse + WellKnownListEmpty + WellKnownInvalidContentType + WellKnownMissingIssuanceEndpoint + WellKnownIssuanceEndpointCrossOrigin + WellKnownUnsupportedSigningAlgorithm + TokenHttpNotFound + TokenNoResponse + TokenInvalidResponse + TokenInvalidContentType + TokenMalformedSdJwt + TokenInvalidSdJwt + KeyBindingSigningFailed + RpOriginIsOpaque + WellKnownMissingAccountsEndpoint + UserLoggedOut + WellKnownAccountsEndpointCrossOrigin + AccountsHttpNotFound + AccountsNoResponse + AccountsInvalidResponse + AccountsInvalidContentType + AccountsEmptyList + EmailVerificationWellKnownHttpNotFound + EmailVerificationWellKnownNoResponse + EmailVerificationWellKnownInvalidResponse + EmailVerificationWellKnownInvalidContentType + JwksHttpNotFound + JwksInvalidResponse + TokenVerificationSdJwtUnsupportedHeaderAlg + TokenVerificationSdJwtInvalidTyp + TokenVerificationSdJwtMissingIss + TokenVerificationSdJwtMissingIat + TokenVerificationSdJwtMissingCnf + TokenVerificationSdJwtMissingEmail + TokenVerificationSdJwtInvalidIssuedAt + TokenVerificationSdJwtInvalidIssuer + TokenVerificationSdJwtJwksMissingKeys + TokenVerificationSdJwtSignatureFailed + TokenVerificationSdJwtInvalidEmailVerified + TokenVerificationSdJwtInvalidEmail + TokenVerificationSdJwtInvalidHolderKey + TokenVerificationKbInvalidTyp + TokenVerificationKbMissingAud + TokenVerificationKbMissingNonce + TokenVerificationKbMissingIat + TokenVerificationKbMissingSdHash + TokenVerificationKbInvalidIssuedAt + TokenVerificationKbInvalidAudience + TokenVerificationKbInvalidNonce + TokenVerificationKbInvalidSdHash + TokenVerificationKbMissingCnf + TokenVerificationKbSignatureFailed + # This issue tracks client hints related issues. It's used to deprecate old # features, encourage the use of new ones, and provide general guidance. type ClientHintIssueDetails extends object @@ -1177,6 +1305,8 @@ experimental domain Audits FontSizeTooSmall FontSizeTooLarge InvalidSizeValue + NonSecureContext + MissingTransientUserActivation # This issue warns about improper usage of the element. type PermissionElementIssueDetails extends object @@ -1245,6 +1375,7 @@ experimental domain Audits PermissionElementIssue PerformanceIssue SelectivePermissionsInterventionIssue + EmailVerificationRequestIssue # This struct holds a list of optional fields with additional information # specific to the kind of issue. When adding a new issue code, please also @@ -1280,6 +1411,7 @@ experimental domain Audits optional PermissionElementIssueDetails permissionElementIssueDetails optional PerformanceIssueDetails performanceIssueDetails optional SelectivePermissionsInterventionIssueDetails selectivePermissionsInterventionIssueDetails + optional EmailVerificationRequestIssueDetails emailVerificationRequestIssueDetails # A unique id for a DevTools inspector issue. Allows other entities (e.g. # exceptions, CDP message, console messages, etc.) to reference an issue. @@ -2188,6 +2320,18 @@ experimental domain CSS # Specificity of the selector. experimental optional Specificity specificity + # Contribution of an individual simple selector to specificity. + experimental type SpecificityComponent extends object + properties + # The simple selector text that contributes to specificity. + string text + # The a component contribution. + integer a + # The b component contribution. + integer b + # The c component contribution. + integer c + # Specificity: # https://drafts.csswg.org/selectors/#specificity-rules experimental type Specificity extends object @@ -2199,6 +2343,8 @@ experimental domain CSS integer b # The c component, which represents the number of type selectors and pseudo-elements. integer c + # Per-simple-selector contributions used to explain this specificity. + experimental optional array of SpecificityComponent components # Selector list data. type SelectorList extends object @@ -2444,7 +2590,10 @@ experimental domain CSS experimental type CSSContainerQuery extends object properties # Container query text. - string text + # Contains the query part without the container name for a single query. + # Deprecated in favor of conditionText which contains the full prelude + # after @container. + deprecated string text # The associated rule header range in the enclosing stylesheet (if # available). optional SourceRange range @@ -2460,6 +2609,8 @@ experimental domain CSS optional boolean queriesScrollState # true if the query contains anchored() queries. optional boolean queriesAnchored + # CSSContainerRule.conditionText + string conditionText # CSS Supports at-rule descriptor. experimental type CSSSupports extends object @@ -2629,6 +2780,7 @@ experimental domain CSS font-face font-feature-values font-palette-values + counter-style # Subsection of font-feature-values, if this is a subsection. optional enum subsection # LINT.IfChange(FontVariantAlternatesFeatureType) @@ -3032,7 +3184,17 @@ experimental domain CSS CSSMedia media # Modifies the expression of a container query. - experimental command setContainerQueryText + # Deprecated. Use setContainerQueryConditionText instead. + experimental deprecated command setContainerQueryText + parameters + DOM.StyleSheetId styleSheetId + SourceRange range + string text + returns + # The resulting CSS container query rule after modification. + CSSContainerQuery containerQuery + + experimental command setContainerQueryConditionText parameters DOM.StyleSheetId styleSheetId SourceRange range @@ -3414,7 +3576,7 @@ domain DOM after expand-icon picker-icon - interest-hint + interest-button marker backdrop column @@ -3446,8 +3608,11 @@ domain DOM file-selector-button details-content picker + select-listbox permission-icon overscroll-area-parent + overscroll-backdrop + skeleton # Shadow root type. type ShadowRootType extends string @@ -4185,6 +4350,11 @@ domain DOM # If true, opens the popover and keeps it open. If false, closes the # popover if it was previously force-opened. boolean enable + # Optional ID of the element invoking this popover, used to establish the implicit anchor. + # If not provided, it will fall back to the first invoker in the document, preferring + # elements with a popovertarget attribute over those with a commandfor attribute. Note that + # if there are multiple invokers, this is just an estimate. + optional BackendNodeId invokerNodeId returns # List of popovers that were closed in order to respect popover stacking order. array of NodeId nodeIds @@ -5359,7 +5529,6 @@ domain Emulation PressureSource source optional PressureMetadata metadata - # TODO: OBSOLETE: To remove when setPressureDataOverride is merged. # Provides a given pressure state that will be processed and eventually be # delivered to PressureObserver users. |source| must have been previously # overridden by setPressureSourceOverrideEnabled. @@ -5368,15 +5537,6 @@ domain Emulation PressureSource source PressureState state - # Provides a given pressure data set that will be processed and eventually be - # delivered to PressureObserver users. |source| must have been previously - # overridden by setPressureSourceOverrideEnabled. - experimental command setPressureDataOverride - parameters - PressureSource source - PressureState state - optional number ownContributionEstimate - # Overrides the Idle state. command setIdleOverride parameters @@ -5635,8 +5795,6 @@ experimental domain Extensions managed # Runs an extension default action. - # Available if the client is connected using the --remote-debugging-pipe - # flag and the --enable-unsafe-extension-debugging flag is set. command triggerAction parameters # Extension id. @@ -5646,9 +5804,7 @@ experimental domain Extensions # Installs an unpacked extension from the filesystem similar to # --load-extension CLI flags. Returns extension ID once the extension - # has been installed. Available if the client is connected using the - # --remote-debugging-pipe flag and the --enable-unsafe-extension-debugging - # flag is set. + # has been installed. command loadUnpacked parameters # Absolute file path. @@ -5674,15 +5830,11 @@ experimental domain Extensions boolean enabled # Gets a list of all unpacked extensions. - # Available if the client is connected using the --remote-debugging-pipe flag - # and the --enable-unsafe-extension-debugging flag is set. command getExtensions returns array of ExtensionInfo extensions # Uninstalls an unpacked extension (others not supported) from the profile. - # Available if the client is connected using the --remote-debugging-pipe flag - # and the --enable-unsafe-extension-debugging. command uninstall parameters # Extension id. @@ -8055,14 +8207,6 @@ domain Network None # The cookie should have been blocked by 3PCD but is exempted by explicit user setting. UserSetting - # The cookie should have been blocked by 3PCD but is exempted by metadata mitigation. - TPCDMetadata - # The cookie should have been blocked by 3PCD but is exempted by Deprecation Trial mitigation. - TPCDDeprecationTrial - # The cookie should have been blocked by 3PCD but is exempted by Top-level Deprecation Trial mitigation. - TopLevelTPCDDeprecationTrial - # The cookie should have been blocked by 3PCD but is exempted by heuristics mitigation. - TPCDHeuristics # The cookie should have been blocked by 3PCD but is exempted by Enterprise Policy. EnterprisePolicy # The cookie should have been blocked by 3PCD but is exempted by Storage Access API. @@ -8374,6 +8518,8 @@ domain Network optional integer packetQueueLength # WebRTC packetReordering feature. optional boolean packetReordering + # True to emulate internet disconnection. + optional boolean offline # Activates emulation of network conditions. This command is deprecated in favor of the emulateNetworkConditionsByRule # and overrideNetworkState commands, which can be used together to the same effect. @@ -8401,8 +8547,11 @@ domain Network # explicitly modify `navigator` behavior. experimental command emulateNetworkConditionsByRule parameters - # True to emulate internet disconnection. - boolean offline + # True to emulate internet disconnection. Deprecated, use the offline property in matchedNetworkConditions + # or emulateOfflineServiceWorker instead. + deprecated optional boolean offline + # True to emulate offline service worker. + optional boolean emulateOfflineServiceWorker # Configure conditions for matching requests. If multiple entries match a request, the first entry wins. Global # conditions can be configured by leaving the urlPattern for the conditions empty. These global conditions are # also applied for throttling of p2p connections. @@ -9474,6 +9623,7 @@ domain Network Success KeyError SigningError + TransientSigningError ServerRequestedTermination InvalidSessionId InvalidChallenge @@ -9539,6 +9689,7 @@ domain Network InvalidFederatedSessionProviderFailedToRestoreKey FailedToUnwrapKey SessionDeletedDuringRefresh + CrossOriginRegistrationSiteNotIncluded # Details about a failed device bound session network request. experimental type DeviceBoundSessionFailedRequest extends object @@ -9578,6 +9729,8 @@ domain Network RefreshQuotaExceeded FatalError SigningQuotaExceeded + RefreshedAsWaiter + TransientSigningError # If there was a fetch attempt, the result of that. optional DeviceBoundSessionFetchResult fetchResult # The session display if there was a newly created session. This is populated @@ -9602,6 +9755,7 @@ domain Network ServerRequested InvalidSessionParams RefreshFatalError + DevTools # Session event details specific to challenges. experimental type ChallengeEventDetails extends object @@ -9646,6 +9800,11 @@ domain Network # Whether to enable or disable events. boolean enable + # Deletes a device bound session. + experimental command deleteDeviceBoundSession + parameters + DeviceBoundSessionKey key + # Fetches the schemeful site for a specific origin. experimental command fetchSchemefulSite parameters @@ -9695,12 +9854,6 @@ domain Network # Whether 3pc restriction is enabled. boolean enableThirdPartyCookieRestriction - # Whether 3pc grace period exception should be enabled; false by default. - boolean disableThirdPartyCookieMetadata - - # Whether 3pc heuristics exceptions should be enabled; false by default. - boolean disableThirdPartyCookieHeuristics - # Copyright 2017 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. @@ -9911,6 +10064,36 @@ experimental domain Overlay # The content box highlight outline color (default: transparent). optional DOM.RGBA outlineColor + # Supported display cutout shapes. + type DisplayCutoutShape extends string + enum + pill + notch + circle + rectangle + + # Configuration for a display cutout. + type DisplayCutoutConfig extends object + properties + # A rectangle representing the cutout bounds. + DOM.Rect rect + # Shape used to draw the cutout. + DisplayCutoutShape shape + # Border radius for rounded cutout shapes. + optional integer borderRadius + # Upper shoulder radius for notch cutout shapes. + optional integer upperRadius + # Lower transition radius for notch cutout shapes. + optional integer lowerRadius + # Center x coordinate for circle cutout shapes. + optional integer cx + # Center y coordinate for circle cutout shapes. + optional integer cy + # Radius for circle cutout shapes. + optional integer radius + # The cutout fill color (default: black). + optional DOM.RGBA contentColor + # Configuration for Window Controls Overlay type WindowControlsOverlayConfig extends object properties @@ -10179,6 +10362,12 @@ experimental domain Overlay # hinge data, null means hideHinge optional HingeConfig hingeConfig + # Add a display cutout overlay. + command setShowDisplayCutout + parameters + # display cutout data, null means hide display cutout + optional DisplayCutoutConfig displayCutoutConfig + # Show elements in isolation mode with overlays. command setShowIsolatedElements parameters @@ -10487,7 +10676,6 @@ domain Page digital-credentials-get direct-sockets direct-sockets-multicast - direct-sockets-private display-capture document-domain encrypted-media @@ -10538,12 +10726,14 @@ domain Page sub-apps summarizer sync-xhr + tools translator unload usb usb-unrestricted vertical-scroll web-app-installation + webnn web-printing web-share window-management @@ -11009,6 +11199,13 @@ domain Page # Whether or not universal access should be granted to the isolated world. This is a powerful # option, use with caution. optional boolean grantUniveralAccess + # An optional content security policy to set for the isolated world. + # If omitted, any existing CSP for the world will be cleared. + # Note that clearing or updating the CSP does not immediately affect the active + # context in the same document because LocalDOMWindow caches the + # ContentSecurityPolicy object. The change takes effect on subsequent + # navigations when a new window context is created. + optional string contentSecurityPolicy returns # Execution context of the isolated world. Runtime.ExecutionContextId executionContextId @@ -11988,6 +12185,7 @@ domain Page EmbedderExtensionMessaging EmbedderExtensionMessagingForOpenPort EmbedderExtensionSentMessageToCachedFrame + EmbedderExtensionFrame RequestedByWebViewClient PostMessageByWebViewClient CacheControlNoStoreDeviceBoundSessionTerminated @@ -12449,6 +12647,7 @@ experimental domain Preload BrowsingDataRemoved PrerenderHostReused FormSubmitWhenPrerendering + CrossDocumentRestart # Fired when a preload enabled state is updated. event preloadEnabledStateUpdated @@ -12491,6 +12690,7 @@ experimental domain Preload PrefetchIneligibleRetryAfter PrefetchIsPrivacyDecoy PrefetchIsStale + PrefetchNotEligibleBlockedByConnectionAllowlist PrefetchNotEligibleBrowserContextOffTheRecord PrefetchNotEligibleDataSaverEnabled PrefetchNotEligibleExistingProxy @@ -12515,6 +12715,7 @@ experimental domain Preload # The prefetch finished successfully but was never used. PrefetchSuccessfulButNotUsed PrefetchNotUsedProbeFailed + PrefetchCancelledOnUserNavigation # Fired when a prefetch attempt is updated. event prefetchStatusUpdated @@ -12921,7 +13122,6 @@ experimental domain SmartCardEmulation shutdown # Maps to SCARD_E_UNKNOWN_CARD. - # TODO(crbug.com/472114998): Rename Mojo's kUnknownError to kUnknownCard to match. unknown-card # Error code that is not mapped in this enum. @@ -14062,18 +14262,24 @@ domain Target string url # Whether the target has an attached client. boolean attached + # Id of the parent target, if any. For example, "iframe" target may have a "page" parent. + optional TargetID parentId # Opener target Id optional TargetID openerId # Whether the target has access to the originating window. experimental boolean canAccessOpener # Frame id of originating window (is only set if target has an opener). experimental optional Page.FrameId openerFrameId - # Id of the parent frame, only present for the "iframe" targets. + # Id of the parent frame, present for "iframe" and "worker" targets. For nested workers, + # this is the "ancestor" frame that created the first worker in the nested chain. experimental optional Page.FrameId parentFrameId experimental optional Browser.BrowserContextID browserContextId # Provides additional details for specific target types. For example, for # the type of "page", this may be set to "prerender". experimental optional string subtype + # Embedder-specific target metadata. This is only set for targets of + # type "tab". + experimental optional object embedderData # A filter used by target query/discovery/auto-attach operations. experimental type FilterEntry extends object @@ -14208,13 +14414,11 @@ domain Target # present in the tab UI strip. Cannot be created with `forTab: true`, `newWindow: true` or # `background: false`. The life-time of the tab is limited to the life-time of the session. experimental optional boolean hidden - # If specified, the option is used to determine if the new target should - # be focused or not. By default, the focus behavior depends on the - # value of the background field. For example, background=false and focus=false - # will result in the target tab being opened but the browser window remain - # unchanged (if it was in the background, it will remain in the background) - # and background=false with focus=undefined will result in the window being focused. - # Using background: true and focus: true is not supported and will result in an error. + # If specified, determines whether the new target should be focused. + # By default, the focus behavior depends on the `background` parameter: + # - If `background` is false (default) and `focus` is omitted, the new target is focused and the browser window is brought to the foreground. + # - If `background` is false and `focus` is false, the target is opened but the browser window's focus remains unchanged (e.g., if the window was in the background, it stays there). + # - If `background` is true, setting `focus` to true is not supported and will result in an error. experimental optional boolean focus returns # The id of the page opened. @@ -14384,8 +14588,8 @@ domain Target # This can be the page or tab target ID. TargetID targetId # The id of the panel we want DevTools to open initially. Currently - # supported panels are elements, console, network, sources, resources - # and performance. + # supported panels are elements, console, network, sources, resources, + # timeline, chrome-recorder, heap-profiler, lighthouse, and security. optional string panelId returns # The targetId of DevTools page target. @@ -14552,6 +14756,17 @@ domain Tracing experimental optional binary perfettoConfig # Backend type (defaults to `auto`) experimental optional TracingBackend tracingBackend + # Maximum width and height (in pixels) of each captured screenshot. + # Only used when the `disabled-by-default-devtools.screenshot` category is + # enabled. Defaults to 500. The combined memory footprint of screenshots + # (`screenshotMaxSize` * `screenshotMaxSize` * 4 * `screenshotMaxCount`) + # is clamped to the existing per-session budget. + experimental optional integer screenshotMaxSize + # Maximum number of screenshots captured during a single tracing session. + # Only used when the `disabled-by-default-devtools.screenshot` category is + # enabled. Defaults to 450. Clamped together with `screenshotMaxSize` to + # stay within the per-session screenshot memory budget. + experimental optional integer screenshotMaxCount experimental event bufferUsage parameters @@ -15047,13 +15262,15 @@ experimental domain WebMCP properties # A hint indicating that the tool does not modify any state. optional boolean readOnly + # A hint indicating that the tool output may contain untrusted content, ex: UGC, 3rd party data. + optional boolean untrustedContent # If the declarative tool was declared with the autosubmit attribute. optional boolean autosubmit # Represents the status of a tool invocation. type InvocationStatus extends string enum - Success + Completed Canceled Error @@ -15083,17 +15300,44 @@ experimental domain WebMCP # Disables the WebMCP domain. command disable + # Invokes a registered tool. + command invokeTool + parameters + # Frame in which to invoke the tool. + Page.FrameId frameId + # Name of the tool to invoke. + string toolName + # Input parameters for the tool, matching the tool's inputSchema. + object input + returns + # Unique identifier for this invocation. Response is sent before tool events. + string invocationId + + # Cancels a pending tool invocation. + command cancelInvocation + parameters + # Invocation identifier to cancel. + string invocationId + # Event fired when new tools are added. event toolsAdded parameters # Array of tools that were added. array of Tool tools + # Definition of a tool that was removed. + type RemovedTool extends object + properties + # Tool name. + string name + # Frame identifier associated with the tool registration. + Page.FrameId frameId + # Event fired when tools are removed. event toolsRemoved parameters # Array of tools that were removed. - array of Tool tools + array of RemovedTool tools # Event fired when a tool invocation starts. event toolInvoked @@ -15114,7 +15358,8 @@ experimental domain WebMCP string invocationId # Status of the invocation. InvocationStatus status - # Output or error delivered as delivered to the agent. Missing if `status` is anything other than Success. + # Output or error delivered as delivered to the agent. Missing if `status` is anything other than Completed. + # Note: The output is untrusted and poses a prompt injection risk. Clients should treat this as potentially malicious user input. optional any output # Error text for protocol users. optional string errorText diff --git a/common/devtools/chromium/v148/js_protocol.pdl b/common/devtools/chromium/v151/js_protocol.pdl similarity index 100% rename from common/devtools/chromium/v148/js_protocol.pdl rename to common/devtools/chromium/v151/js_protocol.pdl diff --git a/common/repositories.bzl b/common/repositories.bzl index 0a00d2a7b7945..af5e81000bd3c 100644 --- a/common/repositories.bzl +++ b/common/repositories.bzl @@ -12,8 +12,8 @@ def pin_browsers(): http_archive( name = "linux_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/153.0.1/linux-x86_64/en-US/firefox-153.0.1.tar.xz", - sha256 = "05fb58905a90ce717c36a2ba5af0bbdc4d0e8b0eed6f50469030774c8c85b8eb", + url = "https://ftp.mozilla.org/pub/firefox/releases/153.0.3/linux-x86_64/en-US/firefox-153.0.3.tar.xz", + sha256 = "22b312280900bfb174b685ece32c7b3c6d72e7f8e53d6d30f21ac41a8dc500a2", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -34,8 +34,8 @@ js_library( dmg_archive( name = "mac_firefox", - url = "https://ftp.mozilla.org/pub/firefox/releases/153.0.1/mac/en-US/Firefox%20153.0.1.dmg", - sha256 = "e5a7f8f34b16ac5d8d429a1438468f023ad7bf9099fa928db537f45e32159f78", + url = "https://ftp.mozilla.org/pub/firefox/releases/153.0.3/mac/en-US/Firefox%20153.0.3.dmg", + sha256 = "a0523b6f2f10f13c6071d8b53ed7678193d693febd8a5d4fd8d7417b3c661045", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -200,8 +200,8 @@ js_library( http_archive( name = "linux_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/linux64/chrome-linux64.zip", - sha256 = "6bd04aab53fba1544ce6027d9daddb24137295033124a61ecdf9840d785792e9", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.76/linux64/chrome-linux64.zip", + sha256 = "d9e4c5916f77e737f22056cab47cd8abb7d39ebec559500ffc2d6f3e02106a7e", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") package(default_visibility = ["//visibility:public"]) @@ -221,8 +221,8 @@ js_library( ) http_archive( name = "mac_chrome", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/mac-arm64/chrome-mac-arm64.zip", - sha256 = "1c516b5d6c00a074034d5ce03dc1cc9bd2cde2a09293d9613244e0bc153cb80f", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.76/mac-arm64/chrome-mac-arm64.zip", + sha256 = "4d727601939c51719d2b5ff7d2cd57c44fab6d2529f9f1d2e5769939b86590bf", strip_prefix = "chrome-mac-arm64", patch_cmds = [ "mv 'Google Chrome for Testing.app' Chrome.app", @@ -242,8 +242,8 @@ js_library( ) http_archive( name = "linux_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/linux64/chromedriver-linux64.zip", - sha256 = "89b11804aa50b90b4821b19311f4bf688ce8d394484b2eea08bbcffd5644c1d8", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.76/linux64/chromedriver-linux64.zip", + sha256 = "b80c87a1a41ec5209163f6fbe1e139d5aca7b632116707097fe9a61302e8e555", strip_prefix = "chromedriver-linux64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") @@ -260,8 +260,8 @@ js_library( http_archive( name = "mac_chromedriver", - url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.71/mac-arm64/chromedriver-mac-arm64.zip", - sha256 = "e2956eda0e610414ea280574ccab35e5dd88b5b7f510353232fe157e5a598b7b", + url = "https://storage.googleapis.com/chrome-for-testing-public/151.0.7922.76/mac-arm64/chromedriver-mac-arm64.zip", + sha256 = "7f0c7c77ee4a36b3d7a62b7372a44ad3cc287e4664a31822eab447fa10cdb62d", strip_prefix = "chromedriver-mac-arm64", build_file_content = """ load("@aspect_rules_js//js:defs.bzl", "js_library") diff --git a/dotnet/src/webdriver/DevTools/DevToolsDomains.cs b/dotnet/src/webdriver/DevTools/DevToolsDomains.cs index 026820bd870c5..9710b561b352f 100644 --- a/dotnet/src/webdriver/DevTools/DevToolsDomains.cs +++ b/dotnet/src/webdriver/DevTools/DevToolsDomains.cs @@ -33,14 +33,14 @@ public abstract class DevToolsDomains // added to this array and to the method below. private static int[] SupportedDevToolsVersions => [ - 148, + 151, 150, 149, ]; private static DevToolsDomains? CreateDevToolsDomain(int protocolVersion, DevToolsSession session) => protocolVersion switch { - 148 => new V148.V148Domains(session), + 151 => new V151.V151Domains(session), 150 => new V150.V150Domains(session), 149 => new V149.V149Domains(session), _ => null diff --git a/dotnet/src/webdriver/DevTools/v148/V148Domains.cs b/dotnet/src/webdriver/DevTools/v151/V151Domains.cs similarity index 74% rename from dotnet/src/webdriver/DevTools/v148/V148Domains.cs rename to dotnet/src/webdriver/DevTools/v151/V151Domains.cs index 85b91c7e87703..41042b5ee52d5 100644 --- a/dotnet/src/webdriver/DevTools/v148/V148Domains.cs +++ b/dotnet/src/webdriver/DevTools/v151/V151Domains.cs @@ -1,4 +1,4 @@ -// +// // Licensed to the Software Freedom Conservancy (SFC) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -17,38 +17,38 @@ // under the License. // -namespace OpenQA.Selenium.DevTools.V148; +namespace OpenQA.Selenium.DevTools.V151; /// -/// Class containing the domain implementation for version 148 of the DevTools Protocol. +/// Class containing the domain implementation for version 151 of the DevTools Protocol. /// -public class V148Domains : DevToolsDomains +public class V151Domains : DevToolsDomains { private readonly DevToolsSessionDomains domains; - private readonly Lazy network; - private readonly Lazy javaScript; - private readonly Lazy target; - private readonly Lazy log; + private readonly Lazy network; + private readonly Lazy javaScript; + private readonly Lazy target; + private readonly Lazy log; /// - /// Initializes a new instance of the V148Domains class. + /// Initializes a new instance of the V151Domains class. /// /// The DevToolsSession to use with this set of domains. /// If is . - public V148Domains(DevToolsSession session) + public V151Domains(DevToolsSession session) { ArgumentNullException.ThrowIfNull(session); this.domains = new DevToolsSessionDomains(session); - this.network = new Lazy(() => new V148Network(domains.Network, domains.Fetch)); - this.javaScript = new Lazy(() => new V148JavaScript(domains.Runtime, domains.Page)); - this.target = new Lazy(() => new V148Target(domains.Target)); - this.log = new Lazy(() => new V148Log(domains.Log)); + this.network = new Lazy(() => new V151Network(domains.Network, domains.Fetch)); + this.javaScript = new Lazy(() => new V151JavaScript(domains.Runtime, domains.Page)); + this.target = new Lazy(() => new V151Target(domains.Target)); + this.log = new Lazy(() => new V151Log(domains.Log)); } /// /// Gets the DevTools Protocol version for which this class is valid. /// - public static int DevToolsVersion => 148; + public static int DevToolsVersion => 151; /// /// Gets the version-specific domains for the DevTools session. This value must be cast to a version specific type to be at all useful. diff --git a/dotnet/src/webdriver/DevTools/v148/V148JavaScript.cs b/dotnet/src/webdriver/DevTools/v151/V151JavaScript.cs similarity index 94% rename from dotnet/src/webdriver/DevTools/v148/V148JavaScript.cs rename to dotnet/src/webdriver/DevTools/v151/V151JavaScript.cs index 2e4ddc1b759e5..1b915228bf54f 100644 --- a/dotnet/src/webdriver/DevTools/v148/V148JavaScript.cs +++ b/dotnet/src/webdriver/DevTools/v151/V151JavaScript.cs @@ -1,4 +1,4 @@ -// +// // Licensed to the Software Freedom Conservancy (SFC) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -17,26 +17,26 @@ // under the License. // -using OpenQA.Selenium.DevTools.V148.Page; -using OpenQA.Selenium.DevTools.V148.Runtime; +using OpenQA.Selenium.DevTools.V151.Page; +using OpenQA.Selenium.DevTools.V151.Runtime; -namespace OpenQA.Selenium.DevTools.V148; +namespace OpenQA.Selenium.DevTools.V151; /// -/// Class containing the JavaScript implementation for version 148 of the DevTools Protocol. +/// Class containing the JavaScript implementation for version 151 of the DevTools Protocol. /// -public class V148JavaScript : JavaScript +public class V151JavaScript : JavaScript { private readonly RuntimeAdapter runtime; private readonly PageAdapter page; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The DevTools Protocol adapter for the Runtime domain. /// The DevTools Protocol adapter for the Page domain. /// If or are . - public V148JavaScript(RuntimeAdapter runtime, PageAdapter page) + public V151JavaScript(RuntimeAdapter runtime, PageAdapter page) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(page); diff --git a/dotnet/src/webdriver/DevTools/v148/V148Log.cs b/dotnet/src/webdriver/DevTools/v151/V151Log.cs similarity index 88% rename from dotnet/src/webdriver/DevTools/v148/V148Log.cs rename to dotnet/src/webdriver/DevTools/v151/V151Log.cs index fded3f17375e7..52d7b0466d9cd 100644 --- a/dotnet/src/webdriver/DevTools/v148/V148Log.cs +++ b/dotnet/src/webdriver/DevTools/v151/V151Log.cs @@ -1,4 +1,4 @@ -// +// // Licensed to the Software Freedom Conservancy (SFC) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -17,23 +17,23 @@ // under the License. // -using OpenQA.Selenium.DevTools.V148.Log; +using OpenQA.Selenium.DevTools.V151.Log; -namespace OpenQA.Selenium.DevTools.V148; +namespace OpenQA.Selenium.DevTools.V151; /// -/// Class containing the browser's log as referenced by version 148 of the DevTools Protocol. +/// Class containing the browser's log as referenced by version 151 of the DevTools Protocol. /// -public class V148Log : DevTools.Log +public class V151Log : DevTools.Log { private readonly LogAdapter adapter; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The adapter for the Log domain. /// If is . - public V148Log(LogAdapter adapter) + public V151Log(LogAdapter adapter) { ArgumentNullException.ThrowIfNull(adapter); this.adapter = adapter; diff --git a/dotnet/src/webdriver/DevTools/v148/V148Network.cs b/dotnet/src/webdriver/DevTools/v151/V151Network.cs similarity index 95% rename from dotnet/src/webdriver/DevTools/v148/V148Network.cs rename to dotnet/src/webdriver/DevTools/v151/V151Network.cs index c52a8003d35d2..a8b11c5d43b88 100644 --- a/dotnet/src/webdriver/DevTools/v148/V148Network.cs +++ b/dotnet/src/webdriver/DevTools/v151/V151Network.cs @@ -1,4 +1,4 @@ -// +// // Licensed to the Software Freedom Conservancy (SFC) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -18,26 +18,26 @@ // using System.Text; -using OpenQA.Selenium.DevTools.V148.Fetch; -using OpenQA.Selenium.DevTools.V148.Network; +using OpenQA.Selenium.DevTools.V151.Fetch; +using OpenQA.Selenium.DevTools.V151.Network; -namespace OpenQA.Selenium.DevTools.V148; +namespace OpenQA.Selenium.DevTools.V151; /// -/// Class providing functionality for manipulating network calls using version 148 of the DevTools Protocol +/// Class providing functionality for manipulating network calls using version 151 of the DevTools Protocol /// -public class V148Network : DevTools.Network +public class V151Network : DevTools.Network { private readonly FetchAdapter fetch; private readonly NetworkAdapter network; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The adapter for the Network domain. /// The adapter for the Fetch domain. /// If or are . - public V148Network(NetworkAdapter network, FetchAdapter fetch) + public V151Network(NetworkAdapter network, FetchAdapter fetch) { ArgumentNullException.ThrowIfNull(network); ArgumentNullException.ThrowIfNull(fetch); @@ -231,9 +231,9 @@ public override async Task ContinueWithAuth(string requestId, string? userName, await fetch.ContinueWithAuth(new ContinueWithAuthCommandSettings() { RequestId = requestId, - AuthChallengeResponse = new V148.Fetch.AuthChallengeResponse() + AuthChallengeResponse = new V151.Fetch.AuthChallengeResponse() { - Response = V148.Fetch.AuthChallengeResponseResponseValues.ProvideCredentials, + Response = V151.Fetch.AuthChallengeResponseResponseValues.ProvideCredentials, Username = userName, Password = password } @@ -250,9 +250,9 @@ public override async Task CancelAuth(string requestId) await fetch.ContinueWithAuth(new ContinueWithAuthCommandSettings() { RequestId = requestId, - AuthChallengeResponse = new OpenQA.Selenium.DevTools.V148.Fetch.AuthChallengeResponse() + AuthChallengeResponse = new OpenQA.Selenium.DevTools.V151.Fetch.AuthChallengeResponse() { - Response = V148.Fetch.AuthChallengeResponseResponseValues.CancelAuth + Response = V151.Fetch.AuthChallengeResponseResponseValues.CancelAuth } }).ConfigureAwait(false); } diff --git a/dotnet/src/webdriver/DevTools/v148/V148Target.cs b/dotnet/src/webdriver/DevTools/v151/V151Target.cs similarity index 94% rename from dotnet/src/webdriver/DevTools/v148/V148Target.cs rename to dotnet/src/webdriver/DevTools/v151/V151Target.cs index de2a4bbb884c0..65d58f1650bbb 100644 --- a/dotnet/src/webdriver/DevTools/v148/V148Target.cs +++ b/dotnet/src/webdriver/DevTools/v151/V151Target.cs @@ -1,4 +1,4 @@ -// +// // Licensed to the Software Freedom Conservancy (SFC) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -18,23 +18,23 @@ // using System.Collections.ObjectModel; -using OpenQA.Selenium.DevTools.V148.Target; +using OpenQA.Selenium.DevTools.V151.Target; -namespace OpenQA.Selenium.DevTools.V148; +namespace OpenQA.Selenium.DevTools.V151; /// -/// Class providing functionality for manipulating targets for version 148 of the DevTools Protocol +/// Class providing functionality for manipulating targets for version 151 of the DevTools Protocol /// -public class V148Target : DevTools.Target +public class V151Target : DevTools.Target { private readonly TargetAdapter adapter; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The adapter for the Target domain. /// If is . - public V148Target(TargetAdapter adapter) + public V151Target(TargetAdapter adapter) { ArgumentNullException.ThrowIfNull(adapter); this.adapter = adapter; diff --git a/dotnet/test/webdriver/DevTools/DevToolsConsoleTests.cs b/dotnet/test/webdriver/DevTools/DevToolsConsoleTests.cs index 4f33b9500f4ff..5ad320613aa10 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsConsoleTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsConsoleTests.cs @@ -17,7 +17,7 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; diff --git a/dotnet/test/webdriver/DevTools/DevToolsLogTests.cs b/dotnet/test/webdriver/DevTools/DevToolsLogTests.cs index 48e256b931549..a69b0392489bb 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsLogTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsLogTests.cs @@ -17,7 +17,7 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; diff --git a/dotnet/test/webdriver/DevTools/DevToolsNetworkTests.cs b/dotnet/test/webdriver/DevTools/DevToolsNetworkTests.cs index 503e6f7a6a73e..a3e988037976f 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsNetworkTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsNetworkTests.cs @@ -17,7 +17,7 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; diff --git a/dotnet/test/webdriver/DevTools/DevToolsPerformanceTests.cs b/dotnet/test/webdriver/DevTools/DevToolsPerformanceTests.cs index e8e15993ae735..604ae8e93a8b1 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsPerformanceTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsPerformanceTests.cs @@ -17,7 +17,7 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; diff --git a/dotnet/test/webdriver/DevTools/DevToolsProfilerTests.cs b/dotnet/test/webdriver/DevTools/DevToolsProfilerTests.cs index c84a3420ddbd7..eefc84369678f 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsProfilerTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsProfilerTests.cs @@ -17,7 +17,7 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; diff --git a/dotnet/test/webdriver/DevTools/DevToolsSecurityTests.cs b/dotnet/test/webdriver/DevTools/DevToolsSecurityTests.cs index a6a5ae7de1d3d..3dbb0fddfaa30 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsSecurityTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsSecurityTests.cs @@ -17,7 +17,7 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; diff --git a/dotnet/test/webdriver/DevTools/DevToolsTabsTests.cs b/dotnet/test/webdriver/DevTools/DevToolsTabsTests.cs index 7cbcfb54640b0..125b1b91f2b86 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsTabsTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsTabsTests.cs @@ -17,7 +17,7 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; diff --git a/dotnet/test/webdriver/DevTools/DevToolsTargetTests.cs b/dotnet/test/webdriver/DevTools/DevToolsTargetTests.cs index 41b356211818c..1bba68e771a15 100644 --- a/dotnet/test/webdriver/DevTools/DevToolsTargetTests.cs +++ b/dotnet/test/webdriver/DevTools/DevToolsTargetTests.cs @@ -17,14 +17,14 @@ // under the License. // -using CurrentCdpVersion = OpenQA.Selenium.DevTools.V150; +using CurrentCdpVersion = OpenQA.Selenium.DevTools.V151; namespace OpenQA.Selenium.Tests.DevTools; [TestFixture] public class DevToolsTargetTests : DevToolsTestFixture { - private const int id = 150; + private const int id = 151; [Test] [IgnoreBrowser(Browser.IE, "IE does not support Chrome DevTools Protocol")] diff --git a/dotnet/version.bzl b/dotnet/version.bzl index 44c3c2d443240..280b895f6165b 100644 --- a/dotnet/version.bzl +++ b/dotnet/version.bzl @@ -5,7 +5,7 @@ SE_VERSION = "4.47.0-nightly202607110055" SUPPORTED_DEVTOOLS_VERSIONS = [ "v149", "v150", - "v148", + "v151", ] ASSEMBLY_COMPANY = "Selenium Committers" diff --git a/java/src/org/openqa/selenium/devtools/v148/BUILD.bazel b/java/src/org/openqa/selenium/devtools/v151/BUILD.bazel similarity index 98% rename from java/src/org/openqa/selenium/devtools/v148/BUILD.bazel rename to java/src/org/openqa/selenium/devtools/v151/BUILD.bazel index 1086248f37ff6..6ffbd6e30800c 100644 --- a/java/src/org/openqa/selenium/devtools/v148/BUILD.bazel +++ b/java/src/org/openqa/selenium/devtools/v151/BUILD.bazel @@ -3,7 +3,7 @@ load("//common:defs.bzl", "copy_file") load("//java:defs.bzl", "java_export", "java_library") load("//java:version.bzl", "SE_VERSION") -cdp_version = "v148" +cdp_version = "v151" java_export( name = cdp_version, diff --git a/java/src/org/openqa/selenium/devtools/v148/package-info.java b/java/src/org/openqa/selenium/devtools/v151/package-info.java similarity index 95% rename from java/src/org/openqa/selenium/devtools/v148/package-info.java rename to java/src/org/openqa/selenium/devtools/v151/package-info.java index 3a9b045360273..c3b697ff9c176 100644 --- a/java/src/org/openqa/selenium/devtools/v148/package-info.java +++ b/java/src/org/openqa/selenium/devtools/v151/package-info.java @@ -16,6 +16,6 @@ // under the License. @NullMarked -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import org.jspecify.annotations.NullMarked; diff --git a/java/src/org/openqa/selenium/devtools/v148/v148CdpInfo.java b/java/src/org/openqa/selenium/devtools/v151/v151CdpInfo.java similarity index 86% rename from java/src/org/openqa/selenium/devtools/v148/v148CdpInfo.java rename to java/src/org/openqa/selenium/devtools/v151/v151CdpInfo.java index a874c6f846d08..3ca0110a4756b 100644 --- a/java/src/org/openqa/selenium/devtools/v148/v148CdpInfo.java +++ b/java/src/org/openqa/selenium/devtools/v151/v151CdpInfo.java @@ -15,15 +15,15 @@ // specific language governing permissions and limitations // under the License. -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import com.google.auto.service.AutoService; import org.openqa.selenium.devtools.CdpInfo; @AutoService(CdpInfo.class) -public class v148CdpInfo extends CdpInfo { +public class v151CdpInfo extends CdpInfo { - public v148CdpInfo() { - super(148, v148Domains::new); + public v151CdpInfo() { + super(151, v151Domains::new); } } diff --git a/java/src/org/openqa/selenium/devtools/v148/v148Domains.java b/java/src/org/openqa/selenium/devtools/v151/v151Domains.java similarity index 77% rename from java/src/org/openqa/selenium/devtools/v148/v148Domains.java rename to java/src/org/openqa/selenium/devtools/v151/v151Domains.java index bb8448a9d3eec..791bba431a4d2 100644 --- a/java/src/org/openqa/selenium/devtools/v148/v148Domains.java +++ b/java/src/org/openqa/selenium/devtools/v151/v151Domains.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import org.openqa.selenium.devtools.DevTools; import org.openqa.selenium.devtools.idealized.Domains; @@ -26,21 +26,21 @@ import org.openqa.selenium.devtools.idealized.target.Target; import org.openqa.selenium.internal.Require; -public class v148Domains implements Domains { +public class v151Domains implements Domains { - private final v148Javascript js; - private final v148Events events; - private final v148Log log; - private final v148Network network; - private final v148Target target; + private final v151Javascript js; + private final v151Events events; + private final v151Log log; + private final v151Network network; + private final v151Target target; - public v148Domains(DevTools devtools) { + public v151Domains(DevTools devtools) { Require.nonNull("DevTools", devtools); - events = new v148Events(devtools); - js = new v148Javascript(devtools); - log = new v148Log(); - network = new v148Network(devtools); - target = new v148Target(); + events = new v151Events(devtools); + js = new v151Javascript(devtools); + log = new v151Log(); + network = new v151Network(devtools); + target = new v151Target(); } @Override diff --git a/java/src/org/openqa/selenium/devtools/v148/v148Events.java b/java/src/org/openqa/selenium/devtools/v151/v151Events.java similarity index 86% rename from java/src/org/openqa/selenium/devtools/v148/v148Events.java rename to java/src/org/openqa/selenium/devtools/v151/v151Events.java index 08d7541e92875..f647e087b896c 100644 --- a/java/src/org/openqa/selenium/devtools/v148/v148Events.java +++ b/java/src/org/openqa/selenium/devtools/v151/v151Events.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import java.time.Instant; import java.util.List; @@ -28,15 +28,15 @@ import org.openqa.selenium.devtools.events.ConsoleEvent; import org.openqa.selenium.devtools.idealized.Events; import org.openqa.selenium.devtools.idealized.runtime.model.RemoteObject; -import org.openqa.selenium.devtools.v148.runtime.Runtime; -import org.openqa.selenium.devtools.v148.runtime.model.ConsoleAPICalled; -import org.openqa.selenium.devtools.v148.runtime.model.ExceptionDetails; -import org.openqa.selenium.devtools.v148.runtime.model.ExceptionThrown; -import org.openqa.selenium.devtools.v148.runtime.model.StackTrace; +import org.openqa.selenium.devtools.v151.runtime.Runtime; +import org.openqa.selenium.devtools.v151.runtime.model.ConsoleAPICalled; +import org.openqa.selenium.devtools.v151.runtime.model.ExceptionDetails; +import org.openqa.selenium.devtools.v151.runtime.model.ExceptionThrown; +import org.openqa.selenium.devtools.v151.runtime.model.StackTrace; -public class v148Events extends Events { +public class v151Events extends Events { - public v148Events(DevTools devtools) { + public v151Events(DevTools devtools) { super(devtools); } @@ -77,7 +77,7 @@ protected ConsoleEvent toConsoleEvent(ConsoleAPICalled event) { protected JavascriptException toJsException(ExceptionThrown event) { ExceptionDetails details = event.getExceptionDetails(); Optional maybeTrace = details.getStackTrace(); - Optional maybeException = + Optional maybeException = details.getException(); String message = diff --git a/java/src/org/openqa/selenium/devtools/v148/v148Javascript.java b/java/src/org/openqa/selenium/devtools/v151/v151Javascript.java similarity index 85% rename from java/src/org/openqa/selenium/devtools/v148/v148Javascript.java rename to java/src/org/openqa/selenium/devtools/v151/v151Javascript.java index 879408274a5ec..4f5bf3f7d4323 100644 --- a/java/src/org/openqa/selenium/devtools/v148/v148Javascript.java +++ b/java/src/org/openqa/selenium/devtools/v151/v151Javascript.java @@ -15,21 +15,21 @@ // specific language governing permissions and limitations // under the License. -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import java.util.Optional; import org.openqa.selenium.devtools.Command; import org.openqa.selenium.devtools.DevTools; import org.openqa.selenium.devtools.Event; import org.openqa.selenium.devtools.idealized.Javascript; -import org.openqa.selenium.devtools.v148.page.Page; -import org.openqa.selenium.devtools.v148.page.model.ScriptIdentifier; -import org.openqa.selenium.devtools.v148.runtime.Runtime; -import org.openqa.selenium.devtools.v148.runtime.model.BindingCalled; +import org.openqa.selenium.devtools.v151.page.Page; +import org.openqa.selenium.devtools.v151.page.model.ScriptIdentifier; +import org.openqa.selenium.devtools.v151.runtime.Runtime; +import org.openqa.selenium.devtools.v151.runtime.model.BindingCalled; -public class v148Javascript extends Javascript { +public class v151Javascript extends Javascript { - public v148Javascript(DevTools devtools) { + public v151Javascript(DevTools devtools) { super(devtools); } diff --git a/java/src/org/openqa/selenium/devtools/v148/v148Log.java b/java/src/org/openqa/selenium/devtools/v151/v151Log.java similarity index 89% rename from java/src/org/openqa/selenium/devtools/v148/v148Log.java rename to java/src/org/openqa/selenium/devtools/v151/v151Log.java index 20601b71cedc0..40f506de7e6fb 100644 --- a/java/src/org/openqa/selenium/devtools/v148/v148Log.java +++ b/java/src/org/openqa/selenium/devtools/v151/v151Log.java @@ -15,19 +15,19 @@ // specific language governing permissions and limitations // under the License. -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import java.util.function.Function; import java.util.logging.Level; import org.openqa.selenium.devtools.Command; import org.openqa.selenium.devtools.ConverterFunctions; import org.openqa.selenium.devtools.Event; -import org.openqa.selenium.devtools.v148.log.Log; -import org.openqa.selenium.devtools.v148.log.model.LogEntry; -import org.openqa.selenium.devtools.v148.runtime.model.Timestamp; +import org.openqa.selenium.devtools.v151.log.Log; +import org.openqa.selenium.devtools.v151.log.model.LogEntry; +import org.openqa.selenium.devtools.v151.runtime.model.Timestamp; import org.openqa.selenium.json.JsonInput; -public class v148Log implements org.openqa.selenium.devtools.idealized.log.Log { +public class v151Log implements org.openqa.selenium.devtools.idealized.log.Log { @Override public Command enable() { diff --git a/java/src/org/openqa/selenium/devtools/v148/v148Network.java b/java/src/org/openqa/selenium/devtools/v151/v151Network.java similarity index 88% rename from java/src/org/openqa/selenium/devtools/v148/v148Network.java rename to java/src/org/openqa/selenium/devtools/v151/v151Network.java index 68fe21de2649c..2a49cb5a4ad71 100644 --- a/java/src/org/openqa/selenium/devtools/v148/v148Network.java +++ b/java/src/org/openqa/selenium/devtools/v151/v151Network.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import static java.net.HttpURLConnection.HTTP_OK; @@ -35,40 +35,40 @@ import org.openqa.selenium.devtools.DevToolsException; import org.openqa.selenium.devtools.Event; import org.openqa.selenium.devtools.idealized.Network; -import org.openqa.selenium.devtools.v148.fetch.Fetch; -import org.openqa.selenium.devtools.v148.fetch.model.AuthChallengeResponse; -import org.openqa.selenium.devtools.v148.fetch.model.AuthRequired; -import org.openqa.selenium.devtools.v148.fetch.model.HeaderEntry; -import org.openqa.selenium.devtools.v148.fetch.model.RequestPattern; -import org.openqa.selenium.devtools.v148.fetch.model.RequestPaused; -import org.openqa.selenium.devtools.v148.fetch.model.RequestStage; -import org.openqa.selenium.devtools.v148.network.model.Request; +import org.openqa.selenium.devtools.v151.fetch.Fetch; +import org.openqa.selenium.devtools.v151.fetch.model.AuthChallengeResponse; +import org.openqa.selenium.devtools.v151.fetch.model.AuthRequired; +import org.openqa.selenium.devtools.v151.fetch.model.HeaderEntry; +import org.openqa.selenium.devtools.v151.fetch.model.RequestPattern; +import org.openqa.selenium.devtools.v151.fetch.model.RequestPaused; +import org.openqa.selenium.devtools.v151.fetch.model.RequestStage; +import org.openqa.selenium.devtools.v151.network.model.Request; import org.openqa.selenium.internal.Either; import org.openqa.selenium.remote.http.HttpRequest; import org.openqa.selenium.remote.http.HttpResponse; -public class v148Network extends Network { +public class v151Network extends Network { - private static final Logger LOG = Logger.getLogger(v148Network.class.getName()); + private static final Logger LOG = Logger.getLogger(v151Network.class.getName()); - public v148Network(DevTools devTools) { + public v151Network(DevTools devTools) { super(devTools); } @Override protected Command setUserAgentOverride(UserAgent userAgent) { - return org.openqa.selenium.devtools.v148.network.Network.setUserAgentOverride( + return org.openqa.selenium.devtools.v151.network.Network.setUserAgentOverride( userAgent.userAgent(), userAgent.acceptLanguage(), userAgent.platform(), Optional.empty()); } @Override protected Command enableNetworkCaching() { - return org.openqa.selenium.devtools.v148.network.Network.setCacheDisabled(false); + return org.openqa.selenium.devtools.v151.network.Network.setCacheDisabled(false); } @Override protected Command disableNetworkCaching() { - return org.openqa.selenium.devtools.v148.network.Network.setCacheDisabled(true); + return org.openqa.selenium.devtools.v151.network.Network.setCacheDisabled(true); } @Override diff --git a/java/src/org/openqa/selenium/devtools/v148/v148Target.java b/java/src/org/openqa/selenium/devtools/v151/v151Target.java similarity index 83% rename from java/src/org/openqa/selenium/devtools/v148/v148Target.java rename to java/src/org/openqa/selenium/devtools/v151/v151Target.java index 3e826eeafc1da..b22941e807485 100644 --- a/java/src/org/openqa/selenium/devtools/v148/v148Target.java +++ b/java/src/org/openqa/selenium/devtools/v151/v151Target.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package org.openqa.selenium.devtools.v148; +package org.openqa.selenium.devtools.v151; import java.util.List; import java.util.Map; @@ -28,21 +28,21 @@ import org.openqa.selenium.devtools.idealized.browser.model.BrowserContextID; import org.openqa.selenium.devtools.idealized.target.model.SessionID; import org.openqa.selenium.devtools.idealized.target.model.TargetID; -import org.openqa.selenium.devtools.v148.target.Target; -import org.openqa.selenium.devtools.v148.target.model.TargetInfo; +import org.openqa.selenium.devtools.v151.target.Target; +import org.openqa.selenium.devtools.v151.target.model.TargetInfo; import org.openqa.selenium.json.JsonInput; import org.openqa.selenium.json.TypeToken; -public class v148Target implements org.openqa.selenium.devtools.idealized.target.Target { +public class v151Target implements org.openqa.selenium.devtools.idealized.target.Target { @Override public Command detachFromTarget( Optional sessionId, Optional targetId) { return Target.detachFromTarget( sessionId.map( - id -> new org.openqa.selenium.devtools.v148.target.model.SessionID(id.toString())), + id -> new org.openqa.selenium.devtools.v151.target.model.SessionID(id.toString())), targetId.map( - id -> new org.openqa.selenium.devtools.v148.target.model.TargetID(id.toString()))); + id -> new org.openqa.selenium.devtools.v151.target.model.TargetID(id.toString()))); } @Override @@ -74,19 +74,19 @@ public Command detachFromTarget( @Override public Command attachToTarget(TargetID targetId) { - Function mapper = + Function mapper = ConverterFunctions.map( - "sessionId", org.openqa.selenium.devtools.v148.target.model.SessionID.class); + "sessionId", org.openqa.selenium.devtools.v151.target.model.SessionID.class); return new Command<>( "Target.attachToTarget", Map.of( "targetId", - new org.openqa.selenium.devtools.v148.target.model.TargetID(targetId.toString()), + new org.openqa.selenium.devtools.v151.target.model.TargetID(targetId.toString()), "flatten", true), input -> { - org.openqa.selenium.devtools.v148.target.model.SessionID id = mapper.apply(input); + org.openqa.selenium.devtools.v151.target.model.SessionID id = mapper.apply(input); return new SessionID(id.toString()); }); } @@ -101,9 +101,9 @@ public Event detached() { return new Event<>( "Target.detachedFromTarget", input -> { - Function converter = + Function converter = ConverterFunctions.map( - "targetId", org.openqa.selenium.devtools.v148.target.model.TargetID.class); + "targetId", org.openqa.selenium.devtools.v151.target.model.TargetID.class); return new TargetID(converter.apply(input).toString()); }); } diff --git a/java/src/org/openqa/selenium/devtools/versions.bzl b/java/src/org/openqa/selenium/devtools/versions.bzl index 5dd41ff515ebb..9b5b58d778e5c 100644 --- a/java/src/org/openqa/selenium/devtools/versions.bzl +++ b/java/src/org/openqa/selenium/devtools/versions.bzl @@ -1,7 +1,7 @@ CDP_VERSIONS = [ "v149", "v150", - "v148", + "v151", ] LATEST_CDP_VERSION = "v" + str(max([int(v[1:]) for v in CDP_VERSIONS])) diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel index f9c5e106fe5b3..eb5a355f5a973 100644 --- a/javascript/selenium-webdriver/BUILD.bazel +++ b/javascript/selenium-webdriver/BUILD.bazel @@ -127,7 +127,7 @@ VERSION = "4.47.0-nightly202607110055" BROWSER_VERSIONS = [ "v149", "v150", - "v148", + "v151", ] js_library( diff --git a/py/BUILD.bazel b/py/BUILD.bazel index e3fdcdb0c7b19..afca091dee01d 100644 --- a/py/BUILD.bazel +++ b/py/BUILD.bazel @@ -80,7 +80,7 @@ SE_VERSION = "4.47.0.202607110055" BROWSER_VERSIONS = [ "v149", "v150", - "v148", + "v151", ] TEST_DEPS = [ diff --git a/rake_tasks/java.rake b/rake_tasks/java.rake index 95445cf0cb69e..f53081882ae63 100644 --- a/rake_tasks/java.rake +++ b/rake_tasks/java.rake @@ -9,7 +9,7 @@ JAVA_RELEASE_TARGETS = %w[ //java/src/org/openqa/selenium/chromium:chromium.publish //java/src/org/openqa/selenium/devtools/v149:v149.publish //java/src/org/openqa/selenium/devtools/v150:v150.publish - //java/src/org/openqa/selenium/devtools/v148:v148.publish + //java/src/org/openqa/selenium/devtools/v151:v151.publish //java/src/org/openqa/selenium/devtools/latest:latest.publish //java/src/org/openqa/selenium/edge:edge.publish //java/src/org/openqa/selenium/firefox:firefox.publish diff --git a/rb/Gemfile.lock b/rb/Gemfile.lock index 6cffed9a1797c..5759514ada841 100644 --- a/rb/Gemfile.lock +++ b/rb/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - selenium-devtools (0.150.0) + selenium-devtools (0.151.0) selenium-webdriver (~> 4.2) selenium-webdriver (4.47.0.nightly) base64 (~> 0.2) diff --git a/rb/lib/selenium/devtools/BUILD.bazel b/rb/lib/selenium/devtools/BUILD.bazel index 634e747de9f48..eb432b7903025 100644 --- a/rb/lib/selenium/devtools/BUILD.bazel +++ b/rb/lib/selenium/devtools/BUILD.bazel @@ -6,7 +6,7 @@ package(default_visibility = ["//rb:__subpackages__"]) CDP_VERSIONS = [ "v149", "v150", - "v148", + "v151", ] rb_library( diff --git a/rb/lib/selenium/devtools/version.rb b/rb/lib/selenium/devtools/version.rb index 6e545fca37acb..c66bb483be533 100644 --- a/rb/lib/selenium/devtools/version.rb +++ b/rb/lib/selenium/devtools/version.rb @@ -19,6 +19,6 @@ module Selenium module DevTools - VERSION = '0.150.0' + VERSION = '0.151.0' end # DevTools end # Selenium From 4376465dce6487e9b2c91a7a20c5881e2c81cdde Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 5 Aug 2026 07:50:59 -0500 Subject: [PATCH 42/56] [build] patch rules_ruby to emit Windows batch launchers with CRLF --- MODULE.bazel | 9 +++++ .../bazel/rules_ruby_windows_batch_crlf.patch | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 third_party/bazel/rules_ruby_windows_batch_crlf.patch diff --git a/MODULE.bazel b/MODULE.bazel index 031db0abc184c..827d1a144d406 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -60,6 +60,15 @@ single_version_override( ], ) +# Patch support for bazel-contrib/rules_ruby#393 remove in next rules_ruby release +single_version_override( + module_name = "rules_ruby", + patch_strip = 1, + patches = [ + "//third_party/bazel:rules_ruby_windows_batch_crlf.patch", + ], +) + multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool") multitool.hub(lockfile = "//:multitool.lock.json") use_repo(multitool, "multitool") diff --git a/third_party/bazel/rules_ruby_windows_batch_crlf.patch b/third_party/bazel/rules_ruby_windows_batch_crlf.patch new file mode 100644 index 0000000000000..1c22d9da725ed --- /dev/null +++ b/third_party/bazel/rules_ruby_windows_batch_crlf.patch @@ -0,0 +1,39 @@ +diff --git a/ruby/private/utils.bzl b/ruby/private/utils.bzl +index bfc02aba..f10b2abf 100644 +--- a/ruby/private/utils.bzl ++++ b/ruby/private/utils.bzl +@@ -14,6 +14,7 @@ source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/ + """ + + # https://github.com/aspect-build/bazel-lib/blob/ddac9c46c3bff4cf8d0118a164c75390dbec2da9/lib/windows_utils.bzl ++# Normalized to CRLF; cmd.exe can't reliably find batch labels in LF-only .cmd files. + BATCH_RLOCATION_FUNCTION = r""" + rem Usage of rlocation function: + rem call :rlocation +@@ -76,7 +77,7 @@ set %~2=!abs_path! + exit /b 0 + :rlocation_end + :: End of rlocation +-""" ++""".replace("\r\n", "\n").replace("\n", "\r\n") + + def is_windows(ctx): + windows_constraint = ctx.attr._windows_constraint[platform_common.ConstraintValueInfo] +@@ -95,14 +96,16 @@ def convert_env_to_script(ctx, env): + environment = [] + if is_windows(ctx): + export_command = "set" ++ newline = "\r\n" # keep the generated .cmd CRLF; see BATCH_RLOCATION_FUNCTION + else: + export_command = "export" ++ newline = "\n" + + for (name, value) in env.items(): + command = "{command} {name}={value}".format(command = export_command, name = name, value = value) + environment.append(command) + +- return "\n".join(environment) ++ return newline.join(environment) + + def normalize_path(ctx, path): + """Converts path to an OS-specific equivalent. From 4efbdf0173c875fad8d112f789071e49524f7df3 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Wed, 5 Aug 2026 21:18:28 +0700 Subject: [PATCH 43/56] [grid] Dynamic K8s video always use the per-session subfolder; remove pod-wait + relocation (#17876) --- .../node/kubernetes/KubernetesSession.java | 104 +------------- .../kubernetes/KubernetesSessionFactory.java | 116 +++------------- .../KubernetesSessionFactoryTest.java | 47 ++----- .../kubernetes/KubernetesSessionTest.java | 128 ------------------ 4 files changed, 39 insertions(+), 356 deletions(-) delete mode 100644 java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java index 5d180f5ac8ca1..54fd4bc2f9c86 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java @@ -17,7 +17,6 @@ package org.openqa.selenium.grid.node.kubernetes; -import io.fabric8.kubernetes.api.model.Pod; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.LocalPortForward; @@ -28,7 +27,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; -import java.time.Duration; import java.time.Instant; import java.util.logging.Level; import java.util.logging.Logger; @@ -44,16 +42,12 @@ public class KubernetesSession extends DefaultActiveSession { private static final Logger LOG = Logger.getLogger(KubernetesSession.class.getName()); - private static final Duration POD_POLL_INTERVAL = Duration.ofSeconds(2); - private static final Duration VIDEO_FILE_STABLE_TIMEOUT = Duration.ofSeconds(30); - private static final Duration VIDEO_FILE_POLL_INTERVAL = Duration.ofMillis(500); private final String jobName; private final String namespace; private final KubernetesClient kubeClient; private final String podName; private final @Nullable String assetsPath; - private final @Nullable String videoFileName; private final long terminationGracePeriodSeconds; private final @Nullable LocalPortForward portForward; @@ -63,7 +57,6 @@ public class KubernetesSession extends DefaultActiveSession { KubernetesClient kubeClient, String podName, @Nullable String assetsPath, - @Nullable String videoFileName, long terminationGracePeriodSeconds, @Nullable LocalPortForward portForward, Tracer tracer, @@ -81,7 +74,6 @@ public class KubernetesSession extends DefaultActiveSession { this.kubeClient = Require.nonNull("KubernetesClient", kubeClient); this.podName = Require.nonNull("Pod name", podName); this.assetsPath = assetsPath; - this.videoFileName = videoFileName; this.terminationGracePeriodSeconds = terminationGracePeriodSeconds; this.portForward = portForward; } @@ -97,9 +89,10 @@ public void stop() { LOG.log(Level.WARNING, "Failed to close port-forward for session " + getId(), e); } } - // Delete the Job so K8s sends SIGTERM to containers (including video sidecar). + // Delete the Job so K8s sends SIGTERM to containers (including the video sidecar). The recorder + // writes each video straight to its final /videos// location, so there is nothing to + // wait for or relocate here. deleteJob(); - relocateVideoFiles(); super.stop(); } @@ -114,38 +107,6 @@ private void deleteJob() { } } - private void waitForPodTerminated() { - // Wait for the termination grace period plus a small buffer for K8s overhead - Duration timeout = Duration.ofSeconds(terminationGracePeriodSeconds + 10); - Instant deadline = Instant.now().plus(timeout); - while (Instant.now().isBefore(deadline)) { - try { - Pod pod = kubeClient.pods().inNamespace(namespace).withName(podName).get(); - if (pod == null) { - LOG.fine(String.format("Pod %s has been removed", podName)); - return; - } - String phase = pod.getStatus() != null ? pod.getStatus().getPhase() : null; - if ("Succeeded".equals(phase) || "Failed".equals(phase)) { - LOG.fine(String.format("Pod %s reached terminal phase: %s", podName, phase)); - return; - } - Thread.sleep(POD_POLL_INTERVAL.toMillis()); - } catch (KubernetesClientException e) { - // Pod may already be gone (404) — treat as terminated - LOG.fine(String.format("Pod %s no longer reachable: %s", podName, e.getMessage())); - return; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - LOG.warning( - String.format( - "Pod %s did not terminate within %ds, proceeding anyway", - podName, timeout.getSeconds())); - } - private void saveLogs() { if (assetsPath == null) { return; @@ -173,63 +134,4 @@ private void saveLogs() { LOG.log(Level.WARNING, "Failed to save browser Pod logs", e); } } - - private void relocateVideoFiles() { - if (assetsPath == null || videoFileName == null) { - // Either assets are not kept, or the recorder already wrote the video to its final - // per-session location (SE_VIDEO_SESSION_SUBFOLDER / SE_VIDEO_FILE_NAME=auto), so there is - // nothing to move and no reason to wait for the Pod. - return; - } - // The file is only complete once the recorder has been signalled and the Pod has terminated. - waitForPodTerminated(); - Path assetsDir = Paths.get(assetsPath); - // The recorder writes using jobName (set via SE_VIDEO_FILE_NAME at Job creation time). - // videoFileName is the fully resolved target name (may include caps-derived name + sessionId). - Path videoFile = assetsDir.resolve(jobName + ".mp4"); - if (!Files.exists(videoFile)) { - LOG.fine(String.format("No video file found at %s for session %s", videoFile, getId())); - return; - } - waitForFileStable(videoFile); - try { - Path sessionDir = assetsDir.resolve(getId().toString()); - Files.createDirectories(sessionDir); - Path target = sessionDir.resolve(videoFileName); - Files.move(videoFile, target, StandardCopyOption.REPLACE_EXISTING); - LOG.info( - String.format( - "Relocated video %s → %s for session %s", videoFile.getFileName(), target, getId())); - } catch (IOException e) { - LOG.log(Level.WARNING, "Failed to relocate video file: " + videoFile, e); - } - } - - private void waitForFileStable(Path file) { - Instant deadline = Instant.now().plus(VIDEO_FILE_STABLE_TIMEOUT); - long previousSize = -1; - while (Instant.now().isBefore(deadline)) { - try { - long currentSize = Files.size(file); - if (currentSize > 0 && currentSize == previousSize) { - LOG.fine( - String.format( - "Video file %s stabilized at %d bytes", file.getFileName(), currentSize)); - return; - } - previousSize = currentSize; - Thread.sleep(VIDEO_FILE_POLL_INTERVAL.toMillis()); - } catch (IOException e) { - LOG.log(Level.WARNING, "Error checking video file size: " + file, e); - return; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - LOG.warning( - String.format( - "Video file %s did not stabilize within %ds, relocating as-is", - file.getFileName(), VIDEO_FILE_STABLE_TIMEOUT.getSeconds())); - } } diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java index f7d4054be2b20..89ad656d22628 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactory.java @@ -79,8 +79,6 @@ import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; import org.jspecify.annotations.Nullable; import org.openqa.selenium.Capabilities; import org.openqa.selenium.Dimension; @@ -488,12 +486,6 @@ public Either apply(CreateSessionRequest sess span.addEvent("Kubernetes driver service created session", attributeMap); LOG.fine( String.format("Created session: %s - %s (job: %s)", id, mergedCapabilities, jobName)); - String videoFileName = null; - if (recordVideoForSession(sessionRequest.getDesiredCapabilities()) - && !isRecorderManagedFileName()) { - videoFileName = - resolveVideoFileName(jobName, sessionRequest.getDesiredCapabilities(), id) + ".mp4"; - } return Either.right( new KubernetesSession( jobName, @@ -501,7 +493,6 @@ public Either apply(CreateSessionRequest sess kubeClient, podName, assetsPath, - videoFileName, terminationGracePeriodSeconds, portForward, tracer, @@ -737,9 +728,9 @@ private List buildSessionEnvVars(String jobName, Capabilities sessionCap // Capabilities set to env vars with higher precedence setCapsToEnvVars(sessionCapabilities, envVars); - // Video recording env vars (inline and external use the same naming). + // Video recording env vars: inline and external both use the per-session subfolder approach. if (recordVideoForSession(sessionCapabilities)) { - addVideoFileNameEnvVars(envVars, jobName); + addVideoSubfolderEnvVars(envVars); // Inline video recording: browser container records directly (no sidecar) if (isNoVideoSidecar()) { @@ -752,40 +743,25 @@ private List buildSessionEnvVars(String jobName, Capabilities sessionCap return envVars; } - private void addVideoFileNameEnvVars(List envVars, String jobName) { - if (isVideoSessionSubfolder()) { - envVars.add( - new EnvVarBuilder().withName("SE_VIDEO_SESSION_SUBFOLDER").withValue("true").build()); - // The recorder creates /videos// only while it owns the file name, and /videos is - // the same volume as the assets path, so the video lands at its final location. - if (!isVideoFileNameAuto()) { - envVars.add(new EnvVarBuilder().withName("SE_VIDEO_FILE_NAME").withValue("auto").build()); - } - } else if (!isVideoFileNameAuto()) { - // sessionId is not known yet, so the recorder writes jobName.mp4 and the session relocates it - envVars.add( - new EnvVarBuilder().withName("SE_VIDEO_FILE_NAME").withValue(jobName + ".mp4").build()); - } - } - - private String resolveVideoFileName(String jobName, Capabilities sessionCapabilities) { - return ofNullable(getVideoFileName(sessionCapabilities, "se:videoName")) - .or(() -> ofNullable(getVideoFileName(sessionCapabilities, "se:name"))) - .orElse(jobName); - } - - private String resolveVideoFileName( - String jobName, Capabilities sessionCapabilities, SessionId sessionId) { - String baseName = resolveVideoFileName(jobName, sessionCapabilities); - // Append sessionId suffix when the video name came from caps (se:videoName or se:name) - // and SE_VIDEO_FILE_NAME_SUFFIX is not explicitly disabled (default: true). - boolean nameFromCaps = !baseName.equals(jobName); - String suffixEnv = System.getenv("SE_VIDEO_FILE_NAME_SUFFIX"); - boolean appendSuffix = suffixEnv == null || !suffixEnv.equalsIgnoreCase("false"); - if (nameFromCaps && appendSuffix) { - return baseName + "_" + sessionId; - } - return baseName; + private void addVideoSubfolderEnvVars(List envVars) { + // Always use the per-session subfolder approach for both inline recording and the external + // video + // sidecar: the recorder writes each video straight to its final /videos// location + // (the same volume as the assets path), so the Node never has to wait for the Pod to terminate + // and relocate the file. When the recorder cannot resolve a session id it falls back to + // SE_NODE_CONTAINER_NAME (the Pod name, from the downward API) as the subfolder. + envVars.add( + new EnvVarBuilder().withName("SE_VIDEO_SESSION_SUBFOLDER").withValue("true").build()); + envVars.add(new EnvVarBuilder().withName("SE_VIDEO_FILE_NAME").withValue("auto").build()); + envVars.add( + new EnvVarBuilder() + .withName("SE_NODE_CONTAINER_NAME") + .withNewValueFrom() + .withNewFieldRef() + .withFieldPath("metadata.name") + .endFieldRef() + .endValueFrom() + .build()); } private Container buildBrowserContainer(String jobName, Capabilities sessionCapabilities) { @@ -853,7 +829,7 @@ private List buildVideoEnvVars(String jobName, Capabilities sessionCapab new EnvVarBuilder().withName("DISPLAY_CONTAINER_NAME").withValue("localhost").build()); envVars.add( new EnvVarBuilder().withName("SE_VIDEO_RECORD_STANDALONE").withValue("true").build()); - addVideoFileNameEnvVars(envVars, jobName); + addVideoSubfolderEnvVars(envVars); return envVars; } @@ -888,58 +864,10 @@ private Container buildVideoContainer(String jobName, Capabilities sessionCapabi return containerBuilder.build(); } - @Nullable - private String getVideoFileName(Capabilities sessionRequestCapabilities, String capabilityName) { - String trimRegex = getVideoFileNameTrimRegex(); - Optional testName = - ofNullable(sessionRequestCapabilities.getCapability(capabilityName)); - if (testName.isPresent()) { - String name = testName.get().toString(); - if (!name.isEmpty()) { - name = name.replaceAll(" ", "_").replaceAll(trimRegex, ""); - if (name.length() > 251) { - name = name.substring(0, 251); - } - return name; - } - } - return null; - } - - private String getVideoFileNameTrimRegex() { - String defaultRegex = "[^a-zA-Z0-9-_]"; - String envRegex = System.getenv("SE_VIDEO_FILE_NAME_TRIM_REGEX"); - if (envRegex == null || envRegex.isEmpty()) { - return defaultRegex; - } - try { - Pattern.compile(envRegex); - return envRegex; - } catch (PatternSyntaxException e) { - LOG.warning( - String.format( - "Invalid SE_VIDEO_FILE_NAME_TRIM_REGEX '%s': %s. Using default: %s", - envRegex, e.getMessage(), defaultRegex)); - return defaultRegex; - } - } - private boolean isNoVideoSidecar() { return videoImage == null || videoImage.equalsIgnoreCase("false"); } - private boolean isVideoFileNameAuto() { - return "auto".equalsIgnoreCase(System.getenv("SE_VIDEO_FILE_NAME")); - } - - boolean isVideoSessionSubfolder() { - return Boolean.parseBoolean(System.getenv("SE_VIDEO_SESSION_SUBFOLDER")); - } - - private boolean isRecorderManagedFileName() { - return isVideoFileNameAuto() || isVideoSessionSubfolder(); - } - Job buildJobSpecFromTemplate(String jobName, Capabilities sessionCapabilities) { // Deep copy via YAML round-trip so the original template is not mutated Job job = Serialization.unmarshal(Serialization.asYaml(jobTemplate), Job.class); diff --git a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java index 21487c8e10de5..24c7a395e8981 100644 --- a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java +++ b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionFactoryTest.java @@ -139,34 +139,9 @@ private static KubernetesSessionFactory createImageFactory( private static KubernetesSessionFactory createSubfolderImageFactory( String videoImage, String assetsPath) { - Tracer tracer = Mockito.mock(Tracer.class); - HttpClient.Factory clientFactory = Mockito.mock(HttpClient.Factory.class); - - return new KubernetesSessionFactory( - tracer, - clientFactory, - Duration.ofMinutes(5), - Duration.ofSeconds(120), - () -> Mockito.mock(KubernetesClient.class), - "selenium", - "selenium/standalone-chrome:latest", - new ImmutableCapabilities("browserName", "chrome"), - "IfNotPresent", - null, - Map.of(), - Map.of(), - Map.of(), - videoImage, - assetsPath, - InheritedPodSpec.empty(), - 30L, - false, - caps -> true) { - @Override - boolean isVideoSessionSubfolder() { - return true; - } - }; + // The per-session subfolder approach is always enabled now, so this is the same as the image + // factory (kept as a named helper for the subfolder-focused tests). + return createImageFactory(videoImage, assetsPath); } private static EnvVar findEnvVar(List envVars, String name) { @@ -808,7 +783,7 @@ void imageModeVideoContainerInheritsContainerSecurityContext() { // ---- Browser container env vars ---- @Test - void browserContainerHasVideoFileNameEnvVar() { + void browserContainerUsesAutoVideoFileNameAndSubfolder() { KubernetesSessionFactory factory = createImageFactory(null, null); Job job = @@ -820,7 +795,13 @@ void browserContainerHasVideoFileNameEnvVar() { job.getSpec().getTemplate().getSpec().getContainers(), "browser"); EnvVar videoFileName = findEnvVar(browser.getEnv(), "SE_VIDEO_FILE_NAME"); assertThat(videoFileName).isNotNull(); - assertThat(videoFileName.getValue()).isEqualTo("test-job.mp4"); + assertThat(videoFileName.getValue()).isEqualTo("auto"); + // The per-session subfolder approach is always used, with a Pod-name fallback env var. + assertThat(findEnvVar(browser.getEnv(), "SE_VIDEO_SESSION_SUBFOLDER")) + .isNotNull() + .extracting(EnvVar::getValue) + .isEqualTo("true"); + assertThat(findEnvVar(browser.getEnv(), "SE_NODE_CONTAINER_NAME")).isNotNull(); } @Test @@ -1062,7 +1043,7 @@ void imageModeVideoSidecarHasCorrectEnvVars() { assertThat(findEnvVar(video.getEnv(), "SE_VIDEO_FILE_NAME")) .isNotNull() .extracting(EnvVar::getValue) - .isEqualTo("test-job.mp4"); + .isEqualTo("auto"); assertThat(findEnvVar(video.getEnv(), "SE_VIDEO_RECORD_STANDALONE")) .isNotNull() .extracting(EnvVar::getValue) @@ -1155,7 +1136,7 @@ void templateModeVideoContainerEnvVarsMerged() { assertThat(findEnvVar(video.getEnv(), "SE_VIDEO_FILE_NAME")) .isNotNull() .extracting(EnvVar::getValue) - .isEqualTo("test-job.mp4"); + .isEqualTo("auto"); assertThat(findEnvVar(video.getEnv(), "SE_VIDEO_RECORD_STANDALONE")) .isNotNull() .extracting(EnvVar::getValue) @@ -1263,7 +1244,7 @@ void templateModeBrowserContainerEnvVarsMerged() { assertThat(findEnvVar(browser.getEnv(), "SE_VIDEO_FILE_NAME")) .isNotNull() .extracting(EnvVar::getValue) - .isEqualTo("test-job.mp4"); + .isEqualTo("auto"); assertThat(findEnvVar(browser.getEnv(), "SE_SCREEN_WIDTH")) .isNotNull() .extracting(EnvVar::getValue) diff --git a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java b/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java deleted file mode 100644 index 4baefb22380e2..0000000000000 --- a/java/test/org/openqa/selenium/grid/node/kubernetes/KubernetesSessionTest.java +++ /dev/null @@ -1,128 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package org.openqa.selenium.grid.node.kubernetes; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.RETURNS_DEEP_STUBS; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.fabric8.kubernetes.api.model.Pod; -import io.fabric8.kubernetes.api.model.PodList; -import io.fabric8.kubernetes.api.model.batch.v1.Job; -import io.fabric8.kubernetes.api.model.batch.v1.JobList; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; -import io.fabric8.kubernetes.client.dsl.PodResource; -import io.fabric8.kubernetes.client.dsl.ScalableResource; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.openqa.selenium.ImmutableCapabilities; -import org.openqa.selenium.remote.Dialect; -import org.openqa.selenium.remote.SessionId; -import org.openqa.selenium.remote.http.HttpClient; -import org.openqa.selenium.remote.http.HttpRequest; -import org.openqa.selenium.remote.http.HttpResponse; -import org.openqa.selenium.remote.tracing.Tracer; - -class KubernetesSessionTest { - - @TempDir Path tempDir; - - private KubernetesSession createSession( - KubernetesClient kubeClient, String assetsPath, String videoFileName) throws Exception { - HttpClient httpClient = mock(HttpClient.class); - when(httpClient.execute(any(HttpRequest.class))).thenReturn(new HttpResponse()); - - return new KubernetesSession( - "test-job", - "selenium", - kubeClient, - "test-pod", - assetsPath, - videoFileName, - 30L, - null, - mock(Tracer.class), - httpClient, - new SessionId("test-session-id"), - new URL("http://localhost:4444"), - new ImmutableCapabilities(), - new ImmutableCapabilities(), - Dialect.W3C, - Dialect.W3C, - Instant.now()); - } - - /** - * Deep stubs stop at links whose return type is a type variable, so the Job and Pod chains are - * stubbed by hand. - */ - @SuppressWarnings("unchecked") - private ScalableResource stubJobResource(KubernetesClient kubeClient) { - NonNamespaceOperation> jobsInNamespace = - mock(NonNamespaceOperation.class); - ScalableResource jobResource = mock(ScalableResource.class); - when(kubeClient.batch().v1().jobs().inNamespace("selenium")).thenReturn(jobsInNamespace); - when(jobsInNamespace.withName("test-job")).thenReturn(jobResource); - return jobResource; - } - - @SuppressWarnings("unchecked") - private PodResource stubPodResource(KubernetesClient kubeClient) { - NonNamespaceOperation podsInNamespace = - mock(NonNamespaceOperation.class); - PodResource podResource = mock(PodResource.class); - when(kubeClient.pods().inNamespace("selenium")).thenReturn(podsInNamespace); - when(podsInNamespace.withName("test-pod")).thenReturn(podResource); - return podResource; - } - - @Test - void stopDoesNotWaitForThePodWhenThereIsNoVideoToRelocate() throws Exception { - KubernetesClient kubeClient = mock(KubernetesClient.class, RETURNS_DEEP_STUBS); - ScalableResource jobResource = stubJobResource(kubeClient); - - createSession(kubeClient, null, null).stop(); - - verify(jobResource).delete(); - // Polling the Pod only serves the relocation, which has nothing to do here - verify(kubeClient, never()).pods(); - } - - @Test - void stopRelocatesTheVideoIntoTheSessionFolder() throws Exception { - KubernetesClient kubeClient = mock(KubernetesClient.class, RETURNS_DEEP_STUBS); - stubJobResource(kubeClient); - // A null Pod means it is already gone, so the termination wait returns immediately - when(stubPodResource(kubeClient).get()).thenReturn(null); - Files.writeString(tempDir.resolve("test-job.mp4"), "recorded"); - - createSession(kubeClient, tempDir.toString(), "my-test_test-session-id.mp4").stop(); - - assertThat(tempDir.resolve("test-session-id").resolve("my-test_test-session-id.mp4")).exists(); - assertThat(tempDir.resolve("test-job.mp4")).doesNotExist(); - } -} From b9d61b7eb80c903ed1db3af1d203912b8a1e7f90 Mon Sep 17 00:00:00 2001 From: Mathias Paulenko Echeverz Date: Wed, 5 Aug 2026 23:00:37 +0800 Subject: [PATCH 44/56] [rust] Prevent empty driver version from being cached in metadata (#17757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rust): prevent empty driver version from being cached in metadata Edge and Firefox cached empty driver_version in metadata when a version-less HTTP response was received (e.g. misconfigured driver-mirror-url or intercepting proxy). Chrome already guarded against this with `!driver_version.is_empty()`. This change applies the same guard to Edge and Firefox. Also adds unit tests for previously untested pure logic: - parse_version (version string parsing, error short-circuit, ESR/snap) - get_index_version (major/minor/patch extraction, error cases) - Version predicates (is_stable, is_beta, is_dev, is_nightly, is_esr, is_version_specific) - get_major_version, get_major_browser_version - str_to_os, OS::is, ARCH::is (config parsing) - get_env_name (env var name generation) - find_latest_from_cache, collect_files_from_cache - is_driver_and_matches_browser_version (including empty-major bug) - find_best_driver_from_cache (match, fallback, empty cache) Tests document latent bugs: lexical sort misorders across digit boundaries (9 vs 10), and starts_with("") matches all cached drivers when major version is empty. Fixes #17641 * fix: address Copilot review feedback - Use manager's actual get_platform_label() instead of hardcoded values, so tests work on Apple Silicon (mac-arm64) and 32-bit Windows (win32) - Rename get_major_version_errors_on_garbage to get_major_version_returns_first_component_for_non_numeric since the test asserts Ok, not Err * fix(rust): address qodo review — add regression test for empty driver guard, fix i686 arch classification - Add empty_driver_version_is_not_cached_in_metadata regression test that fails if the !driver_version.is_empty() guard is removed from edge.rs/firefox.rs - Move i686 from ARCH::X64 to ARCH::X32 aliases in config.rs so get_normalized_arch() correctly returns ARCH_X86 for 32-bit systems - Update config_unit_tests.rs to expect i686 under X32 and verify X64 does not match i686 * extract should_cache_driver_version helper for testable regression guard * add doc comment to should_cache_driver_version * fix(rust): address Qodo feedback - explicit as_str, remove unused rstest import, add ARCH docs * fix: resolve CI formatting and test failures - Fix rustfmt formatting in chrome.rs, edge.rs, firefox.rs (if-statement wrapping) - Fix import ordering in config_unit_tests.rs and version_unit_tests.rs - Fix collect_files_from_cache to filter files only, not directories - Fix get_index_version to return error on empty string input * style: apply go format changes to test files - Reformat Apache license headers in cache_unit_tests.rs, config_unit_tests.rs, version_unit_tests.rs - Reorder imports and collapse function signatures in cache_unit_tests.rs - Collapse fs::write calls to single line - Reformat get_driver_version_from_metadata call --- rust/src/chrome.rs | 6 +- rust/src/config.rs | 32 +++- rust/src/edge.rs | 9 +- rust/src/files.rs | 1 + rust/src/firefox.rs | 5 +- rust/src/lib.rs | 40 ++++ rust/src/metadata.rs | 12 ++ rust/tests/cache_unit_tests.rs | 302 +++++++++++++++++++++++++++++++ rust/tests/config_unit_tests.rs | 101 +++++++++++ rust/tests/version_unit_tests.rs | 186 +++++++++++++++++++ 10 files changed, 685 insertions(+), 9 deletions(-) create mode 100644 rust/tests/cache_unit_tests.rs create mode 100644 rust/tests/config_unit_tests.rs create mode 100644 rust/tests/version_unit_tests.rs diff --git a/rust/src/chrome.rs b/rust/src/chrome.rs index b0ed10af90194..06241c1d348a8 100644 --- a/rust/src/chrome.rs +++ b/rust/src/chrome.rs @@ -22,7 +22,8 @@ use crate::downloads::{parse_json_from_url, read_version_from_link}; use crate::files::{BrowserPath, compose_driver_path_in_cache, first_existing_path}; use crate::logger::Logger; use crate::metadata::{ - create_driver_metadata, get_driver_version_from_metadata, get_metadata, write_metadata, + create_driver_metadata, get_driver_version_from_metadata, get_metadata, + should_cache_driver_version, write_metadata, }; use crate::{ BETA, DASH_DASH_VERSION, DEV, NIGHTLY, OFFLINE_REQUEST_ERR_MSG, REG_VERSION_ARG, STABLE, @@ -366,8 +367,7 @@ impl SeleniumManager for ChromeManager { }; let driver_ttl = self.get_ttl(); - if driver_ttl > 0 && !major_browser_version.is_empty() && !driver_version.is_empty() - { + if should_cache_driver_version(driver_ttl, major_browser_version, &driver_version) { metadata.drivers.push(create_driver_metadata( major_browser_version, self.driver_name, diff --git a/rust/src/config.rs b/rust/src/config.rs index 60973a0c7da9e..2872acfdebe9e 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -176,6 +176,7 @@ pub fn str_to_os(os: &str) -> Result { } } +/// Processor architecture families used by the manager. #[allow(dead_code)] #[allow(clippy::upper_case_acronyms)] pub enum ARCH { @@ -186,15 +187,17 @@ pub enum ARCH { } impl ARCH { + /// Returns the known string aliases for this architecture. pub fn to_str_vector(&self) -> Vec<&str> { match self { - ARCH::X32 => vec![ARCH_X86, "i386", "x32"], - ARCH::X64 => vec![ARCH_X64, "amd64", "x64", "i686", "ia64"], + ARCH::X32 => vec![ARCH_X86, "i386", "x32", "i686"], + ARCH::X64 => vec![ARCH_X64, "amd64", "x64", "ia64"], ARCH::ARM64 => vec![ARCH_ARM64, "aarch64", "arm"], ARCH::ARMV7 => vec![ARCH_ARM7L, "armv7l"], } } + /// Checks whether the given architecture string matches this family. pub fn is(&self, arch: &str) -> bool { self.to_str_vector() .contains(&arch.to_ascii_lowercase().as_str()) @@ -269,6 +272,31 @@ fn get_env_name(suffix: &str) -> String { concat(ENV_PREFIX, suffix_uppercase.as_str()) } +#[cfg(test)] +mod env_name_tests { + use super::*; + + #[test] + fn get_env_name_simple_key() { + assert_eq!(get_env_name("browser"), "SE_BROWSER"); + } + + #[test] + fn get_env_name_dashes_become_underscores() { + assert_eq!(get_env_name("browser-version"), "SE_BROWSER_VERSION"); + } + + #[test] + fn get_env_name_mixed_case_uppercased() { + assert_eq!(get_env_name("Cache-Path"), "SE_CACHE_PATH"); + } + + #[test] + fn get_env_name_empty_suffix() { + assert_eq!(get_env_name(""), "SE_"); + } +} + fn get_config() -> Result { let cache_path = read_cache_path(); let config_path = Path::new(&cache_path).to_path_buf().join(CONFIG_FILE); diff --git a/rust/src/edge.rs b/rust/src/edge.rs index 32822382af6d8..e5249c143e72d 100644 --- a/rust/src/edge.rs +++ b/rust/src/edge.rs @@ -21,7 +21,8 @@ use crate::config::OS::{LINUX, MACOS, WINDOWS}; use crate::downloads::{parse_json_from_url, read_version_from_link}; use crate::files::{BrowserPath, compose_driver_path_in_cache, first_existing_path}; use crate::metadata::{ - create_driver_metadata, get_driver_version_from_metadata, get_metadata, write_metadata, + create_driver_metadata, get_driver_version_from_metadata, get_metadata, + should_cache_driver_version, write_metadata, }; use crate::{ BETA, DASH_DASH_VERSION, DEV, ENV_PROGRAM_FILES, ENV_PROGRAM_FILES_X86, Logger, NIGHTLY, @@ -281,7 +282,11 @@ impl SeleniumManager for EdgeManager { read_version_from_link(self.get_http_client(), &driver_url, self.get_logger())?; let driver_ttl = self.get_ttl(); - if driver_ttl > 0 && !major_browser_version.is_empty() { + if should_cache_driver_version( + driver_ttl, + major_browser_version.as_str(), + &driver_version, + ) { metadata.drivers.push(create_driver_metadata( major_browser_version.as_str(), self.driver_name, diff --git a/rust/src/files.rs b/rust/src/files.rs index 0072644332dca..1cbc27316a0d3 100644 --- a/rust/src/files.rs +++ b/rust/src/files.rs @@ -689,6 +689,7 @@ pub fn collect_files_from_cache bool>( .sort_by_file_name() .into_iter() .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) .filter(|entry| filter(entry)) .map(|entry| entry.path().to_owned()) .collect() diff --git a/rust/src/firefox.rs b/rust/src/firefox.rs index 19b9f4b3b014d..a466cf8366dc2 100644 --- a/rust/src/firefox.rs +++ b/rust/src/firefox.rs @@ -21,7 +21,8 @@ use crate::config::OS::{LINUX, MACOS, WINDOWS}; use crate::downloads::{parse_json_from_url, read_content_from_link, read_redirect_from_link}; use crate::files::{BrowserPath, compose_driver_path_in_cache}; use crate::metadata::{ - create_driver_metadata, get_driver_version_from_metadata, get_metadata, write_metadata, + create_driver_metadata, get_driver_version_from_metadata, get_metadata, + should_cache_driver_version, write_metadata, }; use crate::{ BETA, DASH_VERSION, DEV, ESR, LATEST_RELEASE, Logger, NIGHTLY, OFFLINE_REQUEST_ERR_MSG, STABLE, @@ -296,7 +297,7 @@ impl SeleniumManager for FirefoxManager { }; let driver_ttl = self.get_ttl(); - if driver_ttl > 0 && !major_browser_version.is_empty() { + if should_cache_driver_version(driver_ttl, major_browser_version, &driver_version) { metadata.drivers.push(create_driver_metadata( major_browser_version, self.driver_name, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6cf8f325278cd..0914a34f89e2b 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1987,9 +1987,49 @@ pub fn format_three_args(string: &str, arg1: &str, arg2: &str, arg3: &str) -> St // ---------------------------------------------------------- fn get_index_version(full_version: &str, index: usize) -> Result { + if full_version.is_empty() { + return Err(anyhow!(format!("Wrong version: {}", full_version))); + } let version_vec: Vec<&str> = full_version.split('.').collect(); Ok(version_vec .get(index) .ok_or(anyhow!(format!("Wrong version: {}", full_version)))? .to_string()) } + +#[cfg(test)] +mod index_version_tests { + use super::*; + + #[test] + fn get_index_version_major() { + assert_eq!(get_index_version("120.0.6099.109", 0).unwrap(), "120"); + } + + #[test] + fn get_index_version_minor() { + assert_eq!(get_index_version("120.0.6099.109", 1).unwrap(), "0"); + } + + #[test] + fn get_index_version_patch() { + assert_eq!(get_index_version("120.0.6099.109", 2).unwrap(), "6099"); + } + + #[test] + fn get_index_version_single_component() { + assert_eq!(get_index_version("115", 0).unwrap(), "115"); + } + + #[test] + fn get_index_version_out_of_bounds_errors() { + let result = get_index_version("115", 1); + assert!(result.is_err()); + } + + #[test] + fn get_index_version_empty_string_errors() { + let result = get_index_version("", 0); + assert!(result.is_err()); + } +} diff --git a/rust/src/metadata.rs b/rust/src/metadata.rs index 8d9888d77839c..90f234bc044fd 100644 --- a/rust/src/metadata.rs +++ b/rust/src/metadata.rs @@ -181,6 +181,18 @@ pub fn create_browser_metadata( } } +/// Determines whether a discovered driver version should be written to the metadata cache. +/// +/// Returns `false` when the TTL is zero, the major browser version is empty, or the +/// driver version itself is empty (e.g. a version-less HTTP response). +pub fn should_cache_driver_version( + driver_ttl: u64, + major_browser_version: &str, + driver_version: &str, +) -> bool { + driver_ttl > 0 && !major_browser_version.is_empty() && !driver_version.is_empty() +} + pub fn create_driver_metadata( major_browser_version: &str, driver_name: &str, diff --git a/rust/tests/cache_unit_tests.rs b/rust/tests/cache_unit_tests.rs new file mode 100644 index 0000000000000..62aac245b7cf3 --- /dev/null +++ b/rust/tests/cache_unit_tests.rs @@ -0,0 +1,302 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use selenium_manager::SeleniumManager; +use selenium_manager::files::{collect_files_from_cache, find_latest_from_cache}; +use selenium_manager::get_manager_by_browser; +use selenium_manager::metadata::{ + Metadata, create_driver_metadata, get_driver_version_from_metadata, get_metadata, + should_cache_driver_version, write_metadata, +}; + +use std::fs; +use std::path::PathBuf; +use tempfile::tempdir; +use walkdir::WalkDir; + +fn create_driver_in_cache(cache: &PathBuf, driver_name: &str, version: &str) { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + let platform = manager.get_platform_label().to_string(); + let os = manager.get_os().to_string(); + let ext = if selenium_manager::config::OS::WINDOWS.is(&os) { + ".exe" + } else { + "" + }; + let dir = cache + .join(driver_name) + .join(os) + .join(&platform) + .join(version); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(format!("{}{}", driver_name, ext)), b"fake binary").unwrap(); +} + +fn create_driver_in_cache_custom( + cache: &PathBuf, + driver_name: &str, + os_label: &str, + arch_label: &str, + version: &str, +) { + let dir = cache + .join(driver_name) + .join(os_label) + .join(arch_label) + .join(version); + fs::create_dir_all(&dir).unwrap(); + let ext = if os_label == "windows" { ".exe" } else { "" }; + fs::write(dir.join(format!("{}{}", driver_name, ext)), b"fake binary").unwrap(); +} + +#[test] +fn find_latest_from_cache_returns_none_when_empty() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + let result = find_latest_from_cache(&cache, |_| true).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn find_latest_from_cache_returns_matching_file() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "120.0.6099.109"); + + let result = find_latest_from_cache(&cache, |entry| { + entry + .file_name() + .to_str() + .map(|s| s.contains("chromedriver")) + .unwrap_or(false) + }) + .unwrap(); + + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.exists()); + assert!(path.to_str().unwrap().contains("chromedriver")); +} + +#[test] +fn find_latest_from_cache_returns_last_when_multiple_matches() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "115.0.5790.110"); + create_driver_in_cache(&cache, "chromedriver", "120.0.6099.109"); + + let result = find_latest_from_cache(&cache, |entry| { + entry + .file_name() + .to_str() + .map(|s| s.contains("chromedriver")) + .unwrap_or(false) + }) + .unwrap(); + + assert!(result.is_some()); + let path = result.unwrap(); + assert!( + path.to_str().unwrap().contains("120.0.6099.109"), + "expected last sorted entry to be 120.x, got: {:?}", + path + ); +} + +#[test] +fn find_latest_from_cache_lexical_sort_misorders_across_digit_boundaries() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache_custom(&cache, "chromedriver", "linux", "x86_64", "9.0.0"); + create_driver_in_cache_custom(&cache, "chromedriver", "linux", "x86_64", "10.0.0"); + + let result = find_latest_from_cache(&cache, |entry| { + entry + .file_name() + .to_str() + .map(|s| s.contains("chromedriver")) + .unwrap_or(false) + }) + .unwrap() + .unwrap(); + + assert!( + result.to_str().unwrap().contains("9.0.0"), + "lexical sort puts '9' after '10'; this test documents the latent bug" + ); +} + +#[test] +fn collect_files_from_cache_returns_empty_when_no_match() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "120.0.6099.109"); + + let result = collect_files_from_cache(&cache, |_| false); + assert!(result.is_empty()); +} + +#[test] +fn collect_files_from_cache_returns_all_matches() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "115.0.5790.110"); + create_driver_in_cache(&cache, "chromedriver", "120.0.6099.109"); + + let result = collect_files_from_cache(&cache, |entry| { + entry + .file_name() + .to_str() + .map(|s| s.contains("chromedriver")) + .unwrap_or(false) + }); + + assert_eq!(result.len(), 2); +} + +#[test] +fn is_driver_and_matches_browser_version_matches_major() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "120.0.6099.109"); + + let mut manager = get_manager_by_browser("chrome".to_string()).unwrap(); + manager.set_browser_version("120".to_string()); + let entries: Vec<_> = WalkDir::new(&cache) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .collect(); + + let matching: Vec<_> = entries + .iter() + .filter(|e| manager.is_driver_and_matches_browser_version(e)) + .collect(); + + assert_eq!(matching.len(), 1); +} + +#[test] +fn is_driver_and_matches_browser_version_empty_major_matches_all() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "115.0.5790.110"); + create_driver_in_cache(&cache, "chromedriver", "120.0.6099.109"); + + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + let entries: Vec<_> = WalkDir::new(&cache) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .collect(); + + let matching: Vec<_> = entries + .iter() + .filter(|e| manager.is_driver_and_matches_browser_version(e)) + .collect(); + + assert_eq!( + matching.len(), + 2, + "starts_with(\"\") matches every cached driver — documents the latent bug" + ); +} + +#[test] +fn find_best_driver_from_cache_returns_matching_version() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "115.0.5790.110"); + create_driver_in_cache(&cache, "chromedriver", "120.0.6099.109"); + + let mut manager = get_manager_by_browser("chrome".to_string()).unwrap(); + manager.set_cache_path(cache.to_str().unwrap().to_string()); + manager.set_browser_version("120".to_string()); + + let result = manager.find_best_driver_from_cache().unwrap(); + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.to_str().unwrap().contains("120.0.6099.109")); +} + +#[test] +fn find_best_driver_from_cache_falls_back_to_latest_when_no_match() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + create_driver_in_cache(&cache, "chromedriver", "115.0.5790.110"); + + let mut manager = get_manager_by_browser("chrome".to_string()).unwrap(); + manager.set_cache_path(cache.to_str().unwrap().to_string()); + manager.set_browser_version("130".to_string()); + + let result = manager.find_best_driver_from_cache().unwrap(); + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.to_str().unwrap().contains("chromedriver")); +} + +#[test] +fn find_best_driver_from_cache_returns_none_when_cache_empty() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + + let mut manager = get_manager_by_browser("chrome".to_string()).unwrap(); + manager.set_cache_path(cache.to_str().unwrap().to_string()); + manager.set_browser_version("120".to_string()); + + let result = manager.find_best_driver_from_cache().unwrap(); + assert!(result.is_none()); +} + +#[test] +fn empty_driver_version_is_not_cached_in_metadata() { + let tmp = tempdir().unwrap(); + let cache = tmp.path().to_path_buf(); + let log = selenium_manager::logger::Logger::default(); + + let mut metadata = Metadata { + browsers: Vec::new(), + drivers: Vec::new(), + stats: Vec::new(), + cached_assets: Vec::new(), + }; + + let major_browser_version = "120"; + let driver_name = "edgedriver"; + let driver_ttl = 3600; + + let driver_version = ""; + + if should_cache_driver_version(driver_ttl, major_browser_version, driver_version) { + metadata.drivers.push(create_driver_metadata( + major_browser_version, + driver_name, + driver_version, + driver_ttl, + )); + write_metadata(&metadata, &log, Some(cache.clone())); + } + + let read_back = get_metadata(&log, &Some(cache.clone())); + let result = + get_driver_version_from_metadata(&read_back.drivers, driver_name, major_browser_version); + + assert!( + result.is_none(), + "empty driver_version must not be cached in metadata — this test fails if the !driver_version.is_empty() guard is removed from should_cache_driver_version()" + ); +} diff --git a/rust/tests/config_unit_tests.rs b/rust/tests/config_unit_tests.rs new file mode 100644 index 0000000000000..9f43da39f5ed3 --- /dev/null +++ b/rust/tests/config_unit_tests.rs @@ -0,0 +1,101 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use selenium_manager::config::ARCH::{ARM64, ARMV7, X32, X64}; +use selenium_manager::config::OS::{LINUX, MACOS, WINDOWS}; +use selenium_manager::config::{ARCH, OS, str_to_os}; + +use rstest::rstest; + +#[rstest] +#[case("windows", WINDOWS)] +#[case("win", WINDOWS)] +#[case("Windows", WINDOWS)] +#[case("WINDOWS", WINDOWS)] +#[case("macos", MACOS)] +#[case("mac", MACOS)] +#[case("MacOS", MACOS)] +#[case("linux", LINUX)] +#[case("gnu/linux", LINUX)] +#[case("Linux", LINUX)] +fn str_to_os_parses_valid_os(#[case] input: &str, #[case] expected: OS) { + let result = str_to_os(input).unwrap(); + assert_eq!(result, expected); +} + +#[rstest] +#[case("solaris")] +#[case("")] +#[case("darwin")] +#[case("unix")] +fn str_to_os_rejects_invalid_os(#[case] input: &str) { + let result = str_to_os(input); + assert!(result.is_err()); +} + +#[rstest] +#[case(WINDOWS, "windows")] +#[case(WINDOWS, "win")] +#[case(WINDOWS, "Windows")] +#[case(MACOS, "macos")] +#[case(MACOS, "mac")] +#[case(LINUX, "linux")] +#[case(LINUX, "gnu/linux")] +fn os_is_matches(#[case] os: OS, #[case] candidate: &str) { + assert!(os.is(candidate)); +} + +#[rstest] +#[case(WINDOWS, "linux")] +#[case(WINDOWS, "macos")] +#[case(MACOS, "windows")] +#[case(MACOS, "linux")] +#[case(LINUX, "windows")] +#[case(LINUX, "macos")] +fn os_is_does_not_match(#[case] os: OS, #[case] candidate: &str) { + assert!(!os.is(candidate)); +} + +#[rstest] +#[case(X32, "x86")] +#[case(X32, "i386")] +#[case(X32, "x32")] +#[case(X32, "i686")] +#[case(X64, "x86_64")] +#[case(X64, "amd64")] +#[case(X64, "x64")] +#[case(X64, "ia64")] +#[case(ARM64, "arm64")] +#[case(ARM64, "aarch64")] +#[case(ARM64, "arm")] +#[case(ARMV7, "arm7l")] +#[case(ARMV7, "armv7l")] +fn arch_is_matches(#[case] arch: ARCH, #[case] candidate: &str) { + assert!(arch.is(candidate)); +} + +#[rstest] +#[case(X32, "x86_64")] +#[case(X32, "arm64")] +#[case(X64, "x86")] +#[case(X64, "i686")] +#[case(X64, "arm7l")] +#[case(ARM64, "x86_64")] +#[case(ARMV7, "aarch64")] +fn arch_is_does_not_match(#[case] arch: ARCH, #[case] candidate: &str) { + assert!(!arch.is(candidate)); +} diff --git a/rust/tests/version_unit_tests.rs b/rust/tests/version_unit_tests.rs new file mode 100644 index 0000000000000..f47c7b49409dd --- /dev/null +++ b/rust/tests/version_unit_tests.rs @@ -0,0 +1,186 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use selenium_manager::SeleniumManager; +use selenium_manager::files::parse_version; +use selenium_manager::get_manager_by_browser; +use selenium_manager::logger::Logger; + +use rstest::rstest; + +#[rstest] +#[case("Google Chrome 120.0.6099.109", "120.0.6099.109")] +#[case("Chromium 115.0.5790.110", "115.0.5790.110")] +#[case("Microsoft Edge 120.0.2210.91", "120.0.2210.91")] +#[case("Mozilla Firefox 121.0", "121.0")] +#[case("115", "115")] +#[case(" 130.0.6723.91 ", "130.0.6723.91")] +fn parse_version_extracts_version_string(#[case] input: &str, #[case] expected: &str) { + let log = Logger::new(); + let result = parse_version(input.to_string(), &log).unwrap(); + assert_eq!(result, expected); +} + +#[test] +fn parse_version_strips_trailing_dot() { + let log = Logger::new(); + let result = parse_version("120.".to_string(), &log).unwrap(); + assert_eq!(result, "120"); +} + +#[rstest] +#[case("error: command not found")] +#[case("ERROR: something went wrong")] +#[case("Error: unable to locate browser")] +fn parse_version_returns_error_on_error_string(#[case] input: &str) { + let log = Logger::new(); + let result = parse_version(input.to_string(), &log); + assert!(result.is_err()); +} + +#[test] +fn parse_version_returns_empty_on_garbage() { + let log = Logger::new(); + let result = parse_version("no version here".to_string(), &log).unwrap(); + assert_eq!(result, ""); +} + +#[test] +fn parse_version_handles_esr_suffix() { + let log = Logger::new(); + let result = parse_version("Mozilla Firefox 115.12.0esr".to_string(), &log).unwrap(); + assert_eq!(result, "115.12.0"); +} + +#[test] +fn parse_version_handles_snap_prefix() { + let log = Logger::new(); + let result = parse_version("snap 121.0.1".to_string(), &log).unwrap(); + assert_eq!(result, "121.0.1"); +} + +#[rstest] +#[case("stable", true)] +#[case("STABLE", true)] +#[case("Stable", true)] +#[case("beta", false)] +#[case("120.0.6099.109", false)] +fn is_stable_predicate(#[case] version: &str, #[case] expected: bool) { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + assert_eq!(manager.is_stable(version), expected); +} + +#[rstest] +#[case("beta", true)] +#[case("BETA", true)] +#[case("Beta", true)] +#[case("stable", false)] +#[case("120.0", false)] +fn is_beta_predicate(#[case] version: &str, #[case] expected: bool) { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + assert_eq!(manager.is_beta(version), expected); +} + +#[rstest] +#[case("dev", true)] +#[case("DEV", true)] +#[case("Dev", true)] +#[case("stable", false)] +fn is_dev_predicate(#[case] version: &str, #[case] expected: bool) { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + assert_eq!(manager.is_dev(version), expected); +} + +#[rstest] +#[case("nightly", true)] +#[case("NIGHTLY", true)] +#[case("canary", true)] +#[case("CANARY", true)] +#[case("Canary", true)] +#[case("stable", false)] +#[case("120.0", false)] +fn is_nightly_predicate(#[case] version: &str, #[case] expected: bool) { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + assert_eq!(manager.is_nightly(version), expected); +} + +#[rstest] +#[case("esr", true)] +#[case("ESR", true)] +#[case("Esr", true)] +#[case("stable", false)] +#[case("115.12.0", false)] +fn is_esr_predicate(#[case] version: &str, #[case] expected: bool) { + let manager = get_manager_by_browser("firefox".to_string()).unwrap(); + assert_eq!(manager.is_esr(version), expected); +} + +#[rstest] +#[case("120.0.6099.109", true)] +#[case("121.0", true)] +#[case("stable", false)] +#[case("beta", false)] +#[case("", false)] +fn is_version_specific_predicate(#[case] version: &str, #[case] expected: bool) { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + assert_eq!(manager.is_version_specific(version), expected); +} + +#[rstest] +#[case("120.0.6099.109", "120")] +#[case("115", "115")] +#[case("130.0.6723.91", "130")] +fn get_major_version_extracts_major(#[case] version: &str, #[case] expected: &str) { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + let result = manager.get_major_version(version).unwrap(); + assert_eq!(result, expected); +} + +#[test] +fn get_major_version_errors_on_empty() { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + let result = manager.get_major_version(""); + assert!(result.is_err()); +} + +#[test] +fn get_major_version_returns_first_component_for_non_numeric() { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + let result = manager.get_major_version("not-a-version"); + assert_eq!(result.unwrap(), "not-a-version"); +} + +#[rstest] +#[case("stable", "stable")] +#[case("beta", "beta")] +#[case("dev", "dev")] +#[case("nightly", "nightly")] +#[case("canary", "canary")] +#[case("esr", "esr")] +#[case("120.0.6099.109", "120")] +#[case("115", "115")] +fn get_major_browser_version_returns_expected(#[case] version: &str, #[case] expected: &str) { + let mut manager = get_manager_by_browser("chrome".to_string()).unwrap(); + manager.set_browser_version(version.to_string()); + assert_eq!(manager.get_major_browser_version(), expected); +} + +#[test] +fn get_major_browser_version_empty_returns_empty() { + let manager = get_manager_by_browser("chrome".to_string()).unwrap(); + assert_eq!(manager.get_major_browser_version(), ""); +} From 62b6294a39baccb2228d9c02702fa3cfb7157490 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Wed, 5 Aug 2026 10:09:39 -0500 Subject: [PATCH 45/56] [dotnet] support SE_*DRIVER environment variables to set driver locations (#17875) * [dotnet] support SE_*DRIVER environment variables to set driver locations * [dotnet] make env-var driver path internal, log env-var driver source * [dotnet] test env-var driver path via DriverProcessStarting event --------- Co-authored-by: Nikolay Borisenko <22616990+nvborisenko@users.noreply.github.com> --- .../webdriver/Chrome/ChromeDriverService.cs | 3 + .../src/webdriver/Chromium/ChromiumDriver.cs | 2 +- dotnet/src/webdriver/DriverService.cs | 26 ++++++++ .../src/webdriver/Edge/EdgeDriverService.cs | 3 + dotnet/src/webdriver/Firefox/FirefoxDriver.cs | 2 +- .../webdriver/Firefox/FirefoxDriverService.cs | 3 + .../webdriver/IE/InternetExplorerDriver.cs | 2 +- .../IE/InternetExplorerDriverService.cs | 3 + dotnet/src/webdriver/Safari/SafariDriver.cs | 2 +- .../webdriver/Safari/SafariDriverService.cs | 3 + dotnet/test/webdriver/DriverServiceTests.cs | 61 +++++++++++++++++++ 11 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 dotnet/test/webdriver/DriverServiceTests.cs diff --git a/dotnet/src/webdriver/Chrome/ChromeDriverService.cs b/dotnet/src/webdriver/Chrome/ChromeDriverService.cs index 0bc60d53c50d6..c24c77b7b8c53 100644 --- a/dotnet/src/webdriver/Chrome/ChromeDriverService.cs +++ b/dotnet/src/webdriver/Chrome/ChromeDriverService.cs @@ -40,6 +40,9 @@ private ChromeDriverService(string? executablePath, string? executableFileName, { } + /// + protected override string DriverServiceEnvironmentVariableName => "SE_CHROMEDRIVER"; + /// protected override DriverOptions GetDefaultDriverOptions() { diff --git a/dotnet/src/webdriver/Chromium/ChromiumDriver.cs b/dotnet/src/webdriver/Chromium/ChromiumDriver.cs index 522f425ba4d91..7b3197bc5c39a 100644 --- a/dotnet/src/webdriver/Chromium/ChromiumDriver.cs +++ b/dotnet/src/webdriver/Chromium/ChromiumDriver.cs @@ -156,7 +156,7 @@ private static async Task GenerateDriverServiceCommandExecutor throw new ArgumentNullException(nameof(options)); } - if (service.DriverServicePath == null) + if (service.DriverServicePath == null && service.DriverPathFromEnvironment == null) { DriverFinder finder = new DriverFinder(options); string fullServicePath = await finder.GetDriverPathAsync().ConfigureAwait(false); diff --git a/dotnet/src/webdriver/DriverService.cs b/dotnet/src/webdriver/DriverService.cs index 6d80dceacd2ad..7a643a52671ef 100644 --- a/dotnet/src/webdriver/DriverService.cs +++ b/dotnet/src/webdriver/DriverService.cs @@ -147,6 +147,23 @@ public int ProcessId /// public string? DriverServicePath { get; set; } + /// + /// Gets the name of the environment variable used to specify the driver executable location, + /// or if the service does not support one. + /// + protected virtual string? DriverServiceEnvironmentVariableName => null; + + /// + /// Gets the driver executable path from , or + /// if it is unset. When set, Selenium Manager is not invoked. + /// + internal string? DriverPathFromEnvironment => + this.DriverServiceEnvironmentVariableName is string name + && Environment.GetEnvironmentVariable(name) is string path + && !string.IsNullOrWhiteSpace(path) + ? path + : null; + /// /// Gets the command-line arguments for the driver service. /// @@ -218,6 +235,15 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) this.driverServiceProcess.StartInfo.FileName = Path.Combine(this.DriverServicePath, this.DriverServiceExecutableName); } + else if (this.DriverPathFromEnvironment is string environmentDriverPath) + { + if (_logger.IsEnabled(LogEventLevel.Debug)) + { + _logger.Debug($"Skipping Selenium Manager; using driver from {this.DriverServiceEnvironmentVariableName}: {environmentDriverPath}"); + } + + this.driverServiceProcess.StartInfo.FileName = environmentDriverPath; + } else { var driverFinder = new DriverFinder(this.GetDefaultDriverOptions()); diff --git a/dotnet/src/webdriver/Edge/EdgeDriverService.cs b/dotnet/src/webdriver/Edge/EdgeDriverService.cs index 74e59de4f80a3..5e03baef90b7a 100644 --- a/dotnet/src/webdriver/Edge/EdgeDriverService.cs +++ b/dotnet/src/webdriver/Edge/EdgeDriverService.cs @@ -40,6 +40,9 @@ private EdgeDriverService(string? executablePath, string? executableFileName, in { } + /// + protected override string DriverServiceEnvironmentVariableName => "SE_EDGEDRIVER"; + /// protected override DriverOptions GetDefaultDriverOptions() { diff --git a/dotnet/src/webdriver/Firefox/FirefoxDriver.cs b/dotnet/src/webdriver/Firefox/FirefoxDriver.cs index 685612893029e..af16ee93ae2d0 100644 --- a/dotnet/src/webdriver/Firefox/FirefoxDriver.cs +++ b/dotnet/src/webdriver/Firefox/FirefoxDriver.cs @@ -202,7 +202,7 @@ private static async Task GenerateDriverServiceCommandExecutor ArgumentNullException.ThrowIfNull(service); ArgumentNullException.ThrowIfNull(options); - if (service.DriverServicePath == null) + if (service.DriverServicePath == null && service.DriverPathFromEnvironment == null) { DriverFinder finder = new DriverFinder(options); string fullServicePath = await finder.GetDriverPathAsync().ConfigureAwait(false); diff --git a/dotnet/src/webdriver/Firefox/FirefoxDriverService.cs b/dotnet/src/webdriver/Firefox/FirefoxDriverService.cs index 3f6a75f6ccf09..7d558a176a1c0 100644 --- a/dotnet/src/webdriver/Firefox/FirefoxDriverService.cs +++ b/dotnet/src/webdriver/Firefox/FirefoxDriverService.cs @@ -47,6 +47,9 @@ private FirefoxDriverService(string? executablePath, string? executableFileName, { } + /// + protected override string DriverServiceEnvironmentVariableName => "SE_GECKODRIVER"; + /// protected override DriverOptions GetDefaultDriverOptions() { diff --git a/dotnet/src/webdriver/IE/InternetExplorerDriver.cs b/dotnet/src/webdriver/IE/InternetExplorerDriver.cs index dfcd88c350b22..79d9308fa7e1b 100644 --- a/dotnet/src/webdriver/IE/InternetExplorerDriver.cs +++ b/dotnet/src/webdriver/IE/InternetExplorerDriver.cs @@ -162,7 +162,7 @@ private static async Task GenerateDriverServiceCommandExecutor ArgumentNullException.ThrowIfNull(service); ArgumentNullException.ThrowIfNull(options); - if (service.DriverServicePath == null) + if (service.DriverServicePath == null && service.DriverPathFromEnvironment == null) { DriverFinder finder = new DriverFinder(options); string fullServicePath = await finder.GetDriverPathAsync().ConfigureAwait(false); diff --git a/dotnet/src/webdriver/IE/InternetExplorerDriverService.cs b/dotnet/src/webdriver/IE/InternetExplorerDriverService.cs index afd534c9088cc..856af585ae658 100644 --- a/dotnet/src/webdriver/IE/InternetExplorerDriverService.cs +++ b/dotnet/src/webdriver/IE/InternetExplorerDriverService.cs @@ -41,6 +41,9 @@ private InternetExplorerDriverService(string? executablePath, string? executable { } + /// + protected override string DriverServiceEnvironmentVariableName => "SE_IEDRIVER"; + /// protected override DriverOptions GetDefaultDriverOptions() { diff --git a/dotnet/src/webdriver/Safari/SafariDriver.cs b/dotnet/src/webdriver/Safari/SafariDriver.cs index 33c64b082cc89..52b95359b1cf5 100644 --- a/dotnet/src/webdriver/Safari/SafariDriver.cs +++ b/dotnet/src/webdriver/Safari/SafariDriver.cs @@ -167,7 +167,7 @@ private static async Task GenerateDriverServiceCommandExecutor ArgumentNullException.ThrowIfNull(service); ArgumentNullException.ThrowIfNull(options); - if (service.DriverServicePath == null) + if (service.DriverServicePath == null && service.DriverPathFromEnvironment == null) { DriverFinder finder = new DriverFinder(options); string fullServicePath = await finder.GetDriverPathAsync().ConfigureAwait(false); diff --git a/dotnet/src/webdriver/Safari/SafariDriverService.cs b/dotnet/src/webdriver/Safari/SafariDriverService.cs index 5c4f4af094e57..23df9008711a6 100644 --- a/dotnet/src/webdriver/Safari/SafariDriverService.cs +++ b/dotnet/src/webdriver/Safari/SafariDriverService.cs @@ -47,6 +47,9 @@ private SafariDriverService(string? executablePath, string? executableFileName, { } + /// + protected override string DriverServiceEnvironmentVariableName => "SE_SAFARIDRIVER"; + /// protected override DriverOptions GetDefaultDriverOptions() { diff --git a/dotnet/test/webdriver/DriverServiceTests.cs b/dotnet/test/webdriver/DriverServiceTests.cs new file mode 100644 index 0000000000000..6d1ba694c4018 --- /dev/null +++ b/dotnet/test/webdriver/DriverServiceTests.cs @@ -0,0 +1,61 @@ +// +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using System.ComponentModel; +using System.IO; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Edge; +using OpenQA.Selenium.Firefox; +using OpenQA.Selenium.IE; +using OpenQA.Selenium.Safari; + +namespace OpenQA.Selenium.Tests; + +[TestFixture] +[NonParallelizable] +public class DriverServiceTests +{ + private static IEnumerable DriverServices() + { + yield return new TestCaseData((Func)ChromeDriverService.CreateDefaultService, "SE_CHROMEDRIVER").SetName("Chrome"); + yield return new TestCaseData((Func)EdgeDriverService.CreateDefaultService, "SE_EDGEDRIVER").SetName("Edge"); + yield return new TestCaseData((Func)FirefoxDriverService.CreateDefaultService, "SE_GECKODRIVER").SetName("Firefox"); + yield return new TestCaseData((Func)InternetExplorerDriverService.CreateDefaultService, "SE_IEDRIVER").SetName("InternetExplorer"); + yield return new TestCaseData((Func)SafariDriverService.CreateDefaultService, "SE_SAFARIDRIVER").SetName("Safari"); + } + + [TestCaseSource(nameof(DriverServices))] + public void StartsDriverFromEnvironmentVariable(Func createService, string environmentVariable) + { + string original = Environment.GetEnvironmentVariable(environmentVariable); + string expectedPath = Path.Combine("path", "to", "driver"); + try + { + Environment.SetEnvironmentVariable(environmentVariable, expectedPath); + + Assert.That( + async () => await createService().StartAsync(), + Throws.InstanceOf().With.Message.Contains(expectedPath)); + } + finally + { + Environment.SetEnvironmentVariable(environmentVariable, original); + } + } +} From 4f1e33388359915999f472a55851cb5d05df5d25 Mon Sep 17 00:00:00 2001 From: Nikolay Borisenko <22616990+nvborisenko@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:55:22 +0300 Subject: [PATCH 46/56] [dotnet] [bidi] Make BiDi transport factories composable (#17877) --- dotnet/src/webdriver/BiDi/BiDi.cs | 6 +++- .../src/webdriver/BiDi/BiDiOptionsBuilder.cs | 36 ++++++++----------- .../test/webdriver/BiDi/SessionUnitTests.cs | 3 +- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/dotnet/src/webdriver/BiDi/BiDi.cs b/dotnet/src/webdriver/BiDi/BiDi.cs index 93dd627890567..3a51763890ba0 100644 --- a/dotnet/src/webdriver/BiDi/BiDi.cs +++ b/dotnet/src/webdriver/BiDi/BiDi.cs @@ -60,7 +60,11 @@ public static async Task ConnectAsync(Uri url, Action BiDiOptionsBuilder builder = new(); configure?.Invoke(builder); - var transport = await builder.TransportFactory(url, cancellationToken).ConfigureAwait(false); + var transportFactoryTask = builder.TransportFactory(url, cancellationToken) + ?? throw new InvalidOperationException("The transport factory must return a non-null Task instance."); + + var transport = await transportFactoryTask.ConfigureAwait(false) + ?? throw new InvalidOperationException("The transport factory must return a non-null ITransport instance."); BiDi bidi = new(); diff --git a/dotnet/src/webdriver/BiDi/BiDiOptionsBuilder.cs b/dotnet/src/webdriver/BiDi/BiDiOptionsBuilder.cs index 23b7d433fde4f..06c279cec02a7 100644 --- a/dotnet/src/webdriver/BiDi/BiDiOptionsBuilder.cs +++ b/dotnet/src/webdriver/BiDi/BiDiOptionsBuilder.cs @@ -27,8 +27,11 @@ namespace OpenQA.Selenium.BiDi; /// public sealed class BiDiOptionsBuilder { + private static readonly Func> DefaultTransportFactory = + (uri, ct) => WebSocketTransport.ConnectAsync(uri, null, ct); + internal Func> TransportFactory { get; private set; } - = (uri, ct) => WebSocketTransport.ConnectAsync(uri, null, ct); + = DefaultTransportFactory; /// /// Configures the BiDi connection to use a WebSocket transport. @@ -42,36 +45,27 @@ public sealed class BiDiOptionsBuilder /// The current instance for chaining. public BiDiOptionsBuilder UseWebSocket(Action? configure = null) { - return UseTransport((uri, ct) => WebSocketTransport.ConnectAsync(uri, configure, ct)); + TransportFactory = (uri, ct) => WebSocketTransport.ConnectAsync(uri, configure, ct); + return this; } /// - /// Configures the BiDi connection to use a transport created by the specified factory. + /// Composes a transport factory into the current transport pipeline. /// /// - /// BiDi takes ownership of the transport instance returned by the factory and will dispose it. + /// The callback receives the current transport factory and returns + /// the next factory in the chain. BiDi takes ownership of the transport instance returned by + /// the final factory and will dispose it. /// - /// A factory function that creates the instance. + /// A callback that composes a new transport factory from the current one. /// The current instance for chaining. - public BiDiOptionsBuilder UseTransport(Func factory) + public BiDiOptionsBuilder UseTransport(Func>, Func>> next) { - ArgumentNullException.ThrowIfNull(factory); - - return UseTransport((_, ct) => - { - if (ct.IsCancellationRequested) - { - return Task.FromCanceled(ct); - } + ArgumentNullException.ThrowIfNull(next); - var transport = factory() ?? throw new InvalidOperationException("The transport factory must return a non-null ITransport instance."); + var factory = next(TransportFactory) + ?? throw new InvalidOperationException("The transport factory decorator must return a non-null factory."); - return Task.FromResult(transport); - }); - } - - private BiDiOptionsBuilder UseTransport(Func> factory) - { TransportFactory = factory; return this; } diff --git a/dotnet/test/webdriver/BiDi/SessionUnitTests.cs b/dotnet/test/webdriver/BiDi/SessionUnitTests.cs index 8afa9eb9a9c9c..cd699cdc5adb7 100644 --- a/dotnet/test/webdriver/BiDi/SessionUnitTests.cs +++ b/dotnet/test/webdriver/BiDi/SessionUnitTests.cs @@ -34,7 +34,8 @@ class SessionUnitTests public async Task SetUp() { _transport = new FakeTransport(); - _bidi = await Selenium.BiDi.BiDi.ConnectAsync(new Uri("ws://fake"), opts => opts.UseTransport(() => _transport)); + _bidi = await Selenium.BiDi.BiDi.ConnectAsync(new Uri("ws://fake"), opts => + opts.UseTransport(_ => (_, _) => Task.FromResult(_transport))); } [TearDown] From d879edafa58ec2adc45981236ce1fb8cc421341f Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 6 Aug 2026 18:07:52 +0200 Subject: [PATCH 47/56] [java] Fix By.className()/By.id() misescaping non-ASCII leading digits (#17815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [java] Fix By.className()/By.id() misescaping non-ASCII leading digits PreW3CLocator.cssEscape (used by By.className/By.id, duplicated in W3CHttpCommandCodec) used Character.isDigit() to detect a leading digit needing CSS escaping. That accepts any Unicode decimal digit, not just ASCII 0-9, so a leading non-ASCII digit (e.g. Arabic-Indic U+0665) got rewritten using its numeric value as if it were the ASCII digit of the same value, producing the same selector as an unrelated ASCII-digit class/id (By.className("٥foo") collided with By.className("5foo")). Per the CSS Syntax spec, "digit" is ASCII 0-9 only; non-ASCII code points are already valid identifier-start characters and need no escaping, so the fix narrows the check to the ASCII range instead of adding new escaping logic. * [java] Add direct regression coverage for W3CHttpCommandCodec.cssEscape W3CHttpCommandCodec carries a byte-identical copy of the leading-digit CSS-escape logic fixed in By.java, but had no test file exercising it at all. ByTest/RemotableByTest only cover the By.java copy, since By.toJson() pre-escapes client-side before anything reaches a command codec, so a regression in this copy alone (e.g. from a future de-duplication refactor) would go unnoticed. Add W3CHttpCommandCodecTest exercising encode() for the "class name"/ "id" locator strategies directly, mirroring the ByTest cases. Verified these tests fail against the pre-fix implementation and reproduce the exact collision from the original report. * [java] Use HttpRequest#contentAsString() instead of deprecated Contents.string in test --------- Co-authored-by: Titus Fortner --- java/src/org/openqa/selenium/By.java | 6 +- .../remote/codec/w3c/W3CHttpCommandCodec.java | 6 +- java/test/org/openqa/selenium/ByTest.java | 26 +++++++ .../codec/w3c/W3CHttpCommandCodecTest.java | 72 +++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 java/test/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodecTest.java diff --git a/java/src/org/openqa/selenium/By.java b/java/src/org/openqa/selenium/By.java index ef28311f10157..24c890308e797 100644 --- a/java/src/org/openqa/selenium/By.java +++ b/java/src/org/openqa/selenium/By.java @@ -448,7 +448,11 @@ public final Parameters getRemoteParameters() { private String cssEscape(String using) { using = CSS_ESCAPE.matcher(using).replaceAll("\\\\$1"); - if (!using.isEmpty() && Character.isDigit(using.charAt(0))) { + // CSS only requires the leading-digit escape for ASCII 0-9; non-ASCII Unicode digits + // (e.g. Arabic-Indic, fullwidth) are already valid identifier-start code points and must + // be left untouched, or they collide with the escape for a different ASCII digit. + char first = using.isEmpty() ? '\0' : using.charAt(0); + if (first >= '0' && first <= '9') { using = "\\" + (30 + Integer.parseInt(using.substring(0, 1))) + " " + using.substring(1); } return using; diff --git a/java/src/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodec.java b/java/src/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodec.java index b7ad8623db030..eb04e54be920c 100644 --- a/java/src/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodec.java +++ b/java/src/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodec.java @@ -389,7 +389,11 @@ private Map asElement(Object id) { private String cssEscape(String using) { using = CSS_ESCAPE.matcher(using).replaceAll("\\\\$1"); - if (!using.isEmpty() && Character.isDigit(using.charAt(0))) { + // CSS only requires the leading-digit escape for ASCII 0-9; non-ASCII Unicode digits + // (e.g. Arabic-Indic, fullwidth) are already valid identifier-start code points and must + // be left untouched, or they collide with the escape for a different ASCII digit. + char first = using.isEmpty() ? '\0' : using.charAt(0); + if (first >= '0' && first <= '9') { using = "\\" + (30 + Integer.parseInt(using.substring(0, 1))) + " " + using.substring(1); } return using; diff --git a/java/test/org/openqa/selenium/ByTest.java b/java/test/org/openqa/selenium/ByTest.java index 46df1f203b7e3..19ce041c0bffd 100644 --- a/java/test/org/openqa/selenium/ByTest.java +++ b/java/test/org/openqa/selenium/ByTest.java @@ -122,4 +122,30 @@ void ensureIdIsSerializedProperly() { .containsEntry("using", "css selector") .containsEntry("value", "#one\\ two"); } + + @Test + void ensureLeadingAsciiDigitIsEscapedAsCodePoint() { + By by = By.className("5foo"); + + Json json = new Json(); + Map blob = json.toType(json.toJson(by), MAP_TYPE); + + assertThat(blob).containsEntry("using", "css selector").containsEntry("value", ".\\35 foo"); + } + + @Test + void ensureLeadingNonAsciiDigitIsNotMisescapedAsADifferentAsciiDigit() { + // U+0665 (Arabic-Indic digit five) has numeric value 5, but is not an ASCII digit. + // It is already a valid CSS identifier-start code point and must be passed through as-is, + // rather than being (mis)escaped to the same selector as an ASCII '5'. + By arabicIndicFive = By.className("٥foo"); + By asciiFive = By.className("5foo"); + + Json json = new Json(); + Map arabicBlob = json.toType(json.toJson(arabicIndicFive), MAP_TYPE); + Map asciiBlob = json.toType(json.toJson(asciiFive), MAP_TYPE); + + assertThat(arabicBlob).containsEntry("using", "css selector").containsEntry("value", ".٥foo"); + assertThat(arabicBlob.get("value")).isNotEqualTo(asciiBlob.get("value")); + } } diff --git a/java/test/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodecTest.java b/java/test/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodecTest.java new file mode 100644 index 0000000000000..f4d2e5fb4f1a0 --- /dev/null +++ b/java/test/org/openqa/selenium/remote/codec/w3c/W3CHttpCommandCodecTest.java @@ -0,0 +1,72 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.remote.codec.w3c; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openqa.selenium.json.Json.MAP_TYPE; + +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.json.Json; +import org.openqa.selenium.remote.Command; +import org.openqa.selenium.remote.DriverCommand; +import org.openqa.selenium.remote.SessionId; +import org.openqa.selenium.remote.http.HttpRequest; + +@Tag("UnitTests") +class W3CHttpCommandCodecTest { + + private final W3CHttpCommandCodec codec = new W3CHttpCommandCodec(); + private final SessionId sessionId = new SessionId(UUID.randomUUID()); + private final Json json = new Json(); + + @Test + void ensureLeadingAsciiDigitClassNameIsEscapedAsCodePoint() { + Map params = encodeFindElement("class name", "5foo"); + + assertThat(params).containsEntry("using", "css selector").containsEntry("value", ".\\35 foo"); + } + + @Test + void ensureLeadingAsciiDigitIdIsEscapedAsCodePoint() { + Map params = encodeFindElement("id", "5foo"); + + assertThat(params).containsEntry("using", "css selector").containsEntry("value", "#\\35 foo"); + } + + @Test + void ensureLeadingNonAsciiDigitClassNameIsNotMisescapedAsADifferentAsciiDigit() { + // U+0665 (Arabic-Indic digit five) has numeric value 5, but is not an ASCII digit. + // It must be passed through as-is, not (mis)escaped to the same selector as an ASCII '5'. + Map arabicIndicFive = encodeFindElement("class name", "٥foo"); + Map asciiFive = encodeFindElement("class name", "5foo"); + + assertThat(arabicIndicFive) + .containsEntry("using", "css selector") + .containsEntry("value", ".٥foo"); + assertThat(arabicIndicFive.get("value")).isNotEqualTo(asciiFive.get("value")); + } + + private Map encodeFindElement(String strategy, Object value) { + HttpRequest request = + codec.encode(new Command(sessionId, DriverCommand.FIND_ELEMENT(strategy, value))); + return json.toType(request.contentAsString(), MAP_TYPE); + } +} From 06a692ee065b90cedd2434300ca6de472576afa0 Mon Sep 17 00:00:00 2001 From: Navin Chandra Date: Fri, 7 Aug 2026 12:03:01 +0530 Subject: [PATCH 48/56] [py] fix no_proxy matching so empty entries and substrings do not bypass the proxy (#17884) --- py/selenium/webdriver/remote/client_config.py | 36 +++++- .../webdriver/remote/client_config_tests.py | 109 ++++++++++++++++++ .../remote/remote_connection_tests.py | 43 ++++--- 3 files changed, 169 insertions(+), 19 deletions(-) diff --git a/py/selenium/webdriver/remote/client_config.py b/py/selenium/webdriver/remote/client_config.py index a4a930e2f844a..bec52531bd43e 100644 --- a/py/selenium/webdriver/remote/client_config.py +++ b/py/selenium/webdriver/remote/client_config.py @@ -32,6 +32,35 @@ class AuthType(Enum): X_API_KEY = "X-API-Key" +def _no_proxy_entry_matches(entry: str, hostname: str, netloc: str) -> bool: + """Whether one ``no_proxy`` entry covers the host being connected to. + + Follows the semantics of :func:`urllib.request.proxy_bypass_environment`: an + entry covers the host itself and any of its sub-domains, compared without + regard to case. Empty entries, which a trailing or doubled comma produces, + match nothing rather than everything. + + Args: + entry: A single entry from ``no_proxy``, either a bare host + (optionally with a port, optionally dot-prefixed) or a full URL. + hostname: Lower-cased host of the remote server address, without a port. + netloc: Lower-cased host of the remote server address, with any port and + without the brackets an IPv6 literal is written with. + + Returns: + True if the proxy should be bypassed for this host. + """ + # A bare "host:port" entry is not a URL, and parsing it as one would read + # the host as a scheme, so only entries that name a scheme are parsed. + if "://" in entry: + entry = parse.urlparse(entry).netloc + # An IPv6 literal is bracketed in a netloc but not in a hostname. + entry = entry.strip().lstrip(".").lower().replace("[", "").replace("]", "") + if not entry: + return False + return any(host == entry or host.endswith(f".{entry}") for host in (hostname, netloc)) + + class _ClientConfigDescriptor: def __init__(self, name): self.name = name @@ -136,13 +165,12 @@ def get_proxy_url(self) -> str | None: if proxy_type is ProxyType.SYSTEM: _no_proxy = os.environ.get("no_proxy", os.environ.get("NO_PROXY")) if _no_proxy: + hostname = (remote_add.hostname or "").lower() + netloc = remote_add.netloc.lower().replace("[", "").replace("]", "") for entry in map(str.strip, _no_proxy.split(",")): if entry == "*": return None - n_url = parse.urlparse(entry) - if n_url.netloc and remote_add.netloc == n_url.netloc: - return None - if n_url.path in remote_add.netloc: + if _no_proxy_entry_matches(entry, hostname, netloc): return None return os.environ.get( "https_proxy" if self.remote_server_addr.startswith("https://") else "http_proxy", diff --git a/py/test/unit/selenium/webdriver/remote/client_config_tests.py b/py/test/unit/selenium/webdriver/remote/client_config_tests.py index 5a113885ea417..dcc291de1ff4b 100644 --- a/py/test/unit/selenium/webdriver/remote/client_config_tests.py +++ b/py/test/unit/selenium/webdriver/remote/client_config_tests.py @@ -17,14 +17,35 @@ import pytest +from selenium.webdriver.common.proxy import Proxy, ProxyType from selenium.webdriver.remote.client_config import ClientConfig +PROXY = "http://proxy.internal:3128" + @pytest.fixture def config(): return ClientConfig(remote_server_addr="http://localhost:4444") +@pytest.fixture +def system_proxy_env(monkeypatch): + """Clear every proxy variable, then set only ``http_proxy``.""" + + def setup(no_proxy=None): + for name in ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("http_proxy", PROXY) + if no_proxy is not None: + monkeypatch.setenv("no_proxy", no_proxy) + + return setup + + +def system_config(remote_server_addr="http://localhost:4444"): + return ClientConfig(remote_server_addr=remote_server_addr, proxy=Proxy(raw={"proxyType": ProxyType.SYSTEM})) + + def test_websocket_max_message_size_defaults_to_none(config): assert config.websocket_max_message_size is None @@ -37,3 +58,91 @@ def test_websocket_max_message_size_can_be_set(config): def test_websocket_max_message_size_via_constructor(): cfg = ClientConfig(remote_server_addr="http://localhost:4444", websocket_max_message_size=2**26) assert cfg.websocket_max_message_size == 2**26 + + +@pytest.mark.parametrize( + "no_proxy", + [ + "example.com,", + ",example.com", + "example.com,,other.com", + "example.com, ,other.com", + ",", + "", + ], + ids=[ + "trailing-comma", + "leading-comma", + "doubled-comma", + "whitespace-only-entry", + "bare-comma", + "empty-value", + ], +) +def test_empty_no_proxy_entries_do_not_bypass_the_proxy(system_proxy_env, no_proxy): + """An empty entry must be ignored, not treated as matching every host.""" + system_proxy_env(no_proxy) + assert system_config().get_proxy_url() == PROXY + + +@pytest.mark.parametrize( + ("no_proxy", "server"), + [ + ("foo.com", "http://myfoo.com.example.org:4444"), + ("example.com", "http://notexample.common.org:4444"), + ("localhost", "http://localhosting.org:4444"), + ], +) +def test_no_proxy_entry_does_not_match_on_a_bare_substring(system_proxy_env, no_proxy, server): + """A bypass entry must match a whole host or a dot-delimited suffix of it.""" + system_proxy_env(no_proxy) + assert system_config(server).get_proxy_url() == PROXY + + +@pytest.mark.parametrize( + ("no_proxy", "server"), + [ + ("example.com", "http://example.com:4444"), + ("example.com", "http://sub.example.com:4444"), + (".example.com", "http://sub.example.com:4444"), + ("localhost", "http://localhost:4444"), + ("other.com,example.com", "http://example.com:4444"), + ("other.com, example.com", "http://example.com:4444"), + ("example.com,", "http://example.com:4444"), + ("EXAMPLE.COM", "http://example.com:4444"), + ("127.0.0.1", "http://127.0.0.1:4444"), + ("::1", "http://[::1]:4444"), + ("[::1]", "http://[::1]:4444"), + ], +) +def test_matching_no_proxy_entry_bypasses_the_proxy(system_proxy_env, no_proxy, server): + system_proxy_env(no_proxy) + assert system_config(server).get_proxy_url() is None + + +def test_no_proxy_wildcard_bypasses_every_host(system_proxy_env): + system_proxy_env("*") + assert system_config().get_proxy_url() is None + + +def test_no_proxy_entry_written_as_a_url_matches_only_its_host(system_proxy_env): + system_proxy_env("http://example.com") + assert system_config("http://example.com:4444").get_proxy_url() is None + assert system_config("http://localhost:4444").get_proxy_url() == PROXY + + +def test_no_proxy_url_entry_matches_an_ipv6_host(system_proxy_env): + """An IPv6 literal is bracketed in a netloc but not in a hostname.""" + system_proxy_env("http://[::1]") + assert system_config("http://[::1]:4444").get_proxy_url() is None + + +def test_no_proxy_ipv6_url_entry_with_a_port_matches_only_that_port(system_proxy_env): + system_proxy_env("http://[::1]:4444") + assert system_config("http://[::1]:4444").get_proxy_url() is None + assert system_config("http://[::1]:5555").get_proxy_url() == PROXY + + +def test_proxy_is_used_when_no_proxy_is_unset(system_proxy_env): + system_proxy_env() + assert system_config().get_proxy_url() == PROXY diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index 0a93fe5685554..697e8ad98aeda 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -231,7 +231,9 @@ def test_get_proxy_url_https_auth(mock_proxy_auth_settings): def test_get_connection_manager_without_proxy(mock_proxy_settings_missing): remote_connection = RemoteConnection("http://remote", keep_alive=False) conn = remote_connection._get_connection_manager() - assert isinstance(conn, PoolManager) + # ProxyManager and SOCKSProxyManager both subclass PoolManager, so isinstance + # cannot tell a direct connection from a proxied one. + assert type(conn) is PoolManager def test_get_connection_manager_for_certs_and_timeout(): @@ -293,31 +295,42 @@ def test_get_connection_manager_with_auth_https_proxy(mock_proxy_auth_settings): @pytest.mark.parametrize( "url", [ - "*", - ".localhost", - "localhost:80", - "localhost", - "LOCALHOST", - "LOCALHOST:80", "http://localhost", + "http://localhost:80", "https://localhost", - "test.localhost", - " localhost", - "127.0.0.1", - "127.0.0.2", - "::1", + "http://LOCALHOST", + "http://LOCALHOST:80", + "http://test.localhost", + "http://127.0.0.1", + "http://65.253.214.253", + "http://[::1]", ], ) def test_get_connection_manager_when_no_proxy_set(mock_no_proxy_settings, url): remote_connection = RemoteConnection(url) - conn = remote_connection._get_connection_manager() - assert isinstance(conn, PoolManager) + assert remote_connection.client_config.get_proxy_url() is None + assert type(remote_connection._get_connection_manager()) is PoolManager + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.2", + "http://notlocalhost.com", + "http://localhost.evil.com", + ], +) +def test_get_connection_manager_when_no_proxy_does_not_match(mock_no_proxy_settings, url): + """A host that no_proxy does not cover must still be reached through the proxy.""" + remote_connection = RemoteConnection(url) + assert remote_connection.client_config.get_proxy_url() == "http://http_proxy.com:8080" + assert isinstance(remote_connection._get_connection_manager(), ProxyManager) def test_ignore_proxy_env_vars(mock_proxy_settings): remote_connection = RemoteConnection("http://remote", ignore_proxy=True) conn = remote_connection._get_connection_manager() - assert isinstance(conn, PoolManager) + assert type(conn) is PoolManager def test_get_socks_proxy_when_set(mock_socks_proxy_settings): From e653359a8a272f9ac5b64c5dea4ff2213937ae05 Mon Sep 17 00:00:00 2001 From: Puja Jagani Date: Tue, 28 Jul 2026 18:26:14 +0530 Subject: [PATCH 49/56] [js] Ensure BiDi is not exposed on driver Related to #17814 --- .../selenium-webdriver/lib/webdriver.js | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/javascript/selenium-webdriver/lib/webdriver.js b/javascript/selenium-webdriver/lib/webdriver.js index b2059de816491..dc67225c6341d 100644 --- a/javascript/selenium-webdriver/lib/webdriver.js +++ b/javascript/selenium-webdriver/lib/webdriver.js @@ -35,6 +35,7 @@ const http = require('../http/index') const fs = require('node:fs') const { Capabilities } = require('./capabilities') const path = require('node:path') +const util = require('node:util') const { NoSuchElementError } = require('./error') const cdpTargets = ['page', 'browser'] const { Credential } = require('./virtual_authenticator') @@ -1302,10 +1303,13 @@ class WebDriver { } /** - * Initiates bidi connection using 'webSocketUrl' - * @returns {BIDI} + * Initiates bidi connection using 'webSocketUrl'. Internal implementation + * backing the deprecated {@link WebDriver#getBidi}; composed BiDi modules + * (bidi/*.js factories, generated `.create(driver)` classes) call + * this directly so they don't trip the deprecation warning on that method. + * @returns {Promise} */ - async getBidi() { + async _getBidiConnection() { if (this._bidiConnection === undefined) { const caps = await this.getCapabilities() let WebSocketUrl = caps['map_'].get('webSocketUrl') @@ -1778,6 +1782,24 @@ class WebDriver { } } +/** + * Returns the WebDriver BiDi connection for this session. + * + * @deprecated BiDi is an internal implementation detail (see + * docs/decisions/17670-bidi-implementation-boundaries.md) — this accessor hands + * back the raw transport directly, which is no longer supported public API. + * Use a composed BiDi module instead, e.g. `Network.create(driver)` or + * `require('selenium-webdriver/bidi/network')`. + * @function + * @name WebDriver#getBidi + * @returns {Promise} + */ +WebDriver.prototype.getBidi = util.deprecate( + WebDriver.prototype._getBidiConnection, + 'WebDriver#getBidi() is deprecated. Use a composed BiDi module instead, e.g. Network.create(driver) or ' + + "require('selenium-webdriver/bidi/network'). See docs/decisions/17670-bidi-implementation-boundaries.md.", +) + /** * Interface for navigating back and forth in the browser history. * From 6b2b086a8afbb147946333f628ec50837feaa89b Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Fri, 7 Aug 2026 07:35:34 -0500 Subject: [PATCH 50/56] [java] Fix By.name() double String.format on names containing '%' (#17888) [java] Fix By.name() double String.format on names containing '%' (#17807) --- java/src/org/openqa/selenium/By.java | 2 +- java/test/org/openqa/selenium/ByTest.java | 36 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/java/src/org/openqa/selenium/By.java b/java/src/org/openqa/selenium/By.java index 24c890308e797..f4768bc60e7de 100644 --- a/java/src/org/openqa/selenium/By.java +++ b/java/src/org/openqa/selenium/By.java @@ -242,7 +242,7 @@ public ByName(String name) { super( "name", Require.argument("Name", name).nonNull("Cannot find elements when name text is null."), - String.format("*[name='%s']", name.replace("'", "\\'"))); + "*[name='" + name.replace("\\", "\\\\").replace("'", "\\'").replace("%", "%%") + "']"); this.name = name; } diff --git a/java/test/org/openqa/selenium/ByTest.java b/java/test/org/openqa/selenium/ByTest.java index 19ce041c0bffd..6eab809921686 100644 --- a/java/test/org/openqa/selenium/ByTest.java +++ b/java/test/org/openqa/selenium/ByTest.java @@ -148,4 +148,40 @@ void ensureLeadingNonAsciiDigitIsNotMisescapedAsADifferentAsciiDigit() { assertThat(arabicBlob).containsEntry("using", "css selector").containsEntry("value", ".٥foo"); assertThat(arabicBlob.get("value")).isNotEqualTo(asciiBlob.get("value")); } + + @Test + void ensureNameContainingPercentDoesNotThrowAndIsTreatedAsLiteral() { + By by = By.name("50%off"); + + Json json = new Json(); + Map blob = json.toType(json.toJson(by), MAP_TYPE); + + assertThat(blob) + .containsEntry("using", "css selector") + .containsEntry("value", "*[name='50%off']"); + } + + @Test + void ensureNameContainingFormatSpecifierIsNotDoubleFormatted() { + By by = By.name("foo%sbar"); + + Json json = new Json(); + Map blob = json.toType(json.toJson(by), MAP_TYPE); + + assertThat(blob) + .containsEntry("using", "css selector") + .containsEntry("value", "*[name='foo%sbar']"); + } + + @Test + void ensureNameContainingBackslashIsEscapedAsLiteral() { + By by = By.name("a\\b"); + + Json json = new Json(); + Map blob = json.toType(json.toJson(by), MAP_TYPE); + + assertThat(blob) + .containsEntry("using", "css selector") + .containsEntry("value", "*[name='a\\\\b']"); + } } From 076d428580b7b101c62722cef10be995ffd2e44c Mon Sep 17 00:00:00 2001 From: Nikolay Borisenko <22616990+nvborisenko@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:02:27 +0300 Subject: [PATCH 51/56] [dotnet] [bidi] Remove optional command timeout in command options (#17891) --- dotnet/src/webdriver/BiDi/Broker.cs | 15 +++++++-------- .../src/webdriver/BiDi/BrowsingContext/GetTree.cs | 3 +-- .../webdriver/BiDi/BrowsingContext/SetViewport.cs | 3 +-- dotnet/src/webdriver/BiDi/Command.cs | 2 -- .../webdriver/BiDi/Network/AddDataCollector.cs | 3 +-- dotnet/src/webdriver/BiDi/Network/AddIntercept.cs | 1 - .../webdriver/BiDi/Network/SetCacheBehavior.cs | 3 +-- .../src/webdriver/BiDi/Script/AddPreloadScript.cs | 3 +-- dotnet/src/webdriver/BiDi/Script/GetRealms.cs | 3 +-- .../src/webdriver/BiDi/Storage/DeleteCookies.cs | 3 +-- dotnet/src/webdriver/BiDi/Storage/GetCookies.cs | 3 +-- dotnet/src/webdriver/BiDi/Storage/SetCookie.cs | 3 +-- dotnet/test/webdriver/BiDi/SessionUnitTests.cs | 8 -------- 13 files changed, 16 insertions(+), 37 deletions(-) diff --git a/dotnet/src/webdriver/BiDi/Broker.cs b/dotnet/src/webdriver/BiDi/Broker.cs index a38c1f881f39a..69e20f6baaf35 100644 --- a/dotnet/src/webdriver/BiDi/Broker.cs +++ b/dotnet/src/webdriver/BiDi/Broker.cs @@ -78,12 +78,11 @@ public async Task ExecuteAsync(Command(TaskCreationOptions.RunContinuationsAsynchronously); - using var cts = cancellationToken.CanBeCanceled - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) - : new CancellationTokenSource(); + using CancellationTokenSource? cts = cancellationToken.CanBeCanceled + ? null + : new CancellationTokenSource(DefaultCommandTimeout); - var timeout = options?.Timeout ?? DefaultCommandTimeout; - cts.CancelAfter(timeout); + var effectiveToken = cts?.Token ?? cancellationToken; var sendBuffer = RentBuffer(); @@ -132,9 +131,9 @@ public async Task ExecuteAsync(Command + using var ctsRegistration = effectiveToken.Register(() => { - tcs.TrySetCanceled(cts.Token); + tcs.TrySetCanceled(effectiveToken); _pendingCommands.TryRemove(id, out _); }); @@ -149,7 +148,7 @@ public async Task ExecuteAsync(Command new() { Root = context, - MaxDepth = options?.MaxDepth, - Timeout = options?.Timeout + MaxDepth = options?.MaxDepth }; } diff --git a/dotnet/src/webdriver/BiDi/BrowsingContext/SetViewport.cs b/dotnet/src/webdriver/BiDi/BrowsingContext/SetViewport.cs index 28f3b4b255ada..0fdf45fc3347b 100644 --- a/dotnet/src/webdriver/BiDi/BrowsingContext/SetViewport.cs +++ b/dotnet/src/webdriver/BiDi/BrowsingContext/SetViewport.cs @@ -50,8 +50,7 @@ public sealed record ContextSetViewportOptions : CommandOptions { Context = context, Viewport = options?.Viewport, - DevicePixelRatio = options?.DevicePixelRatio, - Timeout = options?.Timeout + DevicePixelRatio = options?.DevicePixelRatio }; } diff --git a/dotnet/src/webdriver/BiDi/Command.cs b/dotnet/src/webdriver/BiDi/Command.cs index 147548b967071..099c685c0618e 100644 --- a/dotnet/src/webdriver/BiDi/Command.cs +++ b/dotnet/src/webdriver/BiDi/Command.cs @@ -63,8 +63,6 @@ public AdditionalData AdditionalData public abstract record CommandOptions { - public TimeSpan? Timeout { get; init; } - public AdditionalData AdditionalData { get; init; } public AdditionalData AdditionalMessageData { get; init; } diff --git a/dotnet/src/webdriver/BiDi/Network/AddDataCollector.cs b/dotnet/src/webdriver/BiDi/Network/AddDataCollector.cs index 4c7dc75352d19..5ce079fc26b98 100644 --- a/dotnet/src/webdriver/BiDi/Network/AddDataCollector.cs +++ b/dotnet/src/webdriver/BiDi/Network/AddDataCollector.cs @@ -43,8 +43,7 @@ public sealed record ContextAddDataCollectorOptions : CommandOptions { Contexts = [context], CollectorType = options?.CollectorType, - UserContexts = options?.UserContexts, - Timeout = options?.Timeout + UserContexts = options?.UserContexts }; } diff --git a/dotnet/src/webdriver/BiDi/Network/AddIntercept.cs b/dotnet/src/webdriver/BiDi/Network/AddIntercept.cs index ec4a6666890b7..b626581d14c98 100644 --- a/dotnet/src/webdriver/BiDi/Network/AddIntercept.cs +++ b/dotnet/src/webdriver/BiDi/Network/AddIntercept.cs @@ -29,7 +29,6 @@ public record AddInterceptOptions() : CommandOptions internal AddInterceptOptions(ContextAddInterceptOptions? options) : this() { UrlPatterns = options?.UrlPatterns; - Timeout = options?.Timeout; } public ImmutableArray? Contexts { get; init; } diff --git a/dotnet/src/webdriver/BiDi/Network/SetCacheBehavior.cs b/dotnet/src/webdriver/BiDi/Network/SetCacheBehavior.cs index 8ae745939c4c3..33eb84b13aaf5 100644 --- a/dotnet/src/webdriver/BiDi/Network/SetCacheBehavior.cs +++ b/dotnet/src/webdriver/BiDi/Network/SetCacheBehavior.cs @@ -33,8 +33,7 @@ public sealed record ContextSetCacheBehaviorOptions : CommandOptions { internal static SetCacheBehaviorOptions WithContext(ContextSetCacheBehaviorOptions? options, BrowsingContext.BrowsingContext context) => new() { - Contexts = [context], - Timeout = options?.Timeout + Contexts = [context] }; } diff --git a/dotnet/src/webdriver/BiDi/Script/AddPreloadScript.cs b/dotnet/src/webdriver/BiDi/Script/AddPreloadScript.cs index 2a859cee1fb42..c6e75a08fd463 100644 --- a/dotnet/src/webdriver/BiDi/Script/AddPreloadScript.cs +++ b/dotnet/src/webdriver/BiDi/Script/AddPreloadScript.cs @@ -44,8 +44,7 @@ public sealed record ContextAddPreloadScriptOptions : CommandOptions { Contexts = [context], Arguments = options?.Arguments, - Sandbox = options?.Sandbox, - Timeout = options?.Timeout + Sandbox = options?.Sandbox }; } diff --git a/dotnet/src/webdriver/BiDi/Script/GetRealms.cs b/dotnet/src/webdriver/BiDi/Script/GetRealms.cs index b4916e3abbcd5..ca91ec2bb35c4 100644 --- a/dotnet/src/webdriver/BiDi/Script/GetRealms.cs +++ b/dotnet/src/webdriver/BiDi/Script/GetRealms.cs @@ -35,8 +35,7 @@ public sealed record ContextGetRealmsOptions : CommandOptions internal static GetRealmsOptions WithContext(ContextGetRealmsOptions? options, BrowsingContext.BrowsingContext context) => new() { Context = context, - Type = options?.Type, - Timeout = options?.Timeout + Type = options?.Type }; } diff --git a/dotnet/src/webdriver/BiDi/Storage/DeleteCookies.cs b/dotnet/src/webdriver/BiDi/Storage/DeleteCookies.cs index e30fd6c0771ca..9559123bc73f2 100644 --- a/dotnet/src/webdriver/BiDi/Storage/DeleteCookies.cs +++ b/dotnet/src/webdriver/BiDi/Storage/DeleteCookies.cs @@ -35,8 +35,7 @@ public sealed record ContextDeleteCookiesOptions : CommandOptions internal static DeleteCookiesOptions WithContext(ContextDeleteCookiesOptions? options, BrowsingContext.BrowsingContext context) => new() { Partition = new ContextPartitionDescriptor(context), - Filter = options?.Filter, - Timeout = options?.Timeout + Filter = options?.Filter }; } diff --git a/dotnet/src/webdriver/BiDi/Storage/GetCookies.cs b/dotnet/src/webdriver/BiDi/Storage/GetCookies.cs index e51b483aa276d..352ff318125d2 100644 --- a/dotnet/src/webdriver/BiDi/Storage/GetCookies.cs +++ b/dotnet/src/webdriver/BiDi/Storage/GetCookies.cs @@ -38,8 +38,7 @@ public sealed record ContextGetCookiesOptions : CommandOptions internal static GetCookiesOptions WithContext(ContextGetCookiesOptions? options, BrowsingContext.BrowsingContext context) => new() { Filter = options?.Filter, - Partition = new ContextPartitionDescriptor(context), - Timeout = options?.Timeout + Partition = new ContextPartitionDescriptor(context) }; } diff --git a/dotnet/src/webdriver/BiDi/Storage/SetCookie.cs b/dotnet/src/webdriver/BiDi/Storage/SetCookie.cs index 2e26166ac8c51..357d0b9960f84 100644 --- a/dotnet/src/webdriver/BiDi/Storage/SetCookie.cs +++ b/dotnet/src/webdriver/BiDi/Storage/SetCookie.cs @@ -47,8 +47,7 @@ public sealed record ContextSetCookieOptions : CommandOptions { internal static SetCookieOptions WithContext(ContextSetCookieOptions? options, BrowsingContext.BrowsingContext context) => new() { - Partition = new ContextPartitionDescriptor(context), - Timeout = options?.Timeout + Partition = new ContextPartitionDescriptor(context) }; } diff --git a/dotnet/test/webdriver/BiDi/SessionUnitTests.cs b/dotnet/test/webdriver/BiDi/SessionUnitTests.cs index cd699cdc5adb7..ac70fdafccf1a 100644 --- a/dotnet/test/webdriver/BiDi/SessionUnitTests.cs +++ b/dotnet/test/webdriver/BiDi/SessionUnitTests.cs @@ -44,14 +44,6 @@ public async Task TearDown() await _bidi.DisposeAsync(); } - [Test] - public void ShouldRespectCommandTimeout() - { - Assert.That( - () => _bidi.StatusAsync(new() { Timeout = TimeSpan.FromMilliseconds(1) }), - Throws.InstanceOf()); - } - [Test] public void ShouldRespectCancellationToken() { From 8d23c9827a0417770027cbf66d53f8c276f31412 Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:00:39 -0400 Subject: [PATCH 52/56] [py] Ensure driver service subprocess resources are cleaned up (#17889) --- py/selenium/webdriver/common/service.py | 40 ++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/py/selenium/webdriver/common/service.py b/py/selenium/webdriver/common/service.py index aef101ea0f3f8..fa53be3988fe9 100644 --- a/py/selenium/webdriver/common/service.py +++ b/py/selenium/webdriver/common/service.py @@ -160,12 +160,13 @@ def stop(self) -> None: self.log_output.close() elif isinstance(self.log_output, int): os.close(self.log_output) - - if self.process is not None and self.process.poll() is None: + if self.process is not None: try: - self.send_remote_shutdown_command() - except TypeError: - pass + if self.process.poll() is None: + try: + self.send_remote_shutdown_command() + except TypeError: + pass finally: self._terminate_process() @@ -173,30 +174,29 @@ def _terminate_process(self) -> None: """Terminate the child process. On POSIX this attempts a graceful SIGTERM followed by a SIGKILL, - on a Windows OS kill is an alias to terminate. Terminating does - not raise itself if something has gone wrong but (currently) - silently ignores errors here. + on a Windows OS kill is an alias to terminate. Terminating does + not raise itself if something has gone wrong but ignores errors here. """ try: - stdin, stdout, stderr = ( + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(60) + except subprocess.TimeoutExpired: + logger.error( + "Service process refused to terminate gracefully with SIGTERM, escalating to SIGKILL.", + exc_info=True, + ) + self.process.kill() + for stream in ( self.process.stdin, self.process.stdout, self.process.stderr, - ) - for stream in stdin, stdout, stderr: + ): try: stream.close() # type: ignore except AttributeError: pass - self.process.terminate() - try: - self.process.wait(60) - except subprocess.TimeoutExpired: - logger.error( - "Service process refused to terminate gracefully with SIGTERM, escalating to SIGKILL.", - exc_info=True, - ) - self.process.kill() except OSError: logger.error("Error terminating service process.", exc_info=True) From f72f9ec0ae678b1061b21bc0ff60f82a02a84c39 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Sun, 9 Aug 2026 18:16:13 -0500 Subject: [PATCH 53/56] [build] Add java:local_dev rake task for IntelliJ IDEA project setup (#17771) * [build] Add java:local_dev rake task for IntelliJ IDEA project setup * [build] Scope java:local_dev to the resolved classpath and auto-derive CDP jars into java-libs * [build] Split IntelliJ java into release (JDK 11) and dev module for tests+tooling (JDK 17, SDK 25) * [build] Remove dangling rb/ruby.iml reference and orphan selenium.iml from IntelliJ project * [build] Ignore java/build and java-libs IDE output in .bazelignore --- .bazelignore | 3 +- .gitignore | 1 + .idea/libraries/java-libs.xml | 13 ++++++ .idea/libraries/libcdp.xml | 21 --------- .idea/misc.xml | 2 +- .idea/modules.xml | 2 +- java/java-dev.iml | 23 +++++++++ java/java.iml | 10 ++-- rake_tasks/java.rake | 88 +++++++++++++++++++++++++++++++++++ selenium.iml | 26 ----------- 10 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 .idea/libraries/java-libs.xml delete mode 100644 .idea/libraries/libcdp.xml create mode 100644 java/java-dev.iml delete mode 100644 selenium.iml diff --git a/.bazelignore b/.bazelignore index 5e7deeab6caa5..a3400f2832385 100644 --- a/.bazelignore +++ b/.bazelignore @@ -9,7 +9,8 @@ dotnet/src/support/bin dotnet/src/support/obj dotnet/src/webdriver/bin dotnet/src/webdriver/obj -java/build/production +java-libs +java/build java/client/build java/server/build javascript/atoms/node_modules diff --git a/.gitignore b/.gitignore index 36ec7206b1833..33e688dbd08d8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ projectFilesBackup/ .svn .credentials.dat .ijwb +/java-libs mockpiframe.log mockpiframe.log.lck junitvmwatcher*.properties diff --git a/.idea/libraries/java-libs.xml b/.idea/libraries/java-libs.xml new file mode 100644 index 0000000000000..d9bca89dcd39a --- /dev/null +++ b/.idea/libraries/java-libs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/.idea/libraries/libcdp.xml b/.idea/libraries/libcdp.xml deleted file mode 100644 index a4f8561db4e2a..0000000000000 --- a/.idea/libraries/libcdp.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/.idea/misc.xml b/.idea/misc.xml index fa12a2be3de04..63ea786e8bbf1 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 9811c78029378..558ff1ebc517a 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -4,10 +4,10 @@ + - diff --git a/java/java-dev.iml b/java/java-dev.iml new file mode 100644 index 0000000000000..605501dd9adfa --- /dev/null +++ b/java/java-dev.iml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/java.iml b/java/java.iml index dcdaca645e3e4..0477414f0cec9 100644 --- a/java/java.iml +++ b/java/java.iml @@ -5,20 +5,16 @@ - - + - - - - + - +