Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import io.appium.uiautomator2.model.CollectionItemInfo;
import io.appium.uiautomator2.model.internal.CustomUiDevice;
import io.appium.uiautomator2.model.internal.GestureController;
import io.appium.uiautomator2.model.settings.MapTestTagToResourceId;
import io.appium.uiautomator2.model.settings.Settings;
import io.appium.uiautomator2.model.settings.SimpleBoundsCalculation;
import io.appium.uiautomator2.model.settings.SnapshotMaxDepth;
Expand Down Expand Up @@ -102,6 +103,32 @@ public static boolean isVisible(@Nullable AccessibilityNodeInfo nodeInfo) {
return nodeInfo != null && nodeInfo.isVisibleToUser();
}

// https://developer.android.com/reference/kotlin/androidx/compose/ui/semantics/package-summary#(androidx.compose.ui.semantics.SemanticsPropertyReceiver).testTag()
private static final String COMPOSE_TEST_TAG_EXTRA_KEY = "androidx.compose.ui.semantics.testTag";

/**
* Returns the node's {@code resource-id}. If the {@link MapTestTagToResourceId} setting is
* enabled and the node carries a Jetpack Compose {@code testTag} semantics property (read
* from its extras), that value takes precedence over the node's real {@code resource-id} -
* matching the behavior of Compose's own {@code testTagsAsResourceId} property, which
* unconditionally overwrites {@code viewIdResourceName} with the {@code testTag} rather than
* only filling in a missing one. Otherwise falls back to the real {@code resource-id}.
*/
@Nullable
public static String getResourceId(@Nullable AccessibilityNodeInfo nodeInfo) {
if (nodeInfo == null) {
return null;
}
if (Settings.get(MapTestTagToResourceId.class).getValue()) {
Object testTag = nodeInfo.getExtras().get(COMPOSE_TEST_TAG_EXTRA_KEY);
if (testTag != null) {
return testTag.toString();
}
}
CharSequence resourceId = nodeInfo.getViewIdResourceName();
return resourceId != null && resourceId.length() > 0 ? resourceId.toString() : null;
}

