From 8e4d7083b73c20613a5f7381a50a25d926443775 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Mon, 10 Aug 2026 16:29:37 -0500 Subject: [PATCH 1/6] [grid] Fix DefaultSlotMatcher matching requests by undeclared automationName extensionCapabilitiesMatch() only inspected extension capability names the stereotype declared, so a stereotype declaring none at all (e.g. a plain browser node) matched any requested automationName by default. This let a native-automation request differentiated solely by automationName match an unrelated browser-only node. Add automationNameMatch(), gated on the stereotype showing some existing Appium-awareness (a relevant extension capability, or a non-W3C-compliant platformVersion), so relay-node matching keeps working while a stereotype with no such awareness no longer matches on an undeclared automationName. Fixes #17845 Co-Authored-By: Claude Sonnet 5 --- .../grid/data/DefaultSlotMatcher.java | 24 +++++++++++ .../grid/data/DefaultSlotMatcherTest.java | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java index c7c822ca442fc..4622abaee1d71 100644 --- a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +++ b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java @@ -75,6 +75,10 @@ public boolean matches(Capabilities stereotype, Capabilities capabilities) { return false; } + if (!automationNameMatch(stereotype, capabilities)) { + return false; + } + if (!platformVersionMatch(stereotype, capabilities)) { return false; } @@ -188,6 +192,26 @@ private Boolean extensionCapabilitiesMatch(Capabilities stereotype, Capabilities .orElse(true); } + private Boolean automationNameMatch(Capabilities stereotype, Capabilities capabilities) { + /* + A stereotype with no Appium-related capabilities at all has no relationship to a + requested automationName, so it should not match. + */ + boolean stereotypeIsAppiumAware = + stereotype.getCapabilityNames().stream() + .anyMatch( + name -> + name.contains("platformVersion") + || (name.contains(":") + && !name.toLowerCase().contains("options") + && EXTENSION_CAPABILITIES_PREFIXES.stream().noneMatch(name::contains))); + if (stereotypeIsAppiumAware) { + return true; + } + return capabilities.getCapabilityNames().stream() + .noneMatch(name -> name.contains("automationName")); + } + public static Boolean matchConditionToRemoveCapability(Capabilities capabilities) { /* This match is specific for the Relay capabilities that are related to the Appium server for native application. diff --git a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java index 99ca4a4c722ef..aa66b1d932299 100644 --- a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java +++ b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java @@ -752,4 +752,45 @@ void extensionCapsAlsoMatch() { assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); } + + @Test + void automationNameDoesNotMatchWhenStereotypeDeclaresNoExtensionCapabilities() { + /* + A plain browser stereotype must not match a request that only asks for automationName. + Regression test for https://github.com/SeleniumHQ/selenium/issues/17845 + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "MicrosoftEdge", + CapabilityType.PLATFORM_NAME, Platform.WIN10); + + Capabilities capabilities = + new ImmutableCapabilities( + "appium:automationName", "Windows", + CapabilityType.PLATFORM_NAME, Platform.WINDOWS); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); + } + + @Test + void automationNameStillMatchesForExtensionAwareStereotypeMissingAutomationName() { + /* + An Appium-aware stereotype may still match a request carrying automationName even if it + doesn't declare that capability itself. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); + + Capabilities capabilities = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, + Platform.ANDROID, + "appium:platformVersion", + "14", + "appium:automationName", + "uiautomator2"); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + } } From e62d62128e0d08c04fe4ffc9f3b548c6a249aab3 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Wed, 12 Aug 2026 09:59:50 -0500 Subject: [PATCH 2/6] [grid] Narrow the Appium-aware signal and fix CI formatting Fixes two issues found in CI/review of #17898: - Format / Check Format was failing on ImmutableCapabilities argument wrapping in the new tests; re-ran ./go format to match. - Qodo correctly flagged that treating any non-vendor extension capability as "Appium-aware" was too broad -- a node advertising an unrelated custom extension capability (e.g. prefixed:cheese) would incorrectly bypass the automationName gate. Narrow the signal to appium:-prefixed capabilities and platformVersion specifically, and tighten the automationName check to an exact/suffix match instead of a bare substring. Added a regression test for the narrowed case. Co-Authored-By: Claude Sonnet 5 --- .../grid/data/DefaultSlotMatcher.java | 10 ++---- .../grid/data/DefaultSlotMatcherTest.java | 31 ++++++++++++++++--- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java index 4622abaee1d71..9fd892ee3a9a2 100644 --- a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +++ b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java @@ -199,17 +199,13 @@ private Boolean automationNameMatch(Capabilities stereotype, Capabilities capabi */ boolean stereotypeIsAppiumAware = stereotype.getCapabilityNames().stream() - .anyMatch( - name -> - name.contains("platformVersion") - || (name.contains(":") - && !name.toLowerCase().contains("options") - && EXTENSION_CAPABILITIES_PREFIXES.stream().noneMatch(name::contains))); + .anyMatch(name -> name.contains("platformVersion") || name.startsWith("appium:")); if (stereotypeIsAppiumAware) { return true; } return capabilities.getCapabilityNames().stream() - .noneMatch(name -> name.contains("automationName")); + .noneMatch( + name -> name.equals("automationName") || name.endsWith(":automationName")); } public static Boolean matchConditionToRemoveCapability(Capabilities capabilities) { diff --git a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java index aa66b1d932299..04d1470238195 100644 --- a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java +++ b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java @@ -761,13 +761,14 @@ void automationNameDoesNotMatchWhenStereotypeDeclaresNoExtensionCapabilities() { */ Capabilities stereotype = new ImmutableCapabilities( - CapabilityType.BROWSER_NAME, "MicrosoftEdge", - CapabilityType.PLATFORM_NAME, Platform.WIN10); + CapabilityType.BROWSER_NAME, + "MicrosoftEdge", + CapabilityType.PLATFORM_NAME, + Platform.WIN10); Capabilities capabilities = new ImmutableCapabilities( - "appium:automationName", "Windows", - CapabilityType.PLATFORM_NAME, Platform.WINDOWS); + "appium:automationName", "Windows", CapabilityType.PLATFORM_NAME, Platform.WINDOWS); assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); } @@ -793,4 +794,26 @@ void automationNameStillMatchesForExtensionAwareStereotypeMissingAutomationName( assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); } + + @Test + void automationNameDoesNotMatchWhenStereotypeHasUnrelatedExtensionCapability() { + /* + A stereotype's unrelated, non-Appium extension capability must not be treated as a signal + that it can serve an automationName-differentiated request. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, + "MicrosoftEdge", + CapabilityType.PLATFORM_NAME, + Platform.WIN10, + "prefixed:cheese", + "amsterdam"); + + Capabilities capabilities = + new ImmutableCapabilities( + "appium:automationName", "Windows", CapabilityType.PLATFORM_NAME, Platform.WINDOWS); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); + } } From 97ccbef3f02d87b3ee56bf09b0e8933f286a5072 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Wed, 12 Aug 2026 10:12:37 -0500 Subject: [PATCH 3/6] [grid] Fix formatting in DefaultSlotMatcher google-java-format wants the noneMatch lambda collapsed onto one line; the previous manual edit split it across two. Co-Authored-By: Claude Sonnet 5 --- java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java index 9fd892ee3a9a2..d8e1b25bc383b 100644 --- a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +++ b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java @@ -204,8 +204,7 @@ private Boolean automationNameMatch(Capabilities stereotype, Capabilities capabi return true; } return capabilities.getCapabilityNames().stream() - .noneMatch( - name -> name.equals("automationName") || name.endsWith(":automationName")); + .noneMatch(name -> name.equals("automationName") || name.endsWith(":automationName")); } public static Boolean matchConditionToRemoveCapability(Capabilities capabilities) { From 0d2c0c848f8dd4cbecabfff97708ca0261a7ccb6 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Wed, 12 Aug 2026 10:51:41 -0500 Subject: [PATCH 4/6] [grid] Add Javadoc to DefaultSlotMatcher.matches() Per Qodo review feedback on #17898: document the public matches() method's parameters and return value, since its behavior changed with the new automationName gating. Co-Authored-By: Claude Sonnet 5 --- .../org/openqa/selenium/grid/data/DefaultSlotMatcher.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java index d8e1b25bc383b..58c578121f44c 100644 --- a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +++ b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java @@ -56,6 +56,14 @@ public class DefaultSlotMatcher implements SlotMatcher, Serializable { public static final List MANDATORY_CAPABILITIES = List.of("platformName", "browserName", "browserVersion"); + /** + * Determines whether {@code stereotype} is an acceptable match for a new session request carrying + * {@code capabilities}, per the class-level matching rules described above. + * + * @param stereotype the capabilities declared by a candidate {@link Slot} + * @param capabilities the capabilities requested for a new session + * @return {@code true} if the stereotype may serve the request + */ @Override public boolean matches(Capabilities stereotype, Capabilities capabilities) { From ae0e1bcbd785cf12f2fd9d156f45797c3a6f331e Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Wed, 12 Aug 2026 22:09:45 -0500 Subject: [PATCH 5/6] [grid] Detect automationName nested inside an options map Fixes a Qodo-flagged gap in #17898: automationNameMatch() only inspected top-level capability names, so a request nesting automationName inside an options map (e.g. appium:options) bypassed the new gate entirely, since Capabilities.getCapabilityNames() never flattens nested maps and this matcher already treats *options* capabilities as opaque elsewhere. Co-Authored-By: Claude Sonnet 5 --- .../grid/data/DefaultSlotMatcher.java | 14 +++++- .../grid/data/DefaultSlotMatcherTest.java | 46 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java index 58c578121f44c..effc665e51f4e 100644 --- a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +++ b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java @@ -21,6 +21,7 @@ import java.io.Serializable; import java.util.List; +import java.util.Map; import java.util.Objects; import org.openqa.selenium.Capabilities; @@ -212,7 +213,18 @@ private Boolean automationNameMatch(Capabilities stereotype, Capabilities capabi return true; } return capabilities.getCapabilityNames().stream() - .noneMatch(name -> name.equals("automationName") || name.endsWith(":automationName")); + .noneMatch(name -> requestsAutomationName(name, capabilities.getCapability(name))); + } + + private boolean requestsAutomationName(String name, Object value) { + if (name.equals("automationName") || name.endsWith(":automationName")) { + return true; + } + // automationName is sometimes nested inside an options map (e.g. appium:options) rather + // than sent as its own top-level capability. + return name.toLowerCase().contains("options") + && value instanceof Map + && ((Map) value).containsKey("automationName"); } public static Boolean matchConditionToRemoveCapability(Capabilities capabilities) { diff --git a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java index 04d1470238195..e1c351becb283 100644 --- a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java +++ b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java @@ -20,6 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.openqa.selenium.remote.CapabilityType.ENABLE_DOWNLOADS; +import java.util.Map; import org.junit.jupiter.api.Test; import org.openqa.selenium.Capabilities; import org.openqa.selenium.ImmutableCapabilities; @@ -816,4 +817,49 @@ void automationNameDoesNotMatchWhenStereotypeHasUnrelatedExtensionCapability() { assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); } + + @Test + void automationNameDoesNotMatchWhenNestedInsideOptionsMap() { + /* + A plain browser stereotype must not match a request that nests automationName inside an + options map (e.g. appium:options) instead of sending it as a top-level capability. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, + "MicrosoftEdge", + CapabilityType.PLATFORM_NAME, + Platform.WIN10); + + Capabilities capabilities = + new ImmutableCapabilities( + "appium:options", + Map.of("automationName", "Windows"), + CapabilityType.PLATFORM_NAME, + Platform.WINDOWS); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); + } + + @Test + void automationNameStillMatchesWhenNestedInsideOptionsMapForAppiumAwareStereotype() { + /* + An Appium-aware stereotype may still match a request nesting automationName inside an + options map, consistent with existing relay-node matching behavior. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); + + Capabilities capabilities = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, + Platform.ANDROID, + "appium:platformVersion", + "14", + "appium:options", + Map.of("automationName", "uiautomator2")); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + } } From aa3fdcd9e41d8e2aa649be2185d1ae09219065e0 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Tue, 18 Aug 2026 00:17:37 -0500 Subject: [PATCH 6/6] [java] Make DefaultSlotMatcher automationName parity strict, add opt-in AppiumRelaySlotMatcher DefaultSlotMatcher.automationNameMatch() now requires the stereotype to declare the same automationName the request asks for, unconditionally -- no more exception for stereotypes that merely look Appium-aware. The matching browserName/browserVersion bypass for app-relay capabilities is removed from the default matcher too. Relay-node operators who relied on that leniency can opt back into it via the new AppiumRelaySlotMatcher (config: distributor.slot-matcher), which composes DefaultSlotMatcher's package-private checks and layers the Appium-aware automationName gate and app-relay browserName/browserVersion bypass on top. Co-Authored-By: Claude Sonnet 5 --- .../grid/data/AppiumRelaySlotMatcher.java | 103 ++++++++++ .../grid/data/DefaultSlotMatcher.java | 50 ++--- .../grid/data/AppiumRelaySlotMatcherTest.java | 176 ++++++++++++++++++ .../grid/data/DefaultSlotMatcherTest.java | 71 ++----- 4 files changed, 320 insertions(+), 80 deletions(-) create mode 100644 java/src/org/openqa/selenium/grid/data/AppiumRelaySlotMatcher.java create mode 100644 java/test/org/openqa/selenium/grid/data/AppiumRelaySlotMatcherTest.java diff --git a/java/src/org/openqa/selenium/grid/data/AppiumRelaySlotMatcher.java b/java/src/org/openqa/selenium/grid/data/AppiumRelaySlotMatcher.java new file mode 100644 index 0000000000000..fcb51d62e0b28 --- /dev/null +++ b/java/src/org/openqa/selenium/grid/data/AppiumRelaySlotMatcher.java @@ -0,0 +1,103 @@ +// 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.data; + +import java.io.Serializable; +import java.util.Objects; +import org.openqa.selenium.Capabilities; + +/** + * Opt-in matching implementation for Nodes that relay sessions to an Appium server. Unlike {@link + * DefaultSlotMatcher}, a stereotype that advertises Appium-awareness (an {@code appium:}-prefixed + * capability, or the non-W3C {@code platformVersion} signal) is treated as a wildcard for + * automationName, and a request carrying app-relay capabilities ({@link + * DefaultSlotMatcher#SPECIFIC_RELAY_CAPABILITIES_APP}) bypasses browserName/browserVersion + * matching. This lets a single relay slot serve varied automation frameworks and hybrid + * browser/native-app requests without the operator enumerating every client value in the + * stereotype. + * + *

