Skip to content

Facebook WebDriverAgent Python Client Library (not official)

License

Notifications You must be signed in to change notification settings

openatx/facebook-wda

Repository files navigation

python-wda

Build Status PyPI PyPI

Facebook WebDriverAgent Python Client Library (not official) Implemented apis describe in https://github.com/facebook/WebDriverAgent/wiki/Queries

Most functions finished.

Since facebook/WebDriverAgent has been archived. Recommend use the forked WDA: https://github.com/appium/WebDriverAgent

Tested with: https://github.com/appium/WebDriverAgent/tree/v2.16.1

Alternatives

Installation

  1. You need to start WebDriverAgent by yourself

    New There is a new tool, which can start WDA without xcodebuild, even you can run in Linux and Windows. See: https://github.com/alibaba/tidevice

    Or

    Follow the instructions in https://github.com/appium/WebDriverAgent

    It is better to start with Xcode to prevent CodeSign issues.

    But it is also ok to start WDA with command line.

    xcodebuild -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'platform=iOS Simulator,name=iPhone 6' test
    

    WDA在真机上运行需要一些配置,可以参考这篇文章 ATX 文档 - iOS 真机如何安装 WebDriverAgent

    配置完之后运行下面的命令即可(需要用到Mac的密码,以及设备的UDID)

    # 解锁keychain,以便可以正常的签名应用
    security unlock-keychain -p $your-mac-password-here ~/Library/Keychains/login.keychain
    
    # 获取设备的UDID
    UDID=$(idevice_id -l | head -n1)
    
    # 运行测试
    xcodebuild -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination "id=$UDID" test
  2. Install python wda client

    pip3 install -U facebook-wda
    

TCP connection over USB (optional)

You can use wifi network, it is very convinient, but not very stable enough.

I found a tools named iproxy which can forward device port to localhost, it's source code is here https://github.com/libimobiledevice/libusbmuxd

The usage is very simple iproxy <local port> <remote port> [udid]

For more information see SSH Over USB

Something you need to know

function window_size() return UIKit size, While screenshot() image size is Native Resolution

IOS Display

when use screenshot, the image size is pixels size. eg(1080 x 1920) But this size is different with c.window_size()

use session.scale to get UIKit scale factor

Configuration

import wda

wda.DEBUG = False # default False
wda.HTTP_TIMEOUT = 60.0 # default 60.0 seconds

How to use

Create a client

import wda

# Enable debug will see http Request and Response
# wda.DEBUG = True
c = wda.Client('http://localhost:8100')

# get env from $DEVICE_URL if no arguments pass to wda.Client
# http://localhost:8100 is the default value if $DEVICE_URL is empty
c = wda.Client()

A wda.WDAError will be raised if communite with WDA went wrong.

Experiment feature: create through usbmuxd without iproxy

Added in version: 0.9.0

class USBClient inherit from Client

USBClient connect to wda-server through unix:/var/run/usbmuxd

import wda

# 如果只有一个设备也可以简写为
# If there is only one iPhone connecttd
c = wda.USBClient()

# 支持指定设备的udid,和WDA的端口号
# Specify udid and WDA port
c = wda.USBClient("539c5fffb18f2be0bf7f771d68f7c327fb68d2d9", port=8100)

# 也支持通过DEVICE_URL访问
c = wda.Client("usbmux://{udid}:8100".format(udid="539c5fffb18f2be0bf7f771d68f7c327fb68d2d9"))
print(c.window_size())

# 注:
# 仅在安装了tins的电脑上可以使用(目前并不对外开放)
# 1.2.0 引入 wda_bundle_id 参数
c = wda.USBClient("539c5fffb18f2be0bf7f771d68f7c327fb68d2d9", port=8100, wda_bundle_id="com.facebook.custom.xctest")

看到这里,可以看 examples 目录下的一些代码了

Client

# Show status
print c.status()

# Wait WDA ready
c.wait_ready(timeout=300) # 等待300s,默认120s
c.wait_ready(timeout=300, noprint=True) # 安静的等待,无进度输出

# Press home button
c.home()

# Hit healthcheck
c.healthcheck()

# Get page source
c.source() # format XML
c.source(accessible=True) # default false, format JSON

c.locked() # true of false
c.lock() # lock screen
c.unlock() # unlock
c.app_current() # {"pid": 1281, "bundleId": "com.netease.cloudmusic"}

# OpenURL not working very well
c.open_url("taobao://m.taobao.com/index.htm")

Take screenshot save as png

c.screenshot('screen.png') # Good
c.screenshot("screen.jpg") # Bad

