Shadowstep
Shadowstep — a modern Python framework for Android test automation. Powered
by Appium.
Write tests, not boilerplate.




Table of Contents
Key Features
Architectural Patterns
- Facade Pattern — simplified interface for Appium interactions
- Page Object Pattern — structured UI representation
- Singleton Pattern — single point of access to driver
- Navigator Pattern — graph-based page navigation
Functionality
- Flexible locator system — dict, xpath, UiSelector with auto-conversion
- Rich DOM navigation — parent, sibling, cousin relationships
- Advanced gestures — tap, swipe, fling, scroll, pinch, zoom
- Lazy/Greedy element search — performance optimization
- Fail-safe decorators — automatic error handling and reconnection
- Built-in logging — Loguru-style colored output
- Image Recognition — find elements by images (OpenCV)
- Logcat Streaming — capture logs via WebSocket
- Page Object Generator — auto-generate page objects from XML
- SSH/ADB Support — remote command execution
Installation
Requirements
- Python 3.9+
- Appium Server 2.x
- UiAutomator2 Driver
- Android Device/Emulator
Install via pip
pip install appium-python-client-shadowstep
Install via uv (recommended)
pip install uv
uv venv
source .venv/bin/activate
.venv\Scripts\activate
uv pip install appium-python-client-shadowstep
Dependencies
Core:
Appium-Python-Client >= 5.2.2
selenium >= 4.36
networkx >= 3.2.1 — navigation
opencv-python >= 4.12.0.88 — image recognition
paramiko >= 4.0.0 — SSH
websocket-client >= 1.8.0 — logcat
Additional:
lxml >= 6.0.2 — XML parsing
jinja2 >= 3.1.6 — template engine
pytesseract >= 0.3.10 — OCR
Quick Start
1. Start Appium Server
appium --use-drivers=uiautomator2
2. Basic Example
from shadowstep import Shadowstep
app = Shadowstep()
app.connect(
capabilities={
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:appPackage": "com.android.settings",
"appium:appActivity": ".Settings",
}
)
element = app.get_element({"text": "Network & internet"})
element.tap()
element.wait_visible(timeout=10)
print(element.text)
print(element.is_displayed())
app.disconnect()
3. Page Object Example
from shadowstep import PageBaseShadowstep, Element
class PageSettings(PageBaseShadowstep):
@property
def edges(self):
return {
"PageNetworkInternet": self.to_network_internet,
}
@property
def title(self) -> Element:
return self.shadowstep.get_element({
"text": "Settings",
"resource-id": "com.android.settings:id/homepage_title"
})
@property
def network_internet(self) -> Element:
return self.recycler.scroll_to_element({
"text": "Network & internet"
})
@property
def recycler(self) -> Element:
return self.shadowstep.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})
def to_network_internet(self):
self.network_internet.tap()
return self.shadowstep.get_page("PageNetworkInternet")
def is_current_page(self) -> bool:
return self.title.is_visible()
app = Shadowstep()
page = app.get_page("PageSettings")
assert page.is_current_page()
page.to_network_internet()
Architecture
Facade Pattern
The project implements Facade Pattern at two levels:
1. Shadowstep (Main Facade)
Shadowstep — the main facade that hides the complexity of Appium WebDriver
interactions and provides a simple API.
class Shadowstep(ShadowstepBase):
"""Main Facade for mobile automation."""
def __init__(self):
super().__init__()
self.navigator = PageNavigator(self)
self.converter = LocatorConverter()
self.mobile_commands = MobileCommands()
Hidden subsystems:
ShadowstepBase — WebDriver management, connections
PageNavigator — page navigation
LocatorConverter — locator conversion
MobileCommands — UiAutomator2 commands
Terminal/Transport — ADB and SSH
ShadowstepLogcat — logging
2. Element (Element Facade)
Element — facade for working with mobile elements, combining multiple
specialized classes.
class Element(ElementBase):
"""Public API for Element."""
def __init__(self, locator, shadowstep, ...):
super().__init__(...)
self.utilities = ElementUtilities(self)
self.properties = ElementProperties(self)
self.dom = ElementDOM(self)
self.actions = ElementActions(self)
self.gestures = ElementGestures(self)
self.coordinates = ElementCoordinates(self)
self.screenshots = ElementScreenshots(self)
self.waiting = ElementWaiting(self)
Hidden subsystems:
ElementDOM — finding related elements (parent, sibling, cousin)
ElementActions — text input, clearing
ElementGestures — tap, swipe, scroll, fling
ElementProperties — attributes, states
ElementCoordinates — coordinates, center
ElementScreenshots — screenshots
ElementWaiting — waits
ElementUtilities — helper functions
Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ User/Test Code │
└──────────────────────┬──────────────────────────────────────┘
│
┌─────────────┴─────────────┐
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ Shadowstep │◄─────┤ PageBase │
│ (Main Facade) │ │ (Page Objects) │
└────────┬───────────┘ └──────────────────┘
│
├─► Navigator (Page Graph)
├─► LocatorConverter
├─► MobileCommands
├─► Terminal/Transport
└─► ShadowstepLogcat
│
▼
┌────────────────────┐
│ Element (Facade) │
└────────┬───────────┘
│
├─► ElementDOM
├─► ElementActions
├─► ElementGestures
├─► ElementProperties
├─► ElementCoordinates
├─► ElementScreenshots
└─► ElementWaiting
│
▼
┌────────────────────┐
│ Appium/Selenium │
│ (WebDriver) │
└────────────────────┘
Core API
Shadowstep (Facade)
Main facade class for managing mobile testing.
Device Connection
app.connect(
capabilities={
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:appPackage": "com.android.settings",
"appium:appActivity": ".Settings",
},
server_ip="127.0.0.1",
server_port=4723
)
from appium.options.android import UiAutomator2Options
options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app_package = "com.android.settings"
app.connect(
capabilities={},
options=options
)
app.connect(
capabilities={...},
server_ip="192.168.1.100",
ssh_user="user",
ssh_password="password"
)
if app.is_connected():
print("Connected successfully")
app.reconnect()
app.disconnect()
Finding Elements
element = app.get_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})
element = app.get_element(("xpath", '//android.widget.TextView[@text="Settings"]'))
from shadowstep.locator import UiSelector
element = app.get_element(UiSelector().text("Settings"))
elements = app.get_elements({"class": "android.widget.TextView"})
for el in elements:
print(el.text)
element = app.get_element(
locator={"text": "Network"},
timeout=30,
poll_frequency=0.5
)
Screen-Level Gestures
app.tap(x=500, y=1000, duration=100)
app.click(x=500, y=1000)
app.double_click(x=500, y=1000)
app.long_click(x=500, y=1000, duration=1000)
app.swipe(
left=100, top=500,
width=800, height=400,
direction="up",
percent=0.75,
speed=5000
)
app.swipe_up(percent=0.75, speed=5000)
app.swipe_down(percent=0.75)
app.swipe_left()
app.swipe_right()
app.scroll(
left=100, top=500,
width=800, height=400,
direction="down",
percent=0.5,
speed=2000
)
app.drag(start_x=500, start_y=1000, end_x=500, end_y=500, speed=2500)
app.fling(
left=100, top=500,
width=800, height=400,
direction="up",
speed=7500
)
app.pinch_open(left=100, top=500, width=800, height=600, percent=0.5)
app.pinch_close(left=100, top=500, width=800, height=600, percent=0.5)
Screenshots and Page Source
screenshot = app.get_screenshot()
app.save_screenshot(path="/tmp", filename="screen.png")
app.save_source(path="/tmp", filename="page.xml")
Application Management
app.start_activity(
intent="com.android.settings/.Settings",
component="com.android.settings/.Settings"
)
package = app.get_current_package()
activity = app.get_current_activity()
app.background_app(seconds=2)
app.activate_app(app_id="com.android.settings")
is_installed = app.is_app_installed(app_id="com.android.settings")
state = app.query_app_state(app_id="com.android.settings")
app.terminate_app(app_id="com.android.settings")
app.clear_app(app_id="com.android.settings")
System Commands
app.press_key(keycode=3)
app.press_key(keycode=4)
app.open_notifications()
app.lock()
app.unlock(key="1234", unlock_type="pin")
is_locked = app.is_locked()
result = app.shell("echo test")
app.type(text="test input")
is_shown = app.is_keyboard_shown()
app.hide_keyboard()
File Operations
import base64
content = base64.b64encode(b"test content").decode()
app.push_file(remote_path="/sdcard/test.txt", payload=content)
content = app.pull_file(remote_path="/sdcard/test.txt")
decoded = base64.b64decode(content)
folder_data = app.pull_folder(remote_path="/sdcard/Android")
app.delete_file(remote_path="/sdcard/test.txt")
app.push(source_file_path="local.txt", destination_file_path="/sdcard/test.txt")
Clipboard
import base64
text = "test clipboard"
encoded = base64.b64encode(text.encode()).decode()
app.set_clipboard(content=encoded)
clipboard = app.get_clipboard()
decoded = base64.b64decode(clipboard).decode()
Screen Recording
app.start_recording_screen()
video_bytes = app.stop_recording_screen()
with open("recording.mp4", "wb") as f:
f.write(video_bytes)
Network Settings
connectivity = app.get_connectivity(services=["wifi", "data"])
app.set_connectivity(wifi=True, data=False)
app.bluetooth(action="enable")
app.bluetooth(action="disable")
app.toggle_gps()
is_enabled = app.is_gps_enabled()
app.nfc(action="enable")
app.nfc(action="disable")
app.set_geolocation(latitude=37.7749, longitude=-122.4194, altitude=10.0)
location = app.get_geolocation(latitude=37.7749, longitude=-122.4194, altitude=10.0)
app.reset_geolocation()
app.refresh_gps_cache(timeout_ms=5000)
Device Information
battery = app.battery_info()
device = app.device_info()
density = app.get_display_density()
bars = app.get_system_bars()
time_str = app.get_device_time()
types = app.get_performance_data_types()
perf_data = app.get_performance_data(
package_name="com.android.settings",
data_type="cpuinfo"
)
Page Navigation
settings_page = app.get_page("PageSettings")
settings_page = app.get_page("PageSettings")
network_page = settings_page.to_network_internet()
page = app.resolve_page("PageNetworkInternet")
Element (Facade)
Facade class for interacting with UI elements.
Creating Element
element = app.get_element({"text": "Settings"})
from shadowstep.element import Element
element = Element(
locator={"text": "Settings"},
shadowstep=app,
timeout=30,
poll_frequency=0.5
)
from appium.webdriver.webelement import WebElement
native_el = driver.find_element(...)
element = Element(
locator={"text": "Settings"},
shadowstep=app,
native=native_el
)
DOM Navigation
element = app.get_element({"text": "Network & internet"})
inner = element.get_element({"class": "android.widget.TextView"})
children = element.get_elements({"class": "android.widget.TextView"})
parent = element.get_parent()
all_parents = element.get_parents()
sibling = element.get_sibling({"resource-id": "android:id/summary"})
all_siblings = element.get_siblings({"class": "android.widget.TextView"})
cousin = element.get_cousin(
cousin_locator={"text": "Apps"},
depth_to_parent=1
)
cousins = element.get_cousins(
cousin_locator={"class": "android.widget.TextView"},
depth_to_parent=2
)
Actions (input)
element = app.get_element({"resource-id": "search_field"})
element.send_keys("test query")
element.clear()
element.set_value("new value")
element.submit()
Gestures
element = app.get_element({"text": "Settings"})
element.tap()
element.tap(duration=3000)
element.tap_and_move(x=100, y=500)
element.tap_and_move(locator={"text": "Apps"})
element.tap_and_move(direction=0, distance=1000)
element.click()
element.click(duration=3000)
element.double_click()
element.drag(end_x=500, end_y=1000, speed=2500)
element.fling(speed=2500, direction="up")
element.fling_up(speed=2500)
element.fling_down()
element.fling_left()
element.fling_right()
recycler = app.get_element({"resource-id": "recycler_view"})
recycler.scroll(direction="down", percent=0.7, speed=2000)
recycler.scroll_down(percent=0.7)
recycler.scroll_up()
recycler.scroll_left()
recycler.scroll_right()
recycler.scroll_to_top(percent=0.7, speed=8000)
recycler.scroll_to_bottom()
target = recycler.scroll_to_element(
locator={"text": "About phone"},
max_swipes=30
)
element.swipe(direction="up", percent=0.75, speed=5000)
element.swipe_up()
element.swipe_down()
element.swipe_left()
element.swipe_right()
element.zoom(percent=0.75, speed=2500)
element.unzoom(percent=0.75, speed=2500)
Properties
element = app.get_element({"text": "Network & internet"})
text = element.get_attribute("text")
attrs = element.get_attributes()
content_desc = element.get_dom_attribute("content-desc")
prop = element.get_property("checked")
is_displayed = element.is_displayed()
is_visible = element.is_visible()
is_enabled = element.is_enabled()
is_selected = element.is_selected()
has_child = element.is_contains({"class": "android.widget.TextView"})
tag = element.tag_name
all_attrs = element.attributes
text = element.text
resource_id = element.resource_id
class_name = element.class_name
class_ = element.class_
index = element.index
package = element.package
bounds = element.bounds
checked = element.checked
checkable = element.checkable
enabled = element.enabled
focusable = element.focusable
focused = element.focused
long_clickable = element.long_clickable
password = element.password
scrollable = element.scrollable
selected = element.selected
displayed = element.displayed
size = element.size
location = element.location
rect = element.rect
location_in_view = element.location_in_view
shadow_root = element.shadow_root
css_value = element.value_of_css_property("color")
aria_role = element.aria_role
accessible_name = element.accessible_name
Coordinates
element = app.get_element({"text": "Settings"})
x, y, width, height = element.get_coordinates()
center_x, center_y = element.get_center()
loc = element.location_in_view
loc = element.location_once_scrolled_into_view
Screenshots
element = app.get_element({"text": "Settings"})
screenshot_b64 = element.screenshot_as_base64
screenshot_png = element.screenshot_as_png
success = element.save_screenshot("/tmp/element.png")
Waiting
element = app.get_element({"text": "Network & internet"})
element.wait(timeout=10, poll_frequency=0.5)
success = element.wait(timeout=10, return_bool=True)
element.wait_visible(timeout=10)
element.wait_clickable(timeout=10)
element.wait_for_not(timeout=10)
element.wait_for_not_visible(timeout=10)
element.wait_for_not_clickable(timeout=10)
Should (DSL assertions)
element = app.get_element({"text": "Settings"})
element.should.be_visible()
element.should.be_enabled()
element.should.have_text("Settings")
element.should.have_attribute("text", "Settings")
element.should.be_displayed()
element.should.be_clickable()
element.should.not_be_visible()
element.should.not_have_text("Other")
Native WebElement
element = app.get_element({"text": "Settings"})
native = element.get_native()
native.click()
PageBase
Abstract base class for Page Object pattern with automatic navigation.
Creating Page Object
from shadowstep import PageBaseShadowstep, Element
class PageSettings(PageBaseShadowstep):
"""Settings page representation."""
@property
def edges(self):
return {
"PageNetworkInternet": self.to_network_internet,
"PageAboutPhone": self.to_about_phone,
}
@property
def name(self) -> str:
return "Settings"
@property
def title(self) -> Element:
return self.shadowstep.get_element({
"text": "Settings",
"resource-id": "com.android.settings:id/homepage_title"
})
@property
def recycler(self) -> Element:
return self.shadowstep.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})
@property
def network_internet(self) -> Element:
return self.recycler.scroll_to_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})
@property
def network_internet_summary(self) -> Element:
return self.network_internet.get_sibling({
"resource-id": "android:id/summary"
})
@property
def about_phone(self) -> Element:
return self.recycler.scroll_to_element({
"text": "About phone"
})
def to_network_internet(self):
"""Navigate to Network & Internet page."""
self.network_internet.tap()
return self.shadowstep.get_page("PageNetworkInternet")
def to_about_phone(self):
"""Navigate to About Phone page."""
self.about_phone.tap()
return self.shadowstep.get_page("PageAboutPhone")
def is_current_page(self) -> bool:
"""Check if Settings page is currently displayed."""
try:
return self.title.is_visible()
except Exception:
return False
Using Page Objects
settings = app.get_page("PageSettings")
assert settings.is_current_page()
print(settings.network_internet.text)
print(settings.network_internet_summary.text)
network_page = settings.to_network_internet()
assert network_page.is_current_page()
PageSettings.clear_instance()
Automatic Navigation (Navigator)
Navigator automatically finds paths between pages through the graph.
from shadowstep.navigator import PageNavigator
app.navigator.list_registered_pages()
current_page = app.get_page("PageSettings")
target_page = app.get_page("PageAboutPhone")
success = app.navigator.navigate(
from_page=current_page,
to_page=target_page,
timeout=10
)
Additional Modules
Navigator
Graph-based navigation system between pages.
How it Works
- Each page defines
edges — relationships with other pages
- Navigator builds a graph from all pages
- During navigation, uses shortest path algorithm (NetworkX or BFS fallback)
from shadowstep.navigator import PageNavigator
navigator = PageNavigator(app)
navigator.auto_discover_pages()
page = PageSettings()
navigator.add_page(page, edges=page.edges)
path = navigator.find_path(
start=PageSettings(),
target=PageAboutPhone()
)
navigator.perform_navigation(path, timeout=10)
success = navigator.navigate(
from_page=PageSettings(),
to_page=PageAboutPhone(),
timeout=10
)
Locator System
Flexible locator system supporting three formats: dict, xpath, UiSelector.
Locator Types
1. Dictionary (Shadowstep Dict)
locator = {"text": "Settings"}
locator = {
"text": "Network & internet",
"resource-id": "android:id/title",
"class": "android.widget.TextView"
}
locator = {"textContains": "Network"}
locator = {"textStartsWith": "Net"}
locator = {"textMatches": "Net.*"}
locator = {
"text": "Settings",
"clickable": True,
"index": 0,
"instance": 0
}
2. XPath
locator = ("xpath", '//android.widget.TextView[@text="Settings"]')
locator = ("xpath", '//android.widget.TextView[contains(@text, "Network")]')
locator = ("xpath", '//android.widget.TextView[starts-with(@text, "Net")]')
locator = ("xpath", '//*[@resource-id="android:id/title" and @text="Settings"]')
locator = ("xpath", '(//android.widget.TextView)[1]')
locator = ("xpath", '//android.widget.ScrollView//android.widget.TextView')
3. UiSelector
from shadowstep.locator import UiSelector
locator = UiSelector().text("Settings")
locator = (UiSelector()
.text("Network & internet")
.resourceId("android:id/title")
.className("android.widget.TextView"))
locator = UiSelector().textContains("Network")
locator = UiSelector().textStartsWith("Net")
locator = UiSelector().textMatches("Net.*")
locator = UiSelector().clickable(True).enabled(True)
locator = UiSelector().className("android.widget.TextView").index(0)
locator = UiSelector().className("android.widget.TextView").instance(2)
locator = UiSelector().description("Phone")
locator = UiSelector().descriptionContains("Pho")
locator = UiSelector().packageName("com.android.settings")
parent = UiSelector().className("android.widget.ScrollView")
child = UiSelector().text("Settings")
locator = parent.childSelector(child)
locator = UiSelector().text("Settings").fromParent(UiSelector().className("android.widget.LinearLayout"))
Locator Conversion
from shadowstep.locator import LocatorConverter
converter = LocatorConverter()
dict_loc = {"text": "Settings", "class": "android.widget.TextView"}
xpath = converter.dict_to_xpath(dict_loc)
ui_selector = converter.dict_to_ui_selector(dict_loc)
ui_loc = UiSelector().text("Settings").clickable(True)
dict_loc = converter.ui_selector_to_dict(str(ui_loc))
xpath = converter.ui_selector_to_xpath(str(ui_loc))
xpath = '//android.widget.TextView[@text="Settings"]'
dict_loc = converter.xpath_to_dict(xpath)
ui_selector = converter.xpath_to_ui_selector(xpath)
Terminal
Two options for command execution: via Appium (Terminal) and via SSH (Transport).
Terminal (via Appium)
terminal = app.terminal
result = terminal.adb_shell(command="dumpsys", args="window windows")
result = terminal.adb_shell(command="pm", args="list packages")
terminal.start_activity(package="com.android.settings", activity=".Settings")
terminal.close_app(package="com.android.settings")
terminal.reboot_app(package="com.android.settings", activity=".Settings")
package = terminal.get_current_app_package()
is_installed = terminal.is_app_installed(package="com.android.settings")
terminal.uninstall_app(package="com.android.settings")
terminal.press_home()
terminal.press_back()
terminal.press_menu()
terminal.input_keycode(keycode="KEYCODE_ENTER")
terminal.input_keycode_num_(num=5)
terminal.input_text(text="hello")
terminal.tap(x=500, y=1000)
terminal.swipe(start_x=500, start_y=1000, end_x=500, end_y=500, duration=300)
terminal.swipe_right_to_left(duration=300)
terminal.swipe_left_to_right()
terminal.swipe_top_to_bottom()
terminal.swipe_bottom_to_top()
is_connected = terminal.check_vpn(ip_address="192.168.1.1")
pid = terminal.know_pid(name="logcat")
exists = terminal.is_process_exist(name="logcat")
terminal.kill_by_pid(pid=1234)
terminal.kill_by_name(name="logcat")
terminal.kill_all(name="logcat")
terminal.run_background_process(command="logcat", args="-v time", process="logcat")
terminal.delete_file_from_internal_storage(path="/sdcard", filename="test.txt")
terminal.delete_files_from_internal_storage(path="/sdcard/Download")
terminal.record_video(time_limit=180000)
video_bytes = terminal.stop_video()
terminal.reboot()
width, height = terminal.get_screen_resolution()
properties = terminal.get_prop()
hardware = terminal.get_prop_hardware()
model = terminal.get_prop_model()
serial = terminal.get_prop_serial()
build = terminal.get_prop_build()
device = terminal.get_prop_device()
packages = terminal.get_packages()
wifi_ip = terminal.get_wifi_ip()
terminal.past_text(text="Hello World", tries=3)
Transport (via SSH)
IMPORTANT: SSH was removed from Terminal and is now only available via Transport.
app.connect(
capabilities={...},
server_ip="192.168.1.100",
ssh_user="user",
ssh_password="password"
)
ssh_client = app.transport.ssh
stdin, stdout, stderr = ssh_client.exec_command("adb devices")
output = stdout.read().decode()
scp_client = app.transport.scp
scp_client.put("local_file.txt", remote_path="/tmp/remote_file.txt")
scp_client.get("/tmp/remote_file.txt", local_path="downloaded_file.txt")
scp_client.put("local_folder", remote_path="/tmp/remote_folder", recursive=True)
ADB (local)
adb = app.adb
devices = adb.get_devices()
model = adb.get_device_model(udid="emulator-5554")
adb.push(source="local.txt", destination="/sdcard/file.txt", udid="emulator-5554")
adb.pull(source="/sdcard/file.txt", destination="local.txt", udid="emulator-5554")
adb.install_app(source="app.apk", udid="emulator-5554")
adb.is_app_installed(package="com.example.app")
adb.uninstall_app(package="com.example.app")
adb.start_activity(package="com.android.settings", activity=".Settings")
adb.get_current_activity()
adb.get_current_package()
adb.close_app(package="com.android.settings")
adb.reboot_app(package="com.android.settings", activity=".Settings")
adb.press_home()
adb.press_back()
adb.press_menu()
adb.input_keycode(keycode="KEYCODE_ENTER")
adb.input_keycode_num_(num=5)
adb.input_text(text="hello")
adb.tap(x=500, y=1000)
adb.swipe(start_x=500, start_y=1000, end_x=500, end_y=500, duration=300)
adb.check_vpn(ip_address="192.168.1.1")
adb.stop_logcat()
adb.is_process_exist(name="logcat")
adb.run_background_process(command="logcat -v time &", process="logcat")
pid = adb.know_pid(name="logcat")
adb.kill_by_pid(pid=1234)
adb.kill_by_name(name="logcat")
adb.kill_all(name="logcat")
adb.reload_adb()
adb.delete_files_from_internal_storage(path="/sdcard/Download")
process = adb.record_video(path="/sdcard/Movies", filename="recording.mp4")
adb.stop_video()
adb.pull_video(source="/sdcard/Movies", destination="./videos", delete=True)
adb.reboot()
width, height = adb.get_screen_resolution()
packages = adb.get_packages_list()
output = adb.execute(command="shell getprop ro.build.version.release")
Logcat
Android log capture via WebSocket with filtering and automatic reconnection.
app.start_logcat(filename="logcat.log")
app._logcat.filters = ["ActivityManager", "System.out"]
app.start_logcat(filename="filtered_logcat.log")
app.stop_logcat()
with app._logcat:
app._logcat.start(filename="logcat.log")
logcat = app._logcat
logcat.filters = ["MyApp", "Firebase"]
Features:
- Works via WebSocket to Appium server
- Automatic reconnection on connection drops
- Buffered file writing (buffering=1)
- Tag filtering with regex
- Graceful shutdown with proper file closing
Image Recognition
Find elements by images using OpenCV.
image_path = "tests/_test_data/connected_devices.png"
image = app.get_image(
image=image_path,
threshold=0.5,
timeout=5.0
)
from PIL import Image
pil_image = Image.open("icon.png")
image = app.get_image(image=pil_image, threshold=0.8)
image.tap()
image.wait(timeout=10)
if image.is_visible():
print("Image found on screen")
x, y = image.get_center()
coords = image.get_coordinates()
images = app.get_images(image=image_path, threshold=0.7)
for img in images:
img.tap()
screenshot = app.get_screenshot()
Page Object Generator
Automatic generation of Page Object classes from UI XML dump.
from shadowstep.page_object import (
PageObjectGenerator,
PageObjectParser,
UiElementNode
)
xml_source = app.driver.page_source
parser = PageObjectParser()
ui_tree: UiElementNode = parser.parse(xml_source)
generator = PageObjectGenerator()
output_path, class_name = generator.generate(
ui_element_tree=ui_tree,
output_dir="./generated_pages",
filename_prefix="page_"
)
print(f"Generated: {output_path}")
print(f"Class: {class_name}")
Capabilities:
- Auto-detection of title, recycler
- Recognition of anchor-switcher pairs (for switch elements)
- Recognition of anchor-summary pairs
- Filtering structural containers
- Generation of navigation methods
- Uses Jinja2 templates
- Supports translator (optional)
Page Object Merger:
from shadowstep.page_object import PageObjectMerger
merger = PageObjectMerger()
merger.add_dump(xml_source_1)
merger.add_dump(xml_source_2)
merger.add_dump(xml_source_3)
merged_tree = merger.merge()
generator.generate(
ui_element_tree=merged_tree,
output_dir="./pages"
)
Page Object Test Generator:
from shadowstep.page_object import PageObjectTestGenerator
test_generator = PageObjectTestGenerator()
test_path = test_generator.generate(
page_class_name="PageSettings",
output_dir="./tests",
page_module="pages.page_settings"
)
Usage Examples
Basic Testing
from shadowstep import Shadowstep
def test_settings_navigation():
app = Shadowstep()
app.connect(
capabilities={
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:appPackage": "com.android.settings",
"appium:appActivity": ".Settings",
}
)
network = app.get_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})
assert network.is_visible()
network.tap()
title = app.get_element({"text": "Network & internet"})
assert title.wait_visible(timeout=5)
app.disconnect()
Working with Forms
def test_search_form():
app = Shadowstep()
search_field = app.get_element({
"resource-id": "com.android.quicksearchbox:id/search_widget_text"
})
search_field.tap()
search_input = app.get_element({
"resource-id": "com.android.quicksearchbox:id/search_src_text"
})
search_input.wait_visible(timeout=3)
search_input.send_keys("test query")
assert "test query" in search_input.text
search_input.clear()
assert search_input.text == ""
Scrolling and Search
def test_scroll_to_element():
app = Shadowstep()
recycler = app.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})
about_phone = recycler.scroll_to_element(
locator={"text": "About phone"},
max_swipes=30
)
assert about_phone.is_visible()
about_phone.tap()
DOM Navigation Example
def test_dom_navigation():
app = Shadowstep()
network = app.get_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})
summary = network.get_sibling({
"resource-id": "android:id/summary"
})
print(f"Summary: {summary.text}")
parent = network.get_parent()
print(f"Parent class: {parent.class_name}")
cousin = network.get_cousin(
cousin_locator={"resource-id": "android:id/summary"},
depth_to_parent=1
)
Multiple Elements
def test_multiple_elements():
app = Shadowstep()
textviews = app.get_elements({
"class": "android.widget.TextView"
})
for tv in textviews:
text = tv.text
if text and "Settings" not in text:
print(f"Found: {text}")
Gestures and Animations
def test_gestures():
app = Shadowstep()
icon = app.get_element({"content-desc": "Gallery"})
x1, y1 = icon.get_center()
icon.drag(end_x=x1 + 200, end_y=y1, speed=2500)
x2, y2 = icon.get_center()
assert x2 > x1
icon.drag(end_x=x1, end_y=y1, speed=2500)
recycler = app.get_element({"resource-id": "recycler_view"})
recycler.fling_up(speed=5000)
Page Object with Navigation
from shadowstep import PageBaseShadowstep, Element
class PageSettings(PageBaseShadowstep):
@property
def edges(self):
return {
"PageNetwork": self.to_network,
"PageApps": self.to_apps,
}
@property
def recycler(self) -> Element:
return self.shadowstep.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})
@property
def network(self) -> Element:
return self.recycler.scroll_to_element({"text": "Network & internet"})
@property
def apps(self) -> Element:
return self.recycler.scroll_to_element({"text": "Apps"})
def to_network(self):
self.network.tap()
return self.shadowstep.get_page("PageNetwork")
def to_apps(self):
self.apps.tap()
return self.shadowstep.get_page("PageApps")
def is_current_page(self) -> bool:
title = self.shadowstep.get_element({"text": "Settings"})
return title.is_visible()
def test_page_navigation():
app = Shadowstep()
settings = app.get_page("PageSettings")
assert settings.is_current_page()
network = settings.to_network()
assert network.is_current_page()
Screenshots and Logs
def test_with_logs_and_screenshots():
app = Shadowstep()
app.start_logcat(filename="test_logs.log")
try:
element = app.get_element({"text": "Settings"})
element.tap()
app.save_screenshot(path="./screenshots", filename="settings.png")
element.save_screenshot("./screenshots/element.png")
finally:
app.stop_logcat()
app.disconnect()
Working with Images
def test_image_recognition():
app = Shadowstep()
icon = app.get_image(
image="icons/settings_icon.png",
threshold=0.8,
timeout=10
)
if icon.is_visible():
icon.tap()
x, y = icon.get_center()
print(f"Icon center: {x}, {y}")
Working with ADB and SSH
def test_adb_commands():
app = Shadowstep()
app.terminal.start_activity(
package="com.android.settings",
activity=".Settings"
)
package = app.terminal.get_current_app_package()
assert "settings" in package.lower()
devices = app.adb.get_devices()
print(f"Connected devices: {devices}")
model = app.adb.get_device_model(udid="emulator-5554")
print(f"Device model: {model}")
def test_ssh_commands():
app = Shadowstep()
app.connect(
capabilities={...},
server_ip="192.168.1.100",
ssh_user="user",
ssh_password="password"
)
stdin, stdout, stderr = app.transport.ssh.exec_command("adb devices")
output = stdout.read().decode()
print(output)
app.transport.scp.put("local.txt", remote_path="/tmp/remote.txt")
app.transport.scp.get("/tmp/remote.txt", local_path="downloaded.txt")
Quality Tools
The project uses modern tools to ensure code quality:
Linters and Formatters
uv run ruff check .
uv run ruff format .
uv run pyright
Testing
uv run pytest
uv run pytest tests/test_unit
uv run pytest tests/test_integro
uv run pytest --cov=shadowstep --cov-report=html
uv run pytest --reruns 3 --reruns-delay 1
Pre-commit Hooks
uv run pre-commit install
uv run pre-commit run --all-files
Configuration
Tool settings are in pyproject.toml:
- Ruff:
select = ["ALL"] with docstring style conflict ignoring
- Pyright:
typeCheckingMode = "strict" for maximum type safety
- Pytest: logging, short traceback, setup show
Additional Information
Supported Python Versions
- Python 3.9+
- Python 3.10
- Python 3.11
- Python 3.12
- Python 3.13
Links
License
MIT License
Contributing
The project follows:
- Clean Architecture — separation of concerns
- Clean Code — readability and maintainability
- Best Practices — design patterns
- Type Safety — strict typing (Pyright strict mode)
- PEP 8 — Python coding style
When developing, use:
- Strict typing with
typing
- Docstrings in English
- Comments in English
- Type hints for all functions and methods
- Pyright strict mode
- Ruff for linting
Author: Molokov Klim
Email: ultrakawaii9654449192@gmail.com