Configure a Node to use this matcher instead of the default with: + * + *

+ * [distributor]
+ * slot-matcher = "org.openqa.selenium.grid.data.AppiumRelaySlotMatcher"
+ * 
+ */ +public class AppiumRelaySlotMatcher implements SlotMatcher, Serializable { + + private final DefaultSlotMatcher strict = new DefaultSlotMatcher(); + + @Override + public boolean matches(Capabilities stereotype, Capabilities capabilities) { + + if (capabilities.asMap().isEmpty()) { + return false; + } + + if (!strict.initialMatch(stereotype, capabilities)) { + return false; + } + + if (!strict.managedDownloadsEnabled(stereotype, capabilities)) { + return false; + } + + if (!strict.extensionCapabilitiesMatch(stereotype, capabilities)) { + return false; + } + + if (!automationNameMatch(stereotype, capabilities)) { + return false; + } + + if (!strict.platformVersionMatch(stereotype, capabilities)) { + return false; + } + + boolean browserNameMatch = + (capabilities.getBrowserName() == null || capabilities.getBrowserName().isEmpty()) + || Objects.equals(stereotype.getBrowserName(), capabilities.getBrowserName()) + || DefaultSlotMatcher.matchConditionToRemoveCapability(capabilities); + boolean browserVersionMatch = + (capabilities.getBrowserVersion() == null + || capabilities.getBrowserVersion().isEmpty() + || Objects.equals(capabilities.getBrowserVersion(), "stable")) + || strict.browserVersionMatch( + stereotype.getBrowserVersion(), capabilities.getBrowserVersion()) + || DefaultSlotMatcher.matchConditionToRemoveCapability(capabilities); + boolean platformNameMatch = + capabilities.getPlatformName() == null + || Objects.equals(stereotype.getPlatformName(), capabilities.getPlatformName()) + || (stereotype.getPlatformName() != null + && stereotype.getPlatformName().is(capabilities.getPlatformName())); + return browserNameMatch && browserVersionMatch && platformNameMatch; + } + + private boolean automationNameMatch(Capabilities stereotype, Capabilities capabilities) { + /* + A stereotype with no Appium-related capabilities at all has no relationship to a + requested automationName, so it should not match. Otherwise, an Appium-aware + stereotype is allowed to omit automationName and still match, since relay + stereotypes intentionally do this to serve varied automation sessions. + */ + boolean stereotypeIsAppiumAware = + stereotype.getCapabilityNames().stream() + .anyMatch(name -> name.contains("platformVersion") || name.startsWith("appium:")); + return stereotypeIsAppiumAware || DefaultSlotMatcher.automationNameValue(capabilities) == null; + } +} diff --git a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java index effc665e51f4e..f2d1a561ab748 100644 --- a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +++ b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java @@ -95,14 +95,13 @@ public boolean matches(Capabilities stereotype, Capabilities capabilities) { // At the end, a simple browser, browserVersion and platformName match boolean browserNameMatch = (capabilities.getBrowserName() == null || capabilities.getBrowserName().isEmpty()) - || Objects.equals(stereotype.getBrowserName(), capabilities.getBrowserName()) - || matchConditionToRemoveCapability(capabilities); + || Objects.equals(stereotype.getBrowserName(), capabilities.getBrowserName()); boolean browserVersionMatch = (capabilities.getBrowserVersion() == null || capabilities.getBrowserVersion().isEmpty() || Objects.equals(capabilities.getBrowserVersion(), "stable")) - || browserVersionMatch(stereotype.getBrowserVersion(), capabilities.getBrowserVersion()) - || matchConditionToRemoveCapability(capabilities); + || browserVersionMatch( + stereotype.getBrowserVersion(), capabilities.getBrowserVersion()); boolean platformNameMatch = capabilities.getPlatformName() == null || Objects.equals(stereotype.getPlatformName(), capabilities.getPlatformName()) @@ -111,11 +110,11 @@ public boolean matches(Capabilities stereotype, Capabilities capabilities) { return browserNameMatch && browserVersionMatch && platformNameMatch; } - private boolean browserVersionMatch(String stereotype, String capabilities) { + boolean browserVersionMatch(String stereotype, String capabilities) { return new SemanticVersionComparator().compare(stereotype, capabilities) == 0; } - private Boolean initialMatch(Capabilities stereotype, Capabilities capabilities) { + Boolean initialMatch(Capabilities stereotype, Capabilities capabilities) { return stereotype.getCapabilityNames().stream() // Matching of extension capabilities is implementation independent. Skip them .filter(name -> !name.contains(":")) @@ -141,7 +140,7 @@ private Boolean initialMatch(Capabilities stereotype, Capabilities capabilities) .orElse(true); } - private Boolean managedDownloadsEnabled(Capabilities stereotype, Capabilities capabilities) { + Boolean managedDownloadsEnabled(Capabilities stereotype, Capabilities capabilities) { // First lets check if user wanted a Node with managed downloads enabled Object raw = capabilities.getCapability(ENABLE_DOWNLOADS); if (raw == null || !Boolean.parseBoolean(raw.toString())) { @@ -154,7 +153,7 @@ private Boolean managedDownloadsEnabled(Capabilities stereotype, Capabilities ca return raw != null && Boolean.parseBoolean(raw.toString()); } - private Boolean platformVersionMatch(Capabilities stereotype, Capabilities capabilities) { + Boolean platformVersionMatch(Capabilities stereotype, Capabilities capabilities) { /* This platform version match is not W3C compliant but users can add Appium servers as Nodes, so we avoid delaying the match until the Slot, which makes the whole matching @@ -171,7 +170,7 @@ private Boolean platformVersionMatch(Capabilities stereotype, Capabilities capab .orElse(true); } - private Boolean extensionCapabilitiesMatch(Capabilities stereotype, Capabilities capabilities) { + Boolean extensionCapabilitiesMatch(Capabilities stereotype, Capabilities capabilities) { /* We match extension capabilities when they are not prefixed with any of the EXTENSION_CAPABILITIES_PREFIXES items. Also, we match them only when the capabilities @@ -201,30 +200,37 @@ private Boolean extensionCapabilitiesMatch(Capabilities stereotype, Capabilities .orElse(true); } - private Boolean automationNameMatch(Capabilities stereotype, Capabilities capabilities) { + Boolean automationNameMatch(Capabilities stereotype, Capabilities capabilities) { /* - A stereotype with no Appium-related capabilities at all has no relationship to a - requested automationName, so it should not match. + If the request specifies automationName (directly or nested in an options map), the + stereotype must declare the same value -- including the case where the stereotype + doesn't declare it at all. See https://github.com/SeleniumHQ/selenium/issues/17845. */ - boolean stereotypeIsAppiumAware = - stereotype.getCapabilityNames().stream() - .anyMatch(name -> name.contains("platformVersion") || name.startsWith("appium:")); - if (stereotypeIsAppiumAware) { + Object requestedAutomationName = automationNameValue(capabilities); + if (requestedAutomationName == null) { return true; } + return Objects.equals(requestedAutomationName, automationNameValue(stereotype)); + } + + static Object automationNameValue(Capabilities capabilities) { return capabilities.getCapabilityNames().stream() - .noneMatch(name -> requestsAutomationName(name, capabilities.getCapability(name))); + .map(name -> automationNameValueFor(name, capabilities.getCapability(name))) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } - private boolean requestsAutomationName(String name, Object value) { + private static Object automationNameValueFor(String name, Object value) { if (name.equals("automationName") || name.endsWith(":automationName")) { - return true; + return value; } // automationName is sometimes nested inside an options map (e.g. appium:options) rather // than sent as its own top-level capability. - return name.toLowerCase().contains("options") - && value instanceof Map - && ((Map) value).containsKey("automationName"); + if (name.toLowerCase().contains("options") && value instanceof Map) { + return ((Map) value).get("automationName"); + } + return null; } public static Boolean matchConditionToRemoveCapability(Capabilities capabilities) { diff --git a/java/test/org/openqa/selenium/grid/data/AppiumRelaySlotMatcherTest.java b/java/test/org/openqa/selenium/grid/data/AppiumRelaySlotMatcherTest.java new file mode 100644 index 0000000000000..b3e3734cd62dc --- /dev/null +++ b/java/test/org/openqa/selenium/grid/data/AppiumRelaySlotMatcherTest.java @@ -0,0 +1,176 @@ +// 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.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.ImmutableCapabilities; +import org.openqa.selenium.Platform; +import org.openqa.selenium.remote.CapabilityType; + +class AppiumRelaySlotMatcherTest { + + private final AppiumRelaySlotMatcher slotMatcher = new AppiumRelaySlotMatcher(); + + @Test + void plainBrowserStereotypeStillRequiresExplicitParity() { + /* + Ordinary, non-relay matching is unaffected: ambient leniency only kicks in for + stereotypes that show Appium-awareness or requests carrying app-relay capabilities. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome", CapabilityType.PLATFORM_NAME, Platform.LINUX); + Capabilities matchingCapabilities = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome", CapabilityType.PLATFORM_NAME, Platform.LINUX); + Capabilities mismatchedCapabilities = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "firefox", CapabilityType.PLATFORM_NAME, Platform.LINUX); + + assertThat(slotMatcher.matches(stereotype, matchingCapabilities)).isTrue(); + assertThat(slotMatcher.matches(stereotype, mismatchedCapabilities)).isFalse(); + } + + @Test + void automationNameDoesNotMatchWhenStereotypeIsNotAppiumAware() { + /* + Regression test for https://github.com/SeleniumHQ/selenium/issues/17845: a plain browser + stereotype with no Appium signal at all must still reject an automationName-differentiated + request, same as DefaultSlotMatcher. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, + "MicrosoftEdge", + CapabilityType.PLATFORM_NAME, + Platform.WIN10); + + Capabilities capabilities = + new ImmutableCapabilities( + "appium:automationName", "Windows", CapabilityType.PLATFORM_NAME, Platform.WINDOWS); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); + } + + @Test + void automationNameMatchesForAppiumAwareStereotypeMissingAutomationName() { + /* + An Appium-aware stereotype (declaring appium:platformVersion) may match a request carrying + automationName even though it doesn't declare that capability itself -- this is the + behavior relay-node operators opt into by choosing this matcher. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); + + Capabilities capabilities = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, + Platform.ANDROID, + "appium:platformVersion", + "14", + "appium:automationName", + "uiautomator2"); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + } + + @Test + void automationNameMatchesWhenNestedInsideOptionsMapForAppiumAwareStereotype() { + /* + An Appium-aware stereotype may still match a request nesting automationName inside an + options map (e.g. appium:options), consistent with top-level automationName handling. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); + + Capabilities capabilities = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, + Platform.ANDROID, + "appium:platformVersion", + "14", + "appium:options", + Map.of("automationName", "uiautomator2")); + + assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + } + + @Test + void relayNodeMatchesByBypassingBrowserNameWhenAppSet() { + /* + Relay node stereotype does not declare browserName (operator wants to restrict the slot to + native-app-only sessions). A request carrying both browserName (e.g. initialized via + ChromeOptions) and an app capability still matches -- the browserName is disregarded. + */ + Capabilities stereotype = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); + Capabilities capabilities = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, + "chrome", + CapabilityType.PLATFORM_NAME, + Platform.ANDROID, + "appium:platformVersion", + "14", + "appium:app", + "link.to.apk", + "appium:automationName", + "uiautomator2"); + assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + } + + @Test + void relayNodeRequiresBrowserNameWhenAppNotSet() { + /* + Relay node 1's stereotype does not declare browserName. A hybrid request (browserName set, + no app capability) must not match it -- the browserName bypass only applies when an + app-relay capability is present. Relay node 2's stereotype declares browserName and matches. + */ + Capabilities stereotype1 = + new ImmutableCapabilities( + CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); + Capabilities capabilities = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, + "chrome", + CapabilityType.PLATFORM_NAME, + Platform.ANDROID, + "appium:platformVersion", + "14", + "appium:automationName", + "uiautomator2"); + assertThat(slotMatcher.matches(stereotype1, capabilities)).isFalse(); + + Capabilities stereotype2 = + new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, + "chrome", + CapabilityType.PLATFORM_NAME, + Platform.ANDROID, + "appium:platformVersion", + "14"); + assertThat(slotMatcher.matches(stereotype2, capabilities)).isTrue(); + } +} diff --git a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java index e1c351becb283..81ea700fb2ffc 100644 --- a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java +++ b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java @@ -84,11 +84,12 @@ public void testSpecificRelayCapabilitiesAppMatch() { } @Test - public void testRelayNodeMatchByRemovingBrowserNameWhenAppSet() { + public void testRelayNodeDoesNotBypassBrowserNameWhenAppSet() { /* - Relay node stereotype does not have browserName (where user wants to restrict to run a native app only) - Request capabilities have both browserName (it might initialize by ChromeOptions) and app set - The browserName will be filter out when validating match + DefaultSlotMatcher requires explicit parity: a stereotype that doesn't declare browserName + or automationName must not match a request carrying app-relay capabilities. The old + "filter out browserName when app is set" leniency now lives in AppiumRelaySlotMatcher, + which relay-node operators can opt into via the "slot-matcher" config. */ Capabilities stereotype = new ImmutableCapabilities( @@ -105,16 +106,16 @@ Request capabilities have both browserName (it might initialize by ChromeOptions "link.to.apk", "appium:automationName", "uiautomator2"); - assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); } @Test - public void testRelayNodeNotMatchHybridBrowserVersionWhenStereotypeWithoutBrowserName() { + public void testRelayNodeRequiresExplicitAutomationNameParity() { /* - Relay node 1 has stereotype does not have browserName (where user wants to restrict to run a native app only) - Request capabilities want to run a hybrid app (browserName is set) and app isn't set - Request capabilities should not match the stereotype - Relay node 2 has stereotype with browserName set should match the request capabilities + Neither relay stereotype declares automationName, so neither matches a hybrid request that + specifies it -- even the stereotype declaring browserName. Unlike the old gated behavior, + being Appium-aware (declaring appium:platformVersion) is not enough on its own; the + stereotype must declare the same automationName the request asks for. */ Capabilities stereotype1 = new ImmutableCapabilities( @@ -138,7 +139,7 @@ Request capabilities want to run a hybrid app (browserName is set) and app isn't Platform.ANDROID, "appium:platformVersion", "14"); - assertThat(slotMatcher.matches(stereotype2, capabilities)).isTrue(); + assertThat(slotMatcher.matches(stereotype2, capabilities)).isFalse(); } @Test @@ -163,9 +164,7 @@ public void testRelayNodeNotMatchWhenNonW3CCompliantPlatformVersionSet() { CapabilityType.PLATFORM_NAME, Platform.ANDROID, "platformVersion", - "15", - "appium:automationName", - "uiautomator2"); + "15"); assertThat(slotMatcher.matches(stereotype1, capabilities)).isFalse(); Capabilities stereotype2 = new ImmutableCapabilities( @@ -774,28 +773,6 @@ void automationNameDoesNotMatchWhenStereotypeDeclaresNoExtensionCapabilities() { assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); } - @Test - void automationNameStillMatchesForExtensionAwareStereotypeMissingAutomationName() { - /* - An Appium-aware stereotype may still match a request carrying automationName even if it - doesn't declare that capability itself. - */ - Capabilities stereotype = - new ImmutableCapabilities( - CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); - - Capabilities capabilities = - new ImmutableCapabilities( - CapabilityType.PLATFORM_NAME, - Platform.ANDROID, - "appium:platformVersion", - "14", - "appium:automationName", - "uiautomator2"); - - assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); - } - @Test void automationNameDoesNotMatchWhenStereotypeHasUnrelatedExtensionCapability() { /* @@ -840,26 +817,4 @@ options map (e.g. appium:options) instead of sending it as a top-level capabilit assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); } - - @Test - void automationNameStillMatchesWhenNestedInsideOptionsMapForAppiumAwareStereotype() { - /* - An Appium-aware stereotype may still match a request nesting automationName inside an - options map, consistent with existing relay-node matching behavior. - */ - Capabilities stereotype = - new ImmutableCapabilities( - CapabilityType.PLATFORM_NAME, Platform.ANDROID, "appium:platformVersion", "14"); - - Capabilities capabilities = - new ImmutableCapabilities( - CapabilityType.PLATFORM_NAME, - Platform.ANDROID, - "appium:platformVersion", - "14", - "appium:options", - Map.of("automationName", "uiautomator2")); - - assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); - } }