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 c7c822ca442fc..f2d1a561ab748 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; @@ -56,6 +57,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) { @@ -75,6 +84,10 @@ public boolean matches(Capabilities stereotype, Capabilities capabilities) { return false; } + if (!automationNameMatch(stereotype, capabilities)) { + return false; + } + if (!platformVersionMatch(stereotype, capabilities)) { return false; } @@ -82,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()) @@ -98,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(":")) @@ -128,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())) { @@ -141,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 @@ -158,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 @@ -188,6 +200,39 @@ private Boolean extensionCapabilitiesMatch(Capabilities stereotype, Capabilities .orElse(true); } + Boolean automationNameMatch(Capabilities stereotype, Capabilities capabilities) { + /* + 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. + */ + Object requestedAutomationName = automationNameValue(capabilities); + if (requestedAutomationName == null) { + return true; + } + return Objects.equals(requestedAutomationName, automationNameValue(stereotype)); + } + + static Object automationNameValue(Capabilities capabilities) { + return capabilities.getCapabilityNames().stream() + .map(name -> automationNameValueFor(name, capabilities.getCapability(name))) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private static Object automationNameValueFor(String name, Object value) { + if (name.equals("automationName") || name.endsWith(":automationName")) { + return value; + } + // automationName is sometimes nested inside an options map (e.g. appium:options) rather + // than sent as its own top-level capability. + if (name.toLowerCase().contains("options") && value instanceof Map) { + return ((Map) value).get("automationName"); + } + return null; + } + 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/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 99ca4a4c722ef..81ea700fb2ffc 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; @@ -83,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( @@ -104,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( @@ -137,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 @@ -162,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( @@ -752,4 +752,69 @@ 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 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(); + } + + @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(); + } }