public static boolean isCollection(@Nullable AccessibilityNodeInfo nodeInfo) {
return nodeInfo != null && nodeInfo.getCollectionInfo() != null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import java.util.UUID;

import io.appium.uiautomator2.core.AxNodeInfoHelper;
import io.appium.uiautomator2.core.UiAutomatorBridge;
import io.appium.uiautomator2.model.settings.CurrentDisplayId;
import io.appium.uiautomator2.model.settings.Settings;
Expand All @@ -52,9 +53,9 @@ public static BySelector toBySelector(@Nullable AccessibilityNodeInfo node) {
if (hasValue(pkg)) {
result = result == null ? By.pkg(pkg.toString()) : result.pkg(pkg.toString());
}
CharSequence res = node.getViewIdResourceName();
if (hasValue(res)) {
result = result == null ? By.res(res.toString()) : result.res(res.toString());
String res = AxNodeInfoHelper.getResourceId(node);
if (res != null) {
result = result == null ? By.res(res) : result.res(res);
}
CharSequence text = node.getText();
if (hasValue(text)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ private static void putAttribute(Map<Attribute, Object> attribs, Attribute key,
case PASSWORD:
return node.isPassword();
case RESOURCE_ID:
return node.getViewIdResourceName();
return AxNodeInfoHelper.getResourceId(node);
case SCROLLABLE:
return node.isScrollable();
case SELECTION_START: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public String getAttribute(String attr) throws UiObjectNotFoundException {
result = element.getClassName();
break;
case RESOURCE_ID:
result = element.getResourceName();
result = AxNodeInfoHelper.getResourceId(toAxNodeInfo(element));
break;
case CONTENT_SIZE:
result = ContentSizeHelpers.getContentSize(this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public String getAttribute(String attr) throws UiObjectNotFoundException {
result = element.getClassName();
break;
case RESOURCE_ID:
result = toAxNodeInfo(element).getViewIdResourceName();
result = AxNodeInfoHelper.getResourceId(toAxNodeInfo(element));
break;
case CONTENT_SIZE:
result = ContentSizeHelpers.getContentSize(this);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* 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 io.appium.uiautomator2.model.settings;

/**
* If enabled, Jetpack Compose's {@code testTag} semantics property (exposed in
* {@link android.view.accessibility.AccessibilityNodeInfo#getExtras()} under the key
* {@code androidx.compose.ui.semantics.testTag}) is used as the value of the
* {@code resource-id} attribute for any node that carries it, taking precedence over the
* node's real {@code resource-id} if it has one. This mirrors the exact behavior of Compose's
* own {@code testTagsAsResourceId} semantics property, which unconditionally overwrites
* {@code viewIdResourceName} with the {@code testTag} rather than only filling in a missing
* one. It exists because {@code testTagsAsResourceId} can only be set from within the
* application's own composable tree and cannot be toggled externally.
*/
public class MapTestTagToResourceId extends AbstractSetting<Boolean> {
private static final String SETTING_NAME = "mapTestTagToResourceId";
private static final boolean DEFAULT_VALUE = false;
private boolean mapTestTagToResourceId = DEFAULT_VALUE;

public MapTestTagToResourceId() {
super(Boolean.class, SETTING_NAME);
}

@Override
public Boolean getValue() {
return mapTestTagToResourceId;
}

@Override
public Boolean getDefaultValue() {
return DEFAULT_VALUE;
}

@Override
protected void apply(Boolean value) {
this.mapTestTagToResourceId = value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ public enum Settings {
SNAPSHOT_MAX_DEPTH(new SnapshotMaxDepth()),
CURRENT_DISPLAY_ID(new CurrentDisplayId()),
ALWAYS_TRAVERSABLE_VIEW_CLASSES(new AlwaysTraversableViewClasses()),
DEFER_ACCESSIBILITY_CACHE_RESET(new DeferAccessibilityCacheReset());
DEFER_ACCESSIBILITY_CACHE_RESET(new DeferAccessibilityCacheReset()),
MAP_TEST_TAG_TO_RESOURCE_ID(new MapTestTagToResourceId());

private final ISetting<?> setting;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import io.appium.uiautomator2.model.UiElementSnapshot;
import io.appium.uiautomator2.model.internal.CustomUiDevice;
import io.appium.uiautomator2.model.settings.DisableIdLocatorAutocompletion;
import io.appium.uiautomator2.model.settings.MapTestTagToResourceId;
import io.appium.uiautomator2.model.settings.Settings;

import static io.appium.uiautomator2.core.AxNodeInfoExtractor.toAxNodeInfo;
Expand Down Expand Up @@ -102,6 +103,52 @@ public static String rewriteIdLocator(By.ById by) {
return String.format("%s:id/%s", packageName, locator);
}

/**
* Builds an XPath expression that matches nodes by {@code resource-id}, going through the
* same attribute computation as {@link Attribute#RESOURCE_ID} (see
* {@code AxNodeInfoHelper.getResourceId}), where a Compose {@code testTag} unconditionally
* takes precedence over the node's real {@code resource-id} when the
* {@link MapTestTagToResourceId} setting is enabled. Used to align {@code By.ById} lookups
* with that precedence, since the native {@code UiSelector}/{@code BySelector} id matcher
* only ever sees the real {@code resource-id}.
* <p>
* {@code rawLocator} is matched as-is to support Compose {@code testTag}s, which - unlike
* resource ids - are never package-qualified by {@link #rewriteIdLocator}.
* {@code rewrittenLocator} (the output of {@link #rewriteIdLocator}) is matched too, so
* package-autocompleted lookups of real resource ids keep working; it is only added when it
* differs from {@code rawLocator}.
*/
static String resourceIdXPath(String rawLocator, String rewrittenLocator) {
return rawLocator.equals(rewrittenLocator)
? String.format(".//*[@resource-id=%s]", toXPathStringLiteral(rawLocator))
: String.format(".//*[@resource-id=%s or @resource-id=%s]",
toXPathStringLiteral(rawLocator), toXPathStringLiteral(rewrittenLocator));
}

/**
* Builds an XPath 1.0 string literal for the given value. XPath 1.0 has no escape
* mechanism for quote characters, so the value is wrapped in whichever quote character
* it does not contain; if it contains both, {@code concat()} is used to splice it back
* together around single-quote boundaries.
*/
static String toXPathStringLiteral(String value) {
if (!value.contains("'")) {
return "'" + value + "'";
}
if (!value.contains("\"")) {
return "\"" + value + "\"";
}
String[] parts = value.split("'", -1);
StringBuilder result = new StringBuilder("concat(");
for (int i = 0; i < parts.length; i++) {
if (i > 0) {
result.append(", \"'\", ");
}
result.append('\'').append(parts[i]).append('\'');
}
return result.append(')').toString();
}

private static Set<Attribute> extractQueriedAttributes(String xpathExpression) {
if (xpathExpression.contains("@*")) {
return new HashSet<>(Arrays.asList(UiElementSnapshot.SUPPORTED_ATTRIBUTES));
Expand Down Expand Up @@ -136,13 +183,55 @@ public static List<UiSelector> toSelectors(String uiaExpression) throws UiSelect
return selectors;
}

/**
* Shared {@code By.ById} handling for both {@link #findElement} overloads: rewrites the
* locator, and - if {@link MapTestTagToResourceId} is enabled - resolves it via the
* testTag-aware XPath lookup, throwing {@link ElementNotFoundException} on no match, exactly
* like the {@code By.ByXPath} branches. Otherwise falls back to the native id matcher,
* scoped to {@code context} when given.
*/
@Nullable
private static AccessibleUiObject findElementById(
By.ById by, @Nullable AndroidElement context) throws UiObjectNotFoundException {
String locator = rewriteIdLocator(by);
if (Settings.get(MapTestTagToResourceId.class).getValue()) {
final NodeInfoList matchedNodes = getXPathNodeMatch(
resourceIdXPath(by.getElementLocator(), locator), context, false);
if (matchedNodes.isEmpty()) {
throw new ElementNotFoundException();
}
return CustomUiDevice.getInstance().findObject(matchedNodes);
}
return context == null
? CustomUiDevice.getInstance().findObject(androidx.test.uiautomator.By.res(locator))
: context.getChild(androidx.test.uiautomator.By.res(locator));
}

/**
* Shared {@code By.ById} handling for both {@link #findElements} overloads. See
* {@link #findElementById} for the resolution logic; the only difference is that an empty
* match yields an empty list rather than throwing, matching the {@code By.ByXPath} branches.
*/
private static List<AccessibleUiObject> findElementsById(By.ById by, @Nullable AndroidElement context) {
String locator = rewriteIdLocator(by);
if (Settings.get(MapTestTagToResourceId.class).getValue()) {
final NodeInfoList matchedNodes = getXPathNodeMatch(
resourceIdXPath(by.getElementLocator(), locator), context, true);
return matchedNodes.isEmpty()
? Collections.<AccessibleUiObject>emptyList()
: CustomUiDevice.getInstance().findObjects(matchedNodes);
}
return context == null
? CustomUiDevice.getInstance().findObjects(androidx.test.uiautomator.By.res(locator))
: context.getChildren(androidx.test.uiautomator.By.res(locator), by);
}

@Nullable
public static AccessibleUiObject findElement(By by) throws UiObjectNotFoundException {
resetAccessibilityCache();

if (by instanceof By.ById) {
String locator = rewriteIdLocator((By.ById) by);
return CustomUiDevice.getInstance().findObject(androidx.test.uiautomator.By.res(locator));
return findElementById((By.ById) by, null);
} else if (by instanceof By.ByAccessibilityId) {
return CustomUiDevice.getInstance().findObject(androidx.test.uiautomator.By.desc(by.getElementLocator()));
} else if (by instanceof By.ByClass) {
Expand All @@ -165,8 +254,7 @@ public static AccessibleUiObject findElement(By by) throws UiObjectNotFoundExcep
@Nullable
public static AccessibleUiObject findElement(By by, AndroidElement context) throws UiObjectNotFoundException {
if (by instanceof By.ById) {
String locator = rewriteIdLocator((By.ById) by);
return context.getChild(androidx.test.uiautomator.By.res(locator));
return findElementById((By.ById) by, context);
} else if (by instanceof By.ByAccessibilityId) {
return context.getChild(androidx.test.uiautomator.By.desc(by.getElementLocator()));
} else if (by instanceof By.ByClass) {
Expand All @@ -190,8 +278,7 @@ public static List<AccessibleUiObject> findElements(By by) {
resetAccessibilityCache();

if (by instanceof By.ById) {
String locator = rewriteIdLocator((By.ById) by);
return CustomUiDevice.getInstance().findObjects(androidx.test.uiautomator.By.res(locator));
return findElementsById((By.ById) by, null);
} else if (by instanceof By.ByAccessibilityId) {
return CustomUiDevice.getInstance().findObjects(androidx.test.uiautomator.By.desc(by.getElementLocator()));
} else if (by instanceof By.ByClass) {
Expand All @@ -212,8 +299,7 @@ public static List<AccessibleUiObject> findElements(By by) {

public static List<AccessibleUiObject> findElements(By by, AndroidElement context) {
if (by instanceof By.ById) {
String locator = rewriteIdLocator((By.ById) by);
return context.getChildren(androidx.test.uiautomator.By.res(locator), by);
return findElementsById((By.ById) by, context);
} else if (by instanceof By.ByAccessibilityId) {
return context.getChildren(androidx.test.uiautomator.By.desc(by.getElementLocator()), by);
} else if (by instanceof By.ByClass) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* 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 io.appium.uiautomator2.model.settings;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

public class MapTestTagToResourceIdTest {

private MapTestTagToResourceId mapTestTagToResourceId;

@Before
public void setup() {
mapTestTagToResourceId = new MapTestTagToResourceId();
}

@Test
public void shouldBeBoolean() {
Assert.assertEquals(Boolean.class, mapTestTagToResourceId.getValueType());
}

@Test
public void shouldReturnValidSettingName() {
Assert.assertEquals("mapTestTagToResourceId", mapTestTagToResourceId.getName());
}

@Test
public void shouldBeDisabledByDefault() {
Assert.assertEquals(false, mapTestTagToResourceId.getValue());
}

@Test
public void shouldBeAbleToEnable() {
mapTestTagToResourceId.apply(true);
Assert.assertEquals(true, mapTestTagToResourceId.getValue());
}

@Test
public void shouldBeAbleToDisable() {
mapTestTagToResourceId.apply(true);
mapTestTagToResourceId.apply(false);
Assert.assertEquals(false, mapTestTagToResourceId.getValue());
}
}
Loading
Loading