Skip to content

Commit 8de9a86

Browse files
committed
Update description action resolver
1 parent 806c70e commit 8de9a86

3 files changed

Lines changed: 483 additions & 76 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* SPDX-License-Identifier: BSD-3-Clause
3+
* Copyright (c) 2026 Universitat Politecnica de Valencia - www.upv.es
4+
* Copyright (c) 2026 Open Universiteit - www.ou.nl
5+
*/
6+
7+
package org.testar.android.action;
8+
9+
import java.util.Collections;
10+
import java.util.List;
11+
12+
import org.junit.Assert;
13+
import org.junit.Test;
14+
import org.testar.android.tag.AndroidTags;
15+
import org.testar.core.action.Action;
16+
import org.testar.core.action.resolver.ResolvedAction;
17+
import org.testar.core.alayer.Rect;
18+
import org.testar.core.tag.Tags;
19+
import org.testar.engine.action.resolver.DescriptionActionResolver;
20+
import org.testar.stub.StateStub;
21+
import org.testar.stub.WidgetStub;
22+
23+
public class AndroidTypeActionResolutionTest {
24+
25+
@Test
26+
public void testResolvedAndroidTypeActionPreservesIdentityTags() {
27+
StateStub state = new StateStub();
28+
WidgetStub widget = new WidgetStub();
29+
state.addChild(widget);
30+
widget.setParent(state);
31+
widget.set(Tags.Shape, Rect.fromCoordinates(0, 0, 100, 100));
32+
widget.set(AndroidTags.AndroidHint, "Enter your username");
33+
widget.set(AndroidTags.AndroidClassName, "android.widget.EditText");
34+
widget.set(AndroidTags.AndroidXpath, "/hierarchy/android.widget.EditText");
35+
36+
Action templateAction = new AndroidActionType(state, widget, "RandomInput");
37+
templateAction.set(Tags.AbstractID, "AAtype123");
38+
templateAction.set(Tags.ConcreteID, "CCtype123");
39+
40+
DescriptionActionResolver resolver = new DescriptionActionResolver();
41+
ResolvedAction resolvedAction = resolver.resolve(
42+
Collections.singleton(templateAction),
43+
List.of("type", "Enter your username", "testar")
44+
);
45+
46+
Assert.assertTrue(resolvedAction.action() instanceof AndroidActionType);
47+
Assert.assertEquals("AAtype123", resolvedAction.action().get(Tags.AbstractID, ""));
48+
Assert.assertEquals("CCtype123", resolvedAction.action().get(Tags.ConcreteID, ""));
49+
Assert.assertEquals("testar", resolvedAction.action().get(Tags.InputText, ""));
50+
}
51+
}

engine/src/org/testar/engine/action/resolver/DescriptionActionResolver.java

Lines changed: 165 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
import java.util.List;
1212
import java.util.Locale;
1313
import java.util.Optional;
14+
import java.util.function.Predicate;
1415

1516
import org.testar.core.action.Action;
1617
import org.testar.core.action.ActionRoles;
1718
import org.testar.core.action.AnnotatingActionCompiler;
1819
import org.testar.core.action.resolver.ActionResolver;
1920
import org.testar.core.action.resolver.ResolvedAction;
2021
import org.testar.core.alayer.Role;
22+
import org.testar.core.state.State;
2123
import org.testar.core.state.Widget;
2224
import org.testar.core.tag.Tags;
2325