# convert to PIL.Image and then save as jpg
c.screenshot().save("screen.jpg") # Good

c.appium_settings() # 获取appium的配置
c.appium_settings({"mjpegServerFramerate": 20}) # 修改配置

Session

From version 0.7.0, All Session methods moved to Client class. now Session is alias of Client

Open app

with c.session('com.apple.Health') as s:
	print(s.orientation)

Same as

s = c.session('com.apple.Health')
print(s.orientation)
s.close()

For web browser like Safari you can define page whit which will be opened:

s = c.session('com.apple.mobilesafari', ['-u', 'https://www.google.com/ncr'])
print(s.orientation)
s.close()

Other app operation (Works in appium/WebDriverAgent)

c.app_current() # show current app info
# Output example --
# {'processArguments': {'env': {}, 'args': []},
# 'name': '',
# 'pid': 2978,
# 'bundleId': 'com.apple.Preferences'}

# Handle alert automatically in WDA (never tested before)
# alert_action should be one of ["accept", "dismiss"]
s = c.session("com.apple.Health", alert_action="accept")

# launch without terminate app (WDAEmptyResponseError might raise)
c.session().app_activate("com.apple.Health") # same as app_launch

# terminate app
c.session().app_terminate("com.apple.Health")

# get app state
c.session().app_state("com.apple.Health")
# output {"value": 4, "sessionId": "xxxxxx"}
# different value means 1: die, 2: background, 4: running

Session operations

# set default element search timeout 30 seconds
s.implicitly_wait(30.0)

# Current bundleId and sessionId
print(s.bundle_id, s.id)

s.home() # same as c.home(), use the same API

s.lock() # lock screen
s.unlock() # unlock screen
s.locked() # locked status, true or false

s.battery_info() # return like {"level": 1, "state": 2}
s.device_info() # return like {"currentLocale": "zh_CN", "timeZone": "Asia/Shanghai"}

s.set_clipboard("Hello world") # update clipboard
# s.get_clipboard() # Not working now

# Screenshot return PIL.Image
# Requires pillow, installed by "pip install pillow"
s.screenshot().save("s.png")

# Sometimes screenshot rotation is wrong, but we can rotate it to the right direction
# Refs: https://pillow.readthedocs.io/en/3.1.x/reference/Image.html#PIL.Image.Image.transpose
from PIL import Image
s.screenshot().transpose(Image.ROTATE_90).save("correct.png")

# One of <PORTRAIT | LANDSCAPE>
print(s.orientation) # expect PORTRAIT or LANDSCAPE

# Change orientation
s.orientation = wda.LANDSCAPE # there are many other directions

# Deactivate App for some time
s.deactivate(5.0) # 5s

# Get width and height
print(s.window_size())
# Expect tuple output (width, height)
# For example: (414, 736)

# Get UIKit scale factor, the first time will take about 1s, next time use cached value
print(s.scale)
# Example output: 3

# Simulate touch
s.tap(200, 200)

# Very like tap, but support float and int argument
# float indicate percent. eg 0.5 means 50%
s.click(200, 200)
s.click(0.5, 0.5) # click center of screen
s.click(0.5, 200) # click center of x, and y(200)

# Double touch
s.double_tap(200, 200)

# Simulate swipe, utilizing drag api
s.swipe(x1, y1, x2, y2, 0.5) # 0.5s
s.swipe(0.5, 0.5, 0.5, 1.0)  # swipe middle to bottom

s.swipe_left()
s.swipe_right()
s.swipe_up()
s.swipe_down()

# tap hold for 1 seconds
s.tap_hold(x, y, 1.0)

# Hide keyboard (not working in simulator), did not success using latest WDA
# s.keyboard_dismiss()

# press home, volumeUp, volumeDown
s.press("home") # fater then s.home()
s.press("volumeUp")
s.press("volumeDown")

# New in WebDriverAgent(3.8.0)
# long press home, volumeUp, volumeDown, power, snapshot(power+home)
s.press_duration("volumeUp", 1) # long press for 1 second
s.press_duration("snapshot", 0.1)

Find element

Note: if element not found, WDAElementNotFoundError will be raised

# For example, expect: True or False
# using id to find element and check if exists
s(id="URL").exists # return True or False

# using id or other query conditions
s(id='URL')

# using className
s(className="Button")

# using name
s(name='URL')
s(nameContains='UR')
s(nameMatches=".RL")

# using label
s(label="label")
s(labelContains="URL")

# using value
s(value="Enter")
s(valueContains="RL")

