Skip to content

Commit 5eaf4bd

Browse files
authored
add find by image commands and tests (appium#224)
* add find by image commands and tests * remove and ignore pytest cache files * address review comments * fix docstrings
1 parent 9334511 commit 5eaf4bd

8 files changed

Lines changed: 195 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ dist
1818
.cache
1919
__pycache__
2020
.idea
21+
.pytest_cache

appium/webdriver/common/mobileby.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ class MobileBy(By):
2121
IOS_CLASS_CHAIN = '-ios class chain'
2222
ANDROID_UIAUTOMATOR = '-android uiautomator'
2323
ACCESSIBILITY_ID = 'accessibility id'
24+
IMAGE = '-image'

appium/webdriver/imagelement.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import math
14+
15+
16+
class ImageElement:
17+
18+
def __init__(self, driver, x, y, width, height):
19+
self.driver = driver
20+
self.center_x = math.floor(x + width / 2)
21+
self.center_y = math.floor(y + height / 2)
22+
self.x = x
23+
self.y = y
24+
self.width = width
25+
self.height = height
26+
27+
def click(self):
28+
"""
29+
Clicks in the middle of an image bounds
30+
"""
31+
return self.driver.tap([(self.center_x, self.center_y)])
32+
33+
@property
34+
def size(self):
35+
return {'width': self.width, 'height': self.height}
36+
37+
@property
38+
def location(self):
39+
return {'x': self.x, 'y': self.y}
40+
41+
@property
42+
def rect(self):
43+
return {
44+
'width': self.width,
45+
'height': self.height,
46+
'x': self.x,
47+
'y': self.y
48+
}
49+
50+
def is_displayed(self):
51+
return True

appium/webdriver/webdriver.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .errorhandler import MobileErrorHandler
1919
from .switch_to import MobileSwitchTo
2020
from .webelement import WebElement as MobileWebElement
21+
from .imagelement import ImageElement
2122

2223
from appium.webdriver.clipboard_content_type import ClipboardContentType
2324
from appium.webdriver.common.mobileby import MobileBy
@@ -26,13 +27,16 @@
2627

2728
from selenium.webdriver.common.by import By
2829
from selenium.webdriver.support.ui import WebDriverWait
29-
from selenium.common.exceptions import TimeoutException, InvalidArgumentException
30+
from selenium.common.exceptions import (TimeoutException,
31+
WebDriverException, InvalidArgumentException, NoSuchElementException)
3032

3133
from selenium.webdriver.remote.command import Command as RemoteCommand
3234

3335
import base64
3436
import copy
3537

38+
DEFAULT_MATCH_THRESHOLD = 0.5
39+
3640
# From remote/webdriver.py
3741
_W3C_CAPABILITY_NAMES = frozenset([
3842
'acceptInsecureCerts',
@@ -84,7 +88,9 @@ def _make_w3c_caps(caps):
8488
always_match['moz:firefoxOptions'] = new_opts
8589
return {'firstMatch': [{}], 'alwaysMatch': always_match}
8690

91+
8792
class WebDriver(webdriver.Remote):
93+
8894
def __init__(self, command_executor='http://127.0.0.1:4444/wd/hub',
8995
desired_capabilities=None, browser_profile=None, proxy=None, keep_alive=False):
9096

@@ -102,6 +108,7 @@ def __init__(self, command_executor='http://127.0.0.1:4444/wd/hub',
102108
By.IOS_CLASS_CHAIN = MobileBy.IOS_CLASS_CHAIN
103109
By.ANDROID_UIAUTOMATOR = MobileBy.ANDROID_UIAUTOMATOR
104110
By.ACCESSIBILITY_ID = MobileBy.ACCESSIBILITY_ID
111+
By.IMAGE = MobileBy.IMAGE
105112

106113
def start_session(self, capabilities, browser_profile=None):
107114
"""
@@ -206,6 +213,9 @@ def find_element(self, by=By.ID, value=None):
206213
# elif by == By.NAME:
207214
# by = By.CSS_SELECTOR
208215
# value = '[name="%s"]' % value
216+
if by == By.IMAGE:
217+
return self.find_element_by_image(value)
218+
209219
return self.execute(RemoteCommand.FIND_ELEMENT, {
210220
'using': by,
211221
'value': value})['value']
@@ -235,6 +245,10 @@ def find_elements(self, by=By.ID, value=None):
235245

236246
# Return empty list if driver returns null
237247
# See https://github.com/SeleniumHQ/selenium/issues/4555
248+
249+
if by == By.IMAGE:
250+
return self.find_elements_by_image(value)
251+
238252
return self.execute(RemoteCommand.FIND_ELEMENTS, {
239253
'using': by,
240254
'value': value})['value'] or []
@@ -327,6 +341,52 @@ def find_elements_by_android_uiautomator(self, uia_string):
327341
"""
328342
return self.find_elements(by=By.ANDROID_UIAUTOMATOR, value=uia_string)
329343

344+
def find_element_by_image(self, png_img_path,
345+
match_threshold=DEFAULT_MATCH_THRESHOLD):
346+
"""Finds a portion of a screenshot by an image.
347+
Uses driver.find_image_occurrence under the hood.
348+
349+
:Args:
350+
- png_img_path - a string corresponding to the path of a PNG image
351+
- match_threshold - a double between 0 and 1 below which matches will
352+
be rejected as element not found
353+
354+
:return: an ImageElement object
355+
"""
356+
screenshot = self.get_screenshot_as_base64()
357+
with open(png_img_path, 'rb') as png_file:
358+
b64_data = base64.encodestring(png_file.read())
359+
try:
360+
res = self.find_image_occurrence(screenshot, b64_data,
361+
threshold=match_threshold)
362+
except WebDriverException as e:
363+
if 'Cannot find any occurrences' in str(e):
364+
raise NoSuchElementException(e)
365+
raise
366+
rect = res['rect']
367+
return ImageElement(self, rect['x'], rect['y'], rect['width'],
368+
rect['height'])
369+
370+
def find_elements_by_image(self, png_img_path,
371+
match_threshold=DEFAULT_MATCH_THRESHOLD):
372+
"""Finds a portion of a screenshot by an image.
373+
Uses driver.find_image_occurrence under the hood. Note that this will
374+
only ever return at most one element
375+
376+
:Args:
377+
- png_img_path - a string corresponding to the path of a PNG image
378+
- match_threshold - a double between 0 and 1 below which matches will
379+
be rejected as element not found
380+
381+
:return: possibly-empty list of ImageElements
382+
"""
383+
els = []
384+
try:
385+
els.append(self.find_element_by_image(png_img_path, match_threshold))
386+
except NoSuchElementException:
387+
pass
388+
return els
389+
330390
def find_element_by_accessibility_id(self, id):
331391
"""Finds an element by accessibility id.
332392
@@ -1419,4 +1479,4 @@ def _addCommands(self):
14191479
self.command_executor._commands[Command.GET_CLIPBOARD] = \
14201480
('POST', '/session/$sessionId/appium/device/get_clipboard')
14211481
self.command_executor._commands[Command.COMPARE_IMAGES] = \
1422-
('POST', '/session/$sessionId/appium/compare_images')
1482+
('POST', '/session/$sessionId/appium/compare_images')

test/functional/android/desired_capabilities.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,17 @@
1414

1515
import os
1616

17+
1718
# Returns abs path relative to this file and not cwd
18-
PATH = lambda p: os.path.abspath(
19-
os.path.join(os.path.dirname(__file__), p)
20-
)
19+
def PATH(p):
20+
return os.path.abspath(
21+
os.path.join(os.path.dirname(__file__), p)
22+
)
2123

2224

2325
def get_desired_capabilities(app):
2426
desired_caps = {
2527
'platformName': 'Android',
26-
'platformVersion': '4.2',
2728
'deviceName': 'Android Emulator',
2829
'app': PATH('../../apps/' + app),
2930
'newCommandTimeout': 240
24.7 KB
Loading
7.14 KB
Loading
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env python
2+
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
17+
from appium import webdriver
18+
from selenium.common.exceptions import NoSuchElementException, TimeoutException
19+
from selenium.webdriver.common.by import By
20+
from selenium.webdriver.support.ui import WebDriverWait
21+
from selenium.webdriver.support import expected_conditions as EC
22+
import desired_capabilities
23+
24+
25+
class FindByImageTests(unittest.TestCase):
26+
27+
def setUp(self):
28+
desired_caps = desired_capabilities.get_desired_capabilities('ApiDemos-debug.apk')
29+
self.driver = webdriver.Remote('http://localhost:4723/wd/hub', desired_caps)
30+
31+
def tearDown(self):
32+
self.driver.quit()
33+
34+
def test_find_based_on_image_template(self):
35+
image_path = desired_capabilities.PATH('find_by_image_success.png')
36+
el = WebDriverWait(self.driver, 3).until(
37+
EC.presence_of_element_located((By.IMAGE, image_path))
38+
)
39+
size = el.size
40+
self.assertIsNotNone(size['width'])
41+
self.assertIsNotNone(size['height'])
42+
loc = el.location
43+
self.assertIsNotNone(loc['x'])
44+
self.assertIsNotNone(loc['y'])
45+
rect = el.rect
46+
self.assertIsNotNone(rect['width'])
47+
self.assertIsNotNone(rect['height'])
48+
self.assertIsNotNone(rect['x'])
49+
self.assertIsNotNone(rect['y'])
50+
self.assertTrue(el.is_displayed())
51+
el.click()
52+
self.driver.find_element_by_accessibility_id("Alarm")
53+
54+
def test_find_multiple_elements_by_image_just_returns_one(self):
55+
WebDriverWait(self.driver, 3).until(
56+
EC.presence_of_element_located((By.ACCESSIBILITY_ID, "App"))
57+
)
58+
image_path = desired_capabilities.PATH('find_by_image_success.png')
59+
els = self.driver.find_elements_by_image(image_path)
60+
els[0].click()
61+
self.driver.find_element_by_accessibility_id("Alarm")
62+
63+
def test_find_throws_no_such_element(self):
64+
image_path = desired_capabilities.PATH('find_by_image_failure.png')
65+
with self.assertRaises(TimeoutException):
66+
WebDriverWait(self.driver, 3).until(
67+
EC.presence_of_element_located((By.IMAGE, image_path))
68+
)
69+
with self.assertRaises(NoSuchElementException):
70+
self.driver.find_element_by_image(image_path)
71+
72+
73+
if __name__ == "__main__":
74+
suite = unittest.TestLoader().loadTestsFromTestCase(FindByImageTests)
75+
unittest.TextTestRunner(verbosity=2).run(suite)

0 commit comments

Comments
 (0)