@@ -30,46 +32,46 @@ public final class DescriptionActionResolver implements ActionResolver {
3032
@Override
3133
public ResolvedAction resolve(Iterable<Action> actions, List<String> arguments) {
3234
List<Action> orderedActions = orderedActions(actions);
35+
String mode = normalizedMode(arguments);
36+
String semanticText = requiredSemanticText(arguments, mode);
37+
38+
switch (mode) {
39+
case "click":
40+
return new ResolvedAction(resolveClickAction(orderedActions, semanticText));
41+
case "type":
42+
return new ResolvedAction(resolveTypeAction(orderedActions, semanticText, requiredInput(arguments, mode)));
43+
case "select":
44+
return new ResolvedAction(resolveSelectAction(orderedActions, semanticText, requiredInput(arguments, mode)));
45+
default:
46+
throw new IllegalArgumentException("Unsupported executeAction mode: " + mode);
47+
}
48+
}
49+
50+
private String normalizedMode(List<String> arguments) {
3351
String selector = argumentAt(arguments, 0);
3452
if (selector == null) {
3553
throw new IllegalArgumentException("executeAction requires an action selector");
3654
}
55+
return selector.toLowerCase(Locale.ROOT);
56+
}
3757

38-
String mode = selector.toLowerCase(Locale.ROOT);
58+
private String requiredSemanticText(List<String> arguments, String mode) {
3959
String semanticText = argumentAt(arguments, 1);
4060
if (semanticText == null || semanticText.isBlank()) {
4161
throw new IllegalArgumentException("executeAction " + mode + " requires semantic text");
4262
}
63+
return semanticText;
64+
}
4365

44-
switch (mode) {
45-
case "click":
46-
return findSemanticAction(orderedActions, semanticText, false, false)
47-
.orElseThrow(() -> new IllegalArgumentException(
48-
"no click action matched semantic text: " + semanticText
49-
));
50-
case "type":
51-
String inputText = joinArguments(arguments, 2);
52-
if (inputText == null || inputText.isBlank()) {
53-
throw new IllegalArgumentException("executeAction type requires input text");
54-
}
55-
ResolvedAction matchedAction = findSemanticAction(orderedActions, semanticText, true, false)
56-
.orElseThrow(() -> new IllegalArgumentException(
57-
"no type action matched semantic text: " + semanticText
58-
));
59-
return new ResolvedAction(buildTypedAction(matchedAction.action(), inputText));
60-
case "select":
61-
String selectedValue = joinArguments(arguments, 2);
62-
if (selectedValue == null || selectedValue.isBlank()) {
63-
throw new IllegalArgumentException("executeAction select requires a value");
64-
}
65-
ResolvedAction matchedSelectAction = findSemanticAction(orderedActions, semanticText, false, true)
66-
.orElseThrow(() -> new IllegalArgumentException(
67-
"no select action matched semantic text: " + semanticText
68-
));
69-
return new ResolvedAction(buildSelectAction(matchedSelectAction.action(), selectedValue));
70-
default:
71-
throw new IllegalArgumentException("Unsupported executeAction mode: " + selector);
66+
private String requiredInput(List<String> arguments, String mode) {
67+
String input = joinArguments(arguments, 2);
68+
if (input == null || input.isBlank()) {
69+
if ("type".equals(mode)) {
70+
throw new IllegalArgumentException("executeAction type requires input text");
71+
}
72+
throw new IllegalArgumentException("executeAction " + mode + " requires a value");
7273
}
74+
return input;
7375
}
7476

7577
private List<Action> orderedActions(Iterable<Action> actions) {
@@ -81,27 +83,69 @@ private List<Action> orderedActions(Iterable<Action> actions) {
8183
return orderedActions;
8284
}
8385

84-
private Optional<ResolvedAction> findSemanticAction(List<Action> orderedActions,
85-
String semanticText,
86-
boolean typeAction,
87-
boolean selectAction) {
86+
private Action resolveClickAction(List<Action> orderedActions, String semanticText) {
87+
return findRequiredAction(
88+
orderedActions,
89+
semanticText,
90+
this::isClickAction,
91+
"no click action matched semantic text: " + semanticText
92+
);
93+
}
94+
95+
private Action resolveTypeAction(List<Action> orderedActions, String semanticText, String inputText) {
96+
Action templateAction = findRequiredAction(
97+
orderedActions,
98+
semanticText,
99+
this::isTypeAction,
100+
"no type action matched semantic text: " + semanticText
101+
);
102+
return buildTypedAction(templateAction, inputText);
103+
}
104+
105+
private Action resolveSelectAction(List<Action> orderedActions, String semanticText, String selectedValue) {
106+
Action templateAction = findRequiredAction(
107+
orderedActions,
108+
semanticText,
109+
this::isSelectAction,
110+
"no select action matched semantic text: " + semanticText
111+
);
112+
return buildSelectAction(templateAction, selectedValue);
113+
}
114+
115+
private Action findRequiredAction(List<Action> orderedActions,
116+
String semanticText,
117+
Predicate<Action> actionFilter,
118+
String errorMessage) {
119+
return findSemanticAction(orderedActions, semanticText, actionFilter)
120+
.orElseThrow(() -> new IllegalArgumentException(errorMessage));
121+
}
122+
123+
private Optional<Action> findSemanticAction(List<Action> orderedActions,
124+
String semanticText,
125+
Predicate<Action> actionFilter) {
88126
String normalizedSemanticText = normalizeText(semanticText);
89-
// Equals semantic
90-
for (int index = 0; index < orderedActions.size(); index++) {
91-
Action action = orderedActions.get(index);
92-
if (matchesAction(action, typeAction, selectAction)) {
93-
if (normalizeText(action.get(Tags.Desc, action.toString())).equals(normalizedSemanticText)) {
94-
return Optional.of(new ResolvedAction(action));
95-
}
96-
}
127+
Optional<Action> exactMatch = findMatchingAction(
128+
orderedActions,
129+
actionFilter,
130+
action -> normalizedDescription(action).equals(normalizedSemanticText)
131+
);
132+
if (exactMatch.isPresent()) {
133+
return exactMatch;
97134
}
98-
// Contains semantic
99-
for (int index = 0; index < orderedActions.size(); index++) {
100-
Action action = orderedActions.get(index);
101-
if (matchesAction(action, typeAction, selectAction)) {
102-
if (normalizeText(action.get(Tags.Desc, action.toString())).contains(normalizedSemanticText)) {
103-
return Optional.of(new ResolvedAction(action));
104-
}
135+
136+
return findMatchingAction(
137+
orderedActions,
138+
actionFilter,
139+
action -> normalizedDescription(action).contains(normalizedSemanticText)
140+
);
141+
}
142+
143+
private Optional<Action> findMatchingAction(List<Action> orderedActions,
144+
Predicate<Action> actionFilter,
145+
Predicate<Action> semanticMatcher) {
146+
for (Action action : orderedActions) {
147+
if (actionFilter.test(action) && semanticMatcher.test(action)) {
148+
return Optional.of(action);
105149
}
106150
}
107151
return Optional.empty();
@@ -114,12 +158,20 @@ private Action buildTypedAction(Action templateAction, String inputText) {
114158
return templateAction;
115159
}
116160

161+
Action rebuiltPlatformAction = rebuildTypedActionWithSameClass(templateAction, originWidget, inputText);
162+
if (rebuiltPlatformAction != null) {
163+
return withCopiedIdentity(templateAction, rebuiltPlatformAction);
164+
}
165+
117166
Role role = templateAction.get(Tags.Role, null);
118167
if (role == ActionRoles.ClickTypeInto) {
119168
if (originWidget == null) {
120169
throw new IllegalArgumentException("Matched type action does not have an origin widget");
121170
}
122-
return new AnnotatingActionCompiler().clickTypeInto(originWidget, inputText, true);
171+
return withCopiedIdentity(
172+
templateAction,
173+
new AnnotatingActionCompiler().clickTypeInto(originWidget, inputText, true)
174+
);
123175
}
124176

125177
throw new IllegalArgumentException(
@@ -128,32 +180,64 @@ private Action buildTypedAction(Action templateAction, String inputText) {
128180
);
129181
}
130182

183+
private Action rebuildTypedActionWithSameClass(Action templateAction, Widget originWidget, String inputText) {
184+
if (originWidget == null) {
185+
return null;
186+
}
187+
188+
Widget rootWidget = originWidget.root();
189+
if (!(rootWidget instanceof State)) {
190+
return null;
191+
}
192+
193+
try {
194+
return (Action) templateAction.getClass()
195+
.getConstructor(State.class, Widget.class, String.class)
196+
.newInstance((State) rootWidget, originWidget, inputText);
197+
} catch (ReflectiveOperationException exception) {
198+
return null;
199+
}
200+
}
201+
131202
private Action buildSelectAction(Action templateAction, String selectedValue) {
132203
Widget originWidget = templateAction.get(Tags.OriginWidget, null);
133204
if (originWidget == null) {
134205
throw new IllegalArgumentException("Matched select action does not have an origin widget");
135206
}
136207

208+
Action rebuiltAction = invokeStaticActionFactory(
209+
"org.testar.webdriver.action.WebdriverSelectListSupport",
210+
"createActionForInput",
211+
new Class<?>[] { Widget.class, String.class },
212+
new Object[] { originWidget, selectedValue },
213+
"Matched select action cannot be rebuilt without WebDriver select support"
214+
);
215+
if (rebuiltAction != null) {
216+
return withCopiedIdentity(templateAction, rebuiltAction);
217+
}
218+
219+
throw new IllegalArgumentException("Matched select action cannot be rebuilt due to missing target");
220+
}
221+
222+
private Action invokeStaticActionFactory(String className,
223+
String methodName,
224+
Class<?>[] parameterTypes,
225+
Object[] arguments,
226+
String errorMessage) {
137227
try {
138-
Class<?> supportClass = Class.forName("org.testar.webdriver.action.WebdriverSelectListSupport");
139-
Object selectAction = supportClass
140-
.getMethod("createActionForInput", Widget.class, String.class)
141-
.invoke(null, originWidget, selectedValue);
142-
if (selectAction instanceof Action) {
143-
Action rebuiltAction = (Action) selectAction;
144-
copyIdentityTags(templateAction, rebuiltAction);
145-
return rebuiltAction;
228+
Object action = Class.forName(className)
229+
.getMethod(methodName, parameterTypes)
230+
.invoke(null, arguments);
231+
if (action instanceof Action) {
232+
return (Action) action;
146233
}
147234
} catch (ReflectiveOperationException exception) {
148-
throw new IllegalArgumentException(
149-
"Matched select action cannot be rebuilt without WebDriver select support",
150-
exception
151-
);
235+
throw new IllegalArgumentException(errorMessage, exception);
152236
}
153-
throw new IllegalArgumentException("Matched select action cannot be rebuilt due to missing target");
237+
return null;
154238
}
155239

156-
private void copyIdentityTags(Action sourceAction, Action targetAction) {
240+
private Action withCopiedIdentity(Action sourceAction, Action targetAction) {
157241
String abstractId = sourceAction.get(Tags.AbstractID, "");
158242
if (!abstractId.isBlank()) {
159243
targetAction.set(Tags.AbstractID, abstractId);
@@ -163,6 +247,8 @@ private void copyIdentityTags(Action sourceAction, Action targetAction) {
163247
if (!concreteId.isBlank()) {
164248
targetAction.set(Tags.ConcreteID, concreteId);
165249
}
250+
251+
return targetAction;
166252
}
167253

168254
private boolean isWebdriverTypeAction(Action action) {
@@ -182,32 +268,35 @@ private void applyWebdriverInputText(Action action, String inputText) {
182268
}
183269

184270
private boolean isClickAction(Action action) {
185-
Role role = action.get(Tags.Role, null);
186-
String roleName = role == null ? "" : role.toString().toLowerCase(Locale.ROOT);
271+
String roleName = roleNameOf(action);
187272
return roleName.contains("click") && !roleName.contains("type");
188273
}
189274

190275
private boolean isTypeAction(Action action) {
191-
Role role = action.get(Tags.Role, null);
192-
String roleName = role == null ? "" : role.toString().toLowerCase(Locale.ROOT);
193-
return roleName.contains("type");
276+
return roleNameOf(action).contains("type");
194277
}
195278

196279
private boolean isSelectAction(Action action) {
280+
return roleNameOf(action).contains("select");
281+
}
282+
283+
private String roleNameOf(Action action) {
197284
Role role = action.get(Tags.Role, null);
198-
String roleName = role == null ? "" : role.toString().toLowerCase(Locale.ROOT);
199-
return roleName.contains("select");
285+
return role == null ? "" : role.toString().toLowerCase(Locale.ROOT);
200286
}
201287

202-
private boolean matchesAction(Action action, boolean typeAction, boolean selectAction) {
203-
if (selectAction) {
204-
return isSelectAction(action);
205-
}
206-
return typeAction ? isTypeAction(action) : isClickAction(action);
288+
private String normalizedDescription(Action action) {
289+
return normalizeText(action.get(Tags.Desc, action.toString()));
207290
}
208291

209292
private String normalizeText(String text) {
210-
return text == null ? "" : text.toLowerCase(Locale.ROOT);
293+
if (text == null) {
294+
return "";
295+
}
296+
297+
String normalized = text.toLowerCase(Locale.ROOT);
298+
normalized = normalized.replaceAll("[\\?\\*\\u2022\\u2023\\u25e6\\u2043\\u2219\\u25cf\\ufffd]{2,}", " passwordmask ");
299+
return normalized;
211300
}
212301

213302
private String joinArguments(List<String> arguments, int startIndex) {

0 commit comments

Comments
 (0)