# using  visible, enabled
s(visible=True, enabled=True)

# using index, index must combined with at least on label,value, etc...
s(name='URL', index=1) # find the second element. index of founded elements, min is 0

# combines search conditions
# attributes bellow can combines
# :"className", "name", "label", "visible", "enabled"
s(className='Button', name='URL', visible=True, labelContains="Addr")

More powerful finding method

s(xpath='//Button[@name="URL"]')

# another code style
s.xpath('//Button[@name="URL"]')

s(predicate='name LIKE "UR*"')
s('name LIKE "U*L"') # predicate is the first argument, without predicate= is ok
s(classChain='**/Button[`name == "URL"`]')

To see more Class Chain Queries examples, view https://github.com/facebookarchive/WebDriverAgent/wiki/Class-Chain-Queries-Construction-Rules

Predicate Format String Syntax

Get Element info

# if not found, raise WDAElementNotFoundError
e = s(text='Dashboard').get(timeout=10.0)

# e could be None if not exists
e = s(text='Dashboard').wait(timeout=10.0)

# get element attributes
e.className # XCUIElementTypeStaticText
e.name # XCUIElementTypeStaticText  /name
e.visible # True    /attribute/visible
e.value # Dashboard /attribute/value
e.label # Dashboard /attribute/label
e.text # Dashboard  /text
e.enabled # True    /enabled
e.displayed # True  /displayed

e.bounds # Rect(x=161, y=32, width=53, height=21)  /rect
x, y, w, h = e.bounds

Element operations (eg: tap, scroll, set_text etc...)

Exmaple search element and tap

# Get first match Element object
# The function get() is very important.
# when elements founded in 10 seconds(:default:), Element object returns
# or WDAElementNotFoundError raises
e = s(text='Dashboard').get(timeout=10.0)
# s(text='Dashboard') is Selector
# e is Element object
e.tap() # tap element

Some times, I just hate to type .get()

Using python magic tricks to do it again.

# 	using python magic function "__getattr__", it is ok with out type "get()"
s(text='Dashboard').tap()
# same as
s(text='Dashboard').get().tap()

Note: Python magic tricks can not used on get attributes

# Accessing attrbutes, you have to use get()
s(text='Dashboard').get().value

# Not right
# s(text='Dashboard').value # Bad, always return None

Click element if exists

s(text='Dashboard').click_exists() # return immediately if not found
s(text='Dashboard').click_exists(timeout=5.0) # wait for 5s

Other Element operations

# Check if elements exists
print(s(text="Dashboard").exists)

# Find all matches elements, return Array of Element object
s(text='Dashboard').find_elements()

# Use index to find second element
s(text='Dashboard')[1].exists

# Use child to search sub elements
s(text='Dashboard').child(className='Cell').exists

# Default timeout is 10 seconds
# But you can change by
s.set_timeout(10.0)

# do element operations
e.tap()
e.click() # alias of tap
e.clear_text()
e.set_text("Hello world")
e.tap_hold(2.0) # tapAndHold for 2.0s

e.scroll() # scroll to make element visiable

# directions can be "up", "down", "left", "right"
# swipe distance default to its height or width according to the direction
e.scroll('up')

# Set text
e.set_text("Hello WDA") # normal usage
e.set_text("Hello WDA\n") # send text with enter
e.set_text("\b\b\b") # delete 3 chars

# Wait element gone
s(text='Dashboard').wait_gone(timeout=10.0)

# Swipe
s(className="Image").swipe("left")

# Pinch
s(className="Map").pinch(2, 1) # scale=2, speed=1
s(className="Map").pinch(0.1, -1) # scale=0.1, speed=-1 (I donot very understand too)

# properties (bool)
e.accessible
e.displayed
e.enabled

# properties (str)
e.text # ex: Dashboard
e.className # ex: XCUIElementTypeStaticText
e.value # ex: github.com

# Bounds return namedtuple
rect = e.bounds # ex: Rect(x=144, y=28, width=88.0, height=27.0)
rect.x # expect 144

Alert

print(s.alert.exists)
print(s.alert.text)
s.alert.accept() # Actually do click first alert button
s.alert.dismiss() # Actually do click second alert button
s.alert.wait(5) # if alert apper in 5 second it will return True,else return False (default 20.0)
s.alert.wait() # wait alert apper in 2 second

s.alert.buttons()
# example return: ["设置", "好"]

s.alert.click("设置")
s.alert.click(["设置", "信任", "安装"]) # when Arg type is list, click the first match, raise ValueError if no match

Alert monitor

