Shadowstep (in development)
Shadowstep is a modular UI automation framework for Android applications built on top of Appium.
- Lazy element lookup and interaction (driver is touched only when necessary)
- PageObject generation
- PageObject navigation engine with page auto-discovery
- Reconnect logic on session loss
- Integration with ADB and an Appium/SSH "terminal"
- DSL-style assertions for readable checks (
should.have
, should.be
)
- Image-based actions on screen
Contents
Installation
pip install appium-python-client-shadowstep
Quick Start
from shadowstep.shadowstep import Shadowstep
application = Shadowstep()
capabilities = {
"platformName": "android",
"appium:automationName": "uiautomator2",
"appium:UDID": "192.168.56.101:5555",
"appium:noReset": True,
"appium:autoGrantPermissions": True,
"appium:newCommandTimeout": 900,
}
application.connect(server_ip='127.0.0.1', server_port=4723, capabilities=capabilities)
- You may pass
command_executor
directly (e.g., http://127.0.0.1:4723/wd/hub
), then server_ip/port
are optional.
- If you pass
capabilities
as a dict
, they will be converted into UiAutomator2Options
internally.
Test Setup (Pytest)
Session-scoped fixture example:
import pytest
from shadowstep.shadowstep import Shadowstep
@pytest.fixture(scope='session', autouse=True)
def app():
application = Shadowstep()
APPIUM_IP = '127.0.0.1'
APPIUM_PORT = 4723
APPIUM_COMMAND_EXECUTOR = f'http://{APPIUM_IP}:{APPIUM_PORT}/wd/hub'
capabilities = {
"platformName": "android",
"appium:automationName": "uiautomator2",
"appium:UDID": "192.168.56.101:5555",
"appium:noReset": True,
"appium:autoGrantPermissions": True,
"appium:newCommandTimeout": 900,
}
application.connect(server_ip=APPIUM_IP,
server_port=APPIUM_PORT,
command_executor=APPIUM_COMMAND_EXECUTOR,
capabilities=capabilities)
yield application
application.disconnect()
Run tests:
pytest -svl --log-cli-level INFO --tb=short tests/test_shadowstep.py
Run Appium server locally:
npm i -g appium@next
appium driver install uiautomator2
appium server -ka 800 --log-level debug -p 4723 -a 0.0.0.0 -pa /wd/hub --allow-insecure=adb_shell
Element API (Element
)
el = app.get_element({"resource-id": "android:id/title"})
el.tap()
el.text
el.get_attribute("enabled")
Call chains
el = app.get_element({"resource-id": "android:id/title"})
el.zoom().click()
Lazy DOM navigation (declarative):
el = app.get_element({'class': 'android.widget.ImageView'}).\
get_parent().\
get_sibling({'resource-id': 'android:id/summary'}).\
get_cousin(cousin_locator={'resource-id': 'android:id/summary'}).\
get_element({"resource-id": "android:id/switch_widget"})
Key features:
-
Lazy evaluation: the actual find_element
happens on the first interaction with an element:
el = app.get_element({'class': 'android.widget.ImageView'}) # find_element is not called here
el.swipe_left() # find_element is called here
-
Locators: dict
and XPath (tuples default to XPath strategy)
-
Built-in retries and auto-reconnect on session failures
-
Rich API: tap
, click
, scroll_to
, get_sibling
, get_parent
, drag_to
, send_keys
, wait_visible
, and more
DSL Assertions
item = app.get_element({'text': 'Network & internet'})
item.should.have.text("Network & internet").have.resource_id("android:id/title")
item.should.be.visible()
item.should.not_be.focused()
See more examples in tests/test_element_should.py
.
Page Objects and Navigation
Base page class is PageBaseShadowstep
.
A page must:
- inherit from
PageBaseShadowstep
- have class name starting with
Page
- provide
edges: Dict[str, Callable[[], PageBaseShadowstep]]
— navigation graph edges
- implement
is_current_page()
Example page:
import logging
from shadowstep.element.element import Element
from shadowstep.page_base import PageBaseShadowstep
class PageAbout(PageBaseShadowstep):
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
def __repr__(self):
return f"{self.name} ({self.__class__.__name__})"
@property
def edges(self):
return {"PageMain": self.to_main}
def to_main(self):
self.shadowstep.terminal.press_back()
return self.shadowstep.get_page("PageMain")
@property
def name(self) -> str:
return "About"
@property
def title(self) -> Element:
return self.shadowstep.get_element(locator={'text': 'About', 'class': 'android.widget.TextView'})
def is_current_page(self) -> bool:
try:
return self.title.is_visible()
except Exception as error:
self.logger.error(error)
return False
Auto-discovery of pages:
- classes inheriting
PageBaseShadowstep
and starting with Page
- files
page*.py
(usually pages/page_*.py
) in project paths
- pages are registered automatically when
Shadowstep
is created
Navigation:
app.shadowstep.navigator.navigate(from_page=app.page_main, to_page=app.page_display)
assert app.page_display.is_current_page()
ADB and Terminal
Two ways to perform low-level actions:
app.adb.*
— direct ADB via subprocess
(good for local runs)
app.terminal.*
— mobile: shell
via Appium or SSH transport (if ssh_user/ssh_password
were provided in connect()
)
ADB examples:
app.adb.press_home()
app.adb.install_app(source="/path/app._apk", udid="192.168.56.101:5555")
app.adb.input_text("hello")
Terminal examples:
app.terminal.start_activity(package="com.example", activity=".MainActivity")
app.terminal.tap(x=1345, y=756)
app.terminal.past_text(text='hello')
Image Operations
image = app.get_image(image="tests/_test_data/connected_devices.png", threshold=0.5, timeout=3.0)
assert image.is_visible()
image.tap()
image.scroll_down(max_attempts=3)
image.zoom().unzoom().drag(to=(100, 100))
Under the hood it uses opencv-python
, numpy
, Pillow
.
Logcat Logs
app.start_logcat("device.logcat")
app.stop_logcat()
Architecture Notes
- The element tree is not fetched upfront
- Reconnects on session loss (
InvalidSessionIdException
, NoSuchDriverException
)
- Works well with Pytest and CI/CD
- Modular architecture:
element
, elements
, navigator
, terminal
, image
, utils
Page Object module (generation)
Tools to automatically generate PageObject classes from UI XML (uiautomator2), enrich them while scrolling, merge results, and generate baseline tests.
- Generate
PageObject
from current page_source
via Jinja2 template
- Detect title, main container (recycler/scrollable), anchors and related elements (summary/switch)
- Discover additional items inside scrollable lists and merge results
- Generate a simple test class for quick smoke coverage of page properties
Components
Note: crawler.py
and scenario.py
are conceptual notes/ideas, not stable API.
Quick Start (PO generation)
- Capture XML and generate a page class
from shadowstep.shadowstep import Shadowstep
from shadowstep.page_object.page_object_parser import PageObjectParser
from shadowstep.page_object.page_object_generator import PageObjectGenerator
app = Shadowstep.get_instance()
xml = app.driver.page_source
parser = PageObjectParser()
tree = parser.parse(xml)
pog = PageObjectGenerator()
path, class_name = pog.generate(ui_element_tree=tree, output_dir="pages")
print(path, class_name)
- Explore recycler and merge results
from shadowstep.page_object.page_object_recycler_explorer import PageObjectRecyclerExplorer
explorer = PageObjectRecyclerExplorer(base=app, translator=None)
merged_path = explorer.explore(output_dir="pages")
print(merged_path)
- Generate a test for the page
from shadowstep.page_object.page_object_test_generator import PageObjectTestGenerator
tg = PageObjectTestGenerator()
test_path, test_class_name = tg.generate_test(input_path=path, class_name=class_name, output_dir="tests/pages")
print(test_path, test_class_name)
Templates
templates/page_object.py.j2
— PageObject Python class template
templates/page_object_test.py.j2
— Pytest class template
To tweak generated code structure, edit these files. (The generator uses the local templates
folder.)
Limitations and Details
- Focused on Android (XML and uiautomator2 attributes)
- Generator heuristics:
- Find
title
via text
/content-desc
- Treat
scrollable==true
container as recycler
if present
- Switch ↔ anchor pairs,
summary
fields, filtering structural/non-informative classes
- Remove
text
from locators for classes where text search is not supported
PageObjectRecyclerExplorer
requires an active session and mobile: shell
capability; uses swipes and adb_shell
- Merge result is saved as a separate file (see prefix/path in
explore()
)
Code References
shadowstep/page_object/page_object_parser.py
shadowstep/page_object/page_object_generator.py
shadowstep/page_object/page_object_recycler_explorer.py
shadowstep/page_object/page_object_merger.py
shadowstep/page_object/page_object_test_generator.py
Known issues
For some reason, jinja templates are not downloaded to the folder when installed via pip.
Insert them manually from this sources into .venv/Lib/site-packages/shadowstep/page_object/templates/
I don't know how to solve this yet.
start_logcat (mobile: startLogsBroadcast) is not working with my Selenium Grid now, need experiments with plugins.
Solve it later
Limitations
- Android only (no iOS or Web)
License
MIT — see LICENSE
.