Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add tapping of accessibility element using AXLabel #758

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ fbsimctl/cli-tests/supports_metal
output/
.clang-format

# CocoaPods
Pods/

# Files copied from Xcode
Fixtures/Source/FBTestRunnerApp/Frameworks

Expand All @@ -75,6 +78,7 @@ var/
*.egg-info/
.installed.cfg
*.egg
protoc-gen-python_grpc

# Installer logs
pip-log.txt
Expand Down
44 changes: 41 additions & 3 deletions idb/cli/commands/hid.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,45 @@
from argparse import ArgumentParser, Namespace

from idb.cli import ClientCommand
from idb.common.types import Client, HIDButtonType
from idb.common.types import Client, HIDButtonType, HIDElementType


class AXTapCommand(ClientCommand):
@property
def description(self) -> str:
return "Tap Accessibility Element On the Screen"

@property
def name(self) -> str:
return "axtap"

def add_parser_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument(
"element",
choices=[element.name for element in HIDElementType],
help="Accessibility Type",
type=str,
)
parser.add_argument("label", nargs="?", help="AXLabel", type=str)
parser.add_argument(
"x", nargs="?", default=1, help="The x-coordinate", type=float
)
parser.add_argument(
"y", nargs="?", default=1, help="The y-coordinate", type=float
)
parser.add_argument("--duration", help="Press duration", type=float)
parser.add_argument("--count", default=1, help="Number of taps", type=int)
super().add_parser_arguments(parser)

async def run_with_client(self, args: Namespace, client: Client) -> None:
await client.axtap(
element=HIDElementType[args.element],
label=args.label,
x=args.x,
y=args.y,
duration=args.duration,
count=args.count,
)


class TapCommand(ClientCommand):
Expand All @@ -20,8 +58,8 @@ def name(self) -> str:
return "tap"

def add_parser_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument("x", help="The x-coordinate", type=int)
parser.add_argument("y", help="The y-coordinate", type=int)
parser.add_argument("x", help="The x-coordinate", type=float)
parser.add_argument("y", help="The y-coordinate", type=float)
parser.add_argument("--duration", help="Press duration", type=float)
super().add_parser_arguments(parser)

Expand Down
2 changes: 2 additions & 0 deletions idb/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from idb.cli.commands.focus import FocusCommand
from idb.cli.commands.framework import FrameworkInstallCommand
from idb.cli.commands.hid import (
AXTapCommand,
ButtonCommand,
KeyCommand,
KeySequenceCommand,
Expand Down Expand Up @@ -242,6 +243,7 @@ async def gen_main(cmd_input: Optional[List[str]] = None) -> int:
commands=[
AccessibilityInfoAllCommand(),
AccessibilityInfoAtPointCommand(),
AXTapCommand(),
TapCommand(),
ButtonCommand(),
TextCommand(),
Expand Down
7 changes: 7 additions & 0 deletions idb/common/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ class HIDButtonType(Enum):
SIRI = 5


class HIDElementType(Enum):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you show an example of each of these from an actual output?

back = "back button"
button = "button"
text = "text"
textfield = "text field"


ConnectionDestination = Union[str, Address]


Expand Down
2 changes: 1 addition & 1 deletion idb/common/udid.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
SIMULATOR_UDID = r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"
OLD_DEVICE_UDID = r"^[0-9a-f]{40}$"
NEW_DEVICE_UDID = r"^[0-9]{8}-[0-9A-F]{16}$"
UDID = fr"({SIMULATOR_UDID}|{OLD_DEVICE_UDID}|{NEW_DEVICE_UDID})"
UDID = rf"({SIMULATOR_UDID}|{OLD_DEVICE_UDID}|{NEW_DEVICE_UDID})"


def is_udid(udid: str) -> bool:
Expand Down
44 changes: 44 additions & 0 deletions idb/grpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import functools
import inspect
import logging
import json
import os
import shutil
import sys
Expand Down Expand Up @@ -65,6 +66,7 @@
FileEntryInfo,
FileListing,
HIDButtonType,
HIDElementType,
HIDEvent,
IdbConnectionException,
IdbException,
Expand Down Expand Up @@ -789,6 +791,48 @@ async def list_xctests(self) -> List[InstalledTestInfo]:
async def send_events(self, events: Iterable[HIDEvent]) -> None:
await self.hid(iterator_to_async_iterator(events))

@log_and_handle_exceptions
async def axtap(
self,
element: HIDElementType,
label: str,
x: float,
y: float,
duration: Optional[float] = None,
count: Optional[int] = 1,
) -> None:
accessibilityInfo = await self.stub.accessibility_info(
AccessibilityInfoRequest(
point=None,
format=(AccessibilityInfoRequest.LEGACY),
)
)

elementFound = False

for item in json.loads(accessibilityInfo.json):
try:
axElement = HIDElementType(item["role_description"])
axLabel = item["AXLabel"]

elementFound = axElement == element and (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all the element types except for back, does it make sense to allow commands without a label?

The current behavior is that:

$ idb ui axtap button

Succeeds and returns the first button it encounters, right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it just uses the first element of the type.

It's debatable where or not that is useful because I don't know if we can guarantee that the order of accessibilityInfo.json is the same every run, though it appeared that way when testing.

label is None or axLabel == label
)

if elementFound:
axframe = item["frame"]
x += axframe["x"]
y += axframe["y"]
break
except:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What could make it raise?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the enum will raise a ValueError if it doesn't exist for the role:
HIDElementType(item["role_description"])

I'm happy to change that if there is a preferred way of handling this scenario.

pass

if elementFound:
for n in range(count):
await self.send_events(tap_to_events(x, y, duration))
else:
print("AXElement with AXLabel: ", label, " type: ", element, " not found.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could log it, however let's not send it to stdout and preserve the current tap behavior.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Can change to logging instead of print.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not preserve the current tap behavior because the coordinates provided are suppose to be relative to the accessibility element's frame and default to (1,1). It is probably better to just do nothing than send tap to unintended coordinates.


@log_and_handle_exceptions
async def tap(self, x: float, y: float, duration: Optional[float] = None) -> None:
await self.send_events(tap_to_events(x, y, duration))
Expand Down