with c.alert.watch_and_click(['好', '确定']):
	s(label="Settings").click() # 
	# ... other operations

# default watch buttons are
# ["使用App时允许", "好", "稍后", "稍后提醒", "确定", "允许", "以后"]
with c.alert.watch_and_click(interval=2.0): # default check every 2.0s
	# ... operations

Callback

回调操作: register_callback

c = wda.Client()

# 使用Example
def device_offline_callback(client, err):
	if isinstance(err, wda.WDABadGateway):
		print("Handle device offline")
		ok = client.wait_ready(60) # 等待60s恢复
		if not ok:
			return wda.Callback.RET_ABORT
		return wda.Callback.RET_RETRY

c.register_callback(wda.Callback.ERROR, device_offline_callback, try_first=True)
# try_first 优先使用device_offline_callback函数处理ERROR


# the argument name in callback function can be one of
# - client: wda.Client
# - url: str, eg: http://localhost:8100/session/024A4577-2105-4E0C-9623-D683CDF9707E/wda/keys
# - urlpath: str, eg: /wda/keys  (without session id)
# - with_session: bool # if url contains session id
# - method: str, eg: GET
# - response: dict # Callback.HTTP_REQUEST_AFTER only 
# - err: WDAError # Callback.ERROR only
#
def _cb(client: wda.Client, url: str):
	if url.endswith("/wda/keys"):
		print("send_keys called")

c.register_callback(wda.Callback.HTTP_REQUEST_BEFORE, _cb)
c.register_callback(wda.Callback.HTTP_REQUEST_BEFORE, lambda url: print(url), try_first=True) # 回调会比_cb更先回调
c.send_keys("Hello")

# unregister
c.unregister_callback(wda.Callback.HTTP_REQUEST_BEFORE, _cb)
c.unregister_callback(wda.Callback.HTTP_REQUEST_BEFORE) # ungister all
c.unregister_callback() # unregister all callbacks

支持的回调有

wda.Callback.HTTP_REQUEST_BEFORE
wda.Callback.HTTP_REQUEST_AFTER
wda.Callback.ERROR

默认代码内置了两个回调函数 wda.Callback.ERROR,使用c.unregister_callback(wda.Callback.ERROR)可以去掉这两个回调

  • 当遇到invalid session id错误时,更新session id并重试
  • 当遇到设备掉线时,等待wda.DEVICE_WAIT_TIMEOUT时间 (当前是30s,以后可能会改的更长一些)

TODO

longTap not done pinch(not found in WDA)

TouchID

  • Match Touch ID
  • Do not match Touch ID

How to handle alert message automaticly (need more tests)

For example

import wda

s = wda.Client().session()

def _alert_callback(session):
    session.alert.accept()

s.set_alert_callback(_alert_callback) # deprecated,此方法不能用了

# do operations, when alert popup, it will auto accept
s(type="Button").click()

Special property

# s: wda.Session
s.alibaba.xxx # only used in alibaba-company

DEVELOP

See DEVELOP.md for more details.

iOS Build-in Apps

苹果自带应用

Name Bundle ID
iMovie com.apple.iMovie
Apple Store com.apple.AppStore
Weather com.apple.weather
相机Camera com.apple.camera
iBooks com.apple.iBooks
Health com.apple.Health
Settings com.apple.Preferences
Watch com.apple.Bridge
Maps com.apple.Maps
Game Center com.apple.gamecenter
Wallet com.apple.Passbook
电话 com.apple.mobilephone
备忘录 com.apple.mobilenotes
指南针 com.apple.compass
浏览器 com.apple.mobilesafari
日历 com.apple.mobilecal
信息 com.apple.MobileSMS
时钟 com.apple.mobiletimer
照片 com.apple.mobileslideshow
提醒事项 com.apple.reminders
Desktop com.apple.springboard (Start this will cause your iPhone reboot)

第三方应用 Thirdparty

Name Bundle ID
腾讯QQ com.tencent.mqq
微信 com.tencent.xin
部落冲突 com.supercell.magic
钉钉 com.laiwang.DingTalk
Skype com.skype.tomskype
Chrome com.google.chrome.ios

Another way to list apps installed on you phone is use ideviceinstaller install with brew install ideviceinstaller

List apps with command

$ ideviceinstaller -l

Tests

测试的用例放在tests/目录下,使用iphone SE作为测试机型,系统语言应用。调度框架pytest

WDA Benchmark E2E Tests

E2E Tests Latest WDA Version Testing Report:

Reference

Source code

Thanks

Articles

Contributors

DESIGN

DESIGN

LICENSE

MIT