diff --git a/__tests__/helpers/validate-python-output.py b/__tests__/helpers/validate-python-output.py new file mode 100644 index 0000000..24beb54 --- /dev/null +++ b/__tests__/helpers/validate-python-output.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Validates a generated Python pytest zip produced by PythonAppiumScriptGenerator. + +Usage: python3 validate-python-output.py +Exit 0 = all checks passed. Exit 1 = at least one check failed (details on stdout). + +Checks performed: + 1. ast.parse() succeeds on every .py file (catches ALL syntax/indentation errors) + 2. test_app.py imports AppiumBy and By (locator code needs both at module scope) + 3. config.py uses Python True/False (not JS true/false) + 4. config.py has @classmethod at 4-space indent (inside class body, not at col 0) + 5. test_suite.py: finally at 4-space indent (peer of try/except, not nested inside except) + 6. test_suite.py: every def test_* at module level (no leading spaces, drift check) +""" + +import ast +import re +import sys +import zipfile + + +def validate(zip_path): + errors = [] + + with zipfile.ZipFile(zip_path) as zf: + py_files = { + name: zf.read(name).decode('utf-8') + for name in zf.namelist() + if name.endswith('.py') + } + + if not py_files: + errors.append('zip contains no .py files') + return errors + + # ------------------------------------------------------------------ + # 1. Syntax check — ast.parse catches IndentationError, SyntaxError, + # wrong boolean literals, and broken string literals. + # ------------------------------------------------------------------ + for name, src in sorted(py_files.items()): + try: + ast.parse(src) + except SyntaxError as exc: + errors.append(f'{name}: SyntaxError line {exc.lineno}: {exc.msg}') + + # ------------------------------------------------------------------ + # 2. test_app.py must have AppiumBy and By imported at module scope. + # Generated locator code uses these names; without the imports the + # script raises NameError at runtime before any test runs. + # ------------------------------------------------------------------ + test_app = py_files.get('test_app.py', '') + if 'from appium.webdriver.common.appiumby import AppiumBy' not in test_app: + errors.append( + 'test_app.py: missing import ' + '"from appium.webdriver.common.appiumby import AppiumBy"' + ) + if 'from selenium.webdriver.common.by import By' not in test_app: + errors.append( + 'test_app.py: missing import ' + '"from selenium.webdriver.common.by import By"' + ) + + # ------------------------------------------------------------------ + # 3. config.py: generated boolean capabilities must use Python + # True/False, not JavaScript true/false. + # Pattern: a dict-value position — '...': true or '...': false + # ------------------------------------------------------------------ + config = py_files.get('config.py', '') + js_bool_re = re.compile(r"^\s+'[^']+': (true|false)[,\s]*$") + for lineno, line in enumerate(config.splitlines(), 1): + if js_bool_re.match(line): + errors.append( + f'config.py line {lineno}: JS boolean literal ' + f'(must be True/False): {line.rstrip()}' + ) + + # ------------------------------------------------------------------ + # 4. config.py: @classmethod must be indented exactly 4 spaces. + # When the first-line absorber was missing, @classmethod landed + # at column 0, placing it outside the class body. + # ------------------------------------------------------------------ + if ' @classmethod\n' not in config and not config.endswith(' @classmethod'): + errors.append( + 'config.py: no @classmethod found at 4-space indent — ' + 'generated methods are outside the class body' + ) + + # ------------------------------------------------------------------ + # 5. test_suite.py: finally must be at 4-space indent (same level + # as try/except). The original bug nested it at 8 spaces inside + # the except block, causing an immediate SyntaxError. + # ------------------------------------------------------------------ + test_suite = py_files.get('test_suite.py', '') + for lineno, line in enumerate(test_suite.splitlines(), 1): + if line.startswith(' finally:'): + errors.append( + f'test_suite.py line {lineno}: "finally:" is at 8-space indent ' + f'(nested inside except block — must be at 4-space, peer of try/except)' + ) + + # ------------------------------------------------------------------ + # 6. test_suite.py: every def test_* must start at column 0. + # When per-device desiredCaps methods leaked +1 indent per device, + # the second (and later) test functions shifted right and became + # nested, making pytest unable to discover them. + # ------------------------------------------------------------------ + for lineno, line in enumerate(test_suite.splitlines(), 1): + stripped = line.lstrip() + if stripped.startswith('def test_') and line != stripped: + errors.append( + f'test_suite.py line {lineno}: test function not at module level ' + f'(leading whitespace = {len(line) - len(stripped)} spaces): ' + f'{line.rstrip()}' + ) + + return errors + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print('Usage: validate-python-output.py ', file=sys.stderr) + sys.exit(2) + + found_errors = validate(sys.argv[1]) + + if found_errors: + for err in found_errors: + print(f'FAIL: {err}') + sys.exit(1) + + print(f'OK: all checks passed ({sys.argv[1]})') + sys.exit(0) diff --git a/__tests__/integration/python-pytest.test.js b/__tests__/integration/python-pytest.test.js new file mode 100644 index 0000000..8d6c01f --- /dev/null +++ b/__tests__/integration/python-pytest.test.js @@ -0,0 +1,37 @@ +import path from 'path' +import fs from 'fs' +import os from 'os' +import {execFileSync} from 'child_process' +import PythonAppiumScriptGenerator from '../../src/services/python' +import {removeDir} from '../../src/utils/fs-wrapper' + +const INPUT_FILE = path.resolve(__dirname, '../resource/python-pytest-input.json') +const VALIDATOR = path.resolve(__dirname, '../helpers/validate-python-output.py') + +describe('PythonAppiumScriptGenerator', () => { + it('generates a valid Python pytest script from a 2-device session fixture', async () => { + const input = JSON.parse(fs.readFileSync(INPUT_FILE, 'utf-8')) + const workingDir = path.join(os.tmpdir(), `python-pytest-test-${Date.now()}`) + + const generator = new PythonAppiumScriptGenerator({}) + const {outputFile} = await generator.run({...input, workingDir}) + + expect(outputFile).toBeTruthy() + expect(fs.existsSync(outputFile)).toBe(true) + + try { + execFileSync('python3', [VALIDATOR, outputFile], { + encoding: 'utf8', + stdio: 'pipe' + }) + } + catch (err) { + throw new Error( + `Python validation failed:\n${(err.stdout || '').trim()}\n${(err.stderr || '').trim()}` + ) + } + finally { + await removeDir(workingDir) + } + }, 30000) +}) diff --git a/__tests__/resource/python-pytest-input.json b/__tests__/resource/python-pytest-input.json new file mode 100644 index 0000000..4a95f00 --- /dev/null +++ b/__tests__/resource/python-pytest-input.json @@ -0,0 +1,180 @@ +{ + "sentAt": { + "low": 1105187238, + "high": 395, + "unsigned": true + }, + "serverInfo": { + "apiUrl": "https://api.kobiton.com", + "portalUrl": "https://portal.kobiton.com", + "username": "kobitonadmin+appiumScriptGen", + "apiKey": "your_api_key" + }, + "isManualSession": true, + "manualSessionId": "605", + "devices": [ + { + "id": 1881, + "name": "A5 2020", + "capabilities": { + "platformName": "Android", + "platformVersion": "9", + "resolution": { + "width": 720, + "height": 1600, + "scale": 1 + } + } + }, + { + "id": 1882, + "name": "Galaxy S22", + "capabilities": { + "platformName": "Android", + "platformVersion": "16", + "resolution": { + "width": 1080, + "height": 2340, + "scale": 1 + } + } + } + ], + "testSteps": [ + { + "id": "17000", + "context": "NATIVE", + "selectorConfigurations": [ + { + "device": { + "deviceName": "A5 2020", + "platformVersion": "9" + }, + "selectors": [ + { + "type": "xpath", + "value": "//*[@resource-id='com.android.chrome:id/url_bar']" + } + ] + }, + { + "selectors": [ + { + "type": "xpath", + "value": "//android.widget.EditText[@resource-id='com.android.chrome:id/url_bar']" + } + ] + } + ], + "actionJson": "{\"command\":\"touchOnElement\",\"x\":\"0.387\",\"y\":\"0.430\"}", + "findingElementTimeout": 7129, + "isOnKeyboard": false + }, + { + "id": "17001", + "context": "NATIVE", + "actionJson": "{\"command\":\"sendKeys\",\"value\":\"kobiton \"}", + "findingElementTimeout": 4014, + "isOnKeyboard": false + }, + { + "id": "17002", + "context": "NATIVE", + "actionJson": "{\"command\":\"press\",\"value\":\"ENTER\"}", + "findingElementTimeout": 1612, + "isOnKeyboard": false + }, + { + "id": "17003", + "context": "WEB", + "selectorConfigurations": [ + { + "selectors": [ + { + "type": "css", + "value": "a[href='https://kobiton.com/']" + } + ] + } + ], + "actionJson": "{\"command\":\"touchOnElement\",\"x\":\"0.359\",\"y\":\"0.300\"}", + "findingElementTimeout": 8965, + "isOnKeyboard": false + }, + { + "id": "17004", + "context": "NATIVE", + "actionJson": "{\"command\":\"rotate\",\"orientation\":\"LANDSCAPE\"}", + "findingElementTimeout": 14108, + "isOnKeyboard": false + }, + { + "id": "17005", + "context": "NATIVE", + "selectorConfigurations": [ + { + "selectors": [ + { + "type": "xpath", + "value": "(//android.webkit.WebView)[1]" + } + ] + } + ], + "actionJson": "{\"command\":\"swipeFromElement\",\"x1\":\"0.506\",\"y1\":\"0.815\",\"x2\":\"0.574\",\"y2\":\"0.251\",\"duration\":637}", + "findingElementTimeout": 13081, + "isOnKeyboard": false + }, + { + "id": "17006", + "context": "NATIVE", + "actionJson": "{\"command\":\"press\",\"value\":\"BACK\"}", + "findingElementTimeout": 6526, + "isOnKeyboard": false + } + ], + "appUnderTest": { + "id": "com.android.chrome" + }, + "desiredCapabilitiesOfDevices": [ + { + "deviceId": 1881, + "desiredCapabilities": [ + {"key": "sessionName", "value": "Automation on A5 2020", "type": "string"}, + {"key": "sessionDescription", "value": "", "type": "string"}, + {"key": "deviceOrientation", "value": "portrait", "type": "string"}, + {"key": "noReset", "value": "false", "type": "bool"}, + {"key": "fullReset", "value": "true", "type": "bool"}, + {"key": "captureScreenshots", "value": "true", "type": "bool"}, + {"key": "newCommandTimeout", "value": "900", "type": "int"}, + {"key": "keepScreenOn", "value": "true", "type": "bool"}, + {"key": "deviceGroup", "value": "KOBITON", "type": "string"}, + {"key": "deviceName", "value": "A5 2020", "type": "string"}, + {"key": "platformVersion", "value": "9", "type": "string"}, + {"key": "platformName", "value": "Android", "type": "string"} + ] + }, + { + "deviceId": 1882, + "desiredCapabilities": [ + {"key": "sessionName", "value": "Automation on Galaxy S22", "type": "string"}, + {"key": "sessionDescription", "value": "", "type": "string"}, + {"key": "deviceOrientation", "value": "portrait", "type": "string"}, + {"key": "noReset", "value": "false", "type": "bool"}, + {"key": "fullReset", "value": "false", "type": "bool"}, + {"key": "captureScreenshots", "value": "true", "type": "bool"}, + {"key": "newCommandTimeout", "value": "900", "type": "int"}, + {"key": "keepScreenOn", "value": "true", "type": "bool"}, + {"key": "deviceGroup", "value": "KOBITON", "type": "string"}, + {"key": "deviceName", "value": "Galaxy S22", "type": "string"}, + {"key": "platformVersion", "value": "16", "type": "string"}, + {"key": "platformName", "value": "Android", "type": "string"} + ] + } + ], + "requestScript": { + "name": "kobiton-appium-script-s605-python-pytest", + "language": "python", + "testingFramework": "pytest" + } +} diff --git a/src/services/python.js b/src/services/python.js index b64ee12..1e922ba 100644 --- a/src/services/python.js +++ b/src/services/python.js @@ -63,7 +63,9 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat _generateDesiredCapabilitiesMethodLines({ desiredCapabilitiesOfDevices, devices, deviceSource, appUnderTest }) { - const lines = [] + const lines = [ + new Line('') + ] const desiredCapsMethodNames = new Set() for (const device of devices) { @@ -102,7 +104,10 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat const parsedValue = this._parseValue(value, type) const suffix = index === desiredCapabilities.length - 1 ? '' : ',' let statement - if (typeof parsedValue === 'string') { + if (typeof parsedValue === 'boolean') { + statement = `'${key}': ${parsedValue ? 'True' : 'False'}${suffix}` + } + else if (typeof parsedValue === 'string') { statement = `'${key}': '${parsedValue}'${suffix}` } else { @@ -112,6 +117,7 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat }) lines.push(new Line('}', -1)) + lines.push(new Line('', -1)) } return lines @@ -137,9 +143,12 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat const retinaScale = get(deviceCaps, 'resolution.scale') || 1 const testFnName = snakeCase( - `test ${deviceName} ${get(device, 'capabilities.platformName')} ${get(device, 'capabilities.platformVersion')}` + `test ${deviceName} ${get(device, 'capabilities.platformName')} ` + + `${get(device, 'capabilities.platformVersion')}` ) - const testDescription = `Run test on ${deviceName} - ${get(device, 'capabilities.platformName')} ${get(device, 'capabilities.platformVersion')}` + const testDescription = + `Run test on ${deviceName} - ${get(device, 'capabilities.platformName')} ` + + `${get(device, 'capabilities.platformVersion')}` const capsCall = (DEVICE_SOURCES.KOBITON === deviceSource || appUnderTest.browserName) ? `Config.${desiredCapsMethodName}()` @@ -163,11 +172,12 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat new Line('error = err'), new Line('if automation_helper:'), new Line('automation_helper.save_debug_resource()', 1), - new Line('finally:', -1), + new Line('finally:', -2), new Line('if automation_helper:', 1), new Line('automation_helper.cleanup()', 1), new Line('', -1), - new Line('assert error is None, f"Test case has error: {error}"', -1) + new Line('assert error is None, f"Test case has error: {error}"', -1), + new Line('', -1) ]) } @@ -224,7 +234,12 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat case 'touchOnElement': { const {x, y} = action const elementVarName = `element${rawLocatorVarName}` - lines.push(new Line(`${elementVarName} = self.find_visible_element(${findingElementTimeout}, ${locatorVarName})`)) + lines.push( + new Line( + `${elementVarName} = self.find_visible_element(` + + `${findingElementTimeout}, ${locatorVarName})` + ) + ) lines.push(new Line(`self.touch_on_element(${elementVarName}, ${x}, ${y})`)) } break @@ -233,7 +248,12 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat resourceFiles[`${id}.json`] = JSON.stringify(elementInfo) !isOnKeyboard && lines.push(new Line('self.hide_keyboard()')) const elementVarName = `element${rawLocatorVarName}` - lines.push(new Line(`${elementVarName} = self.find_visible_element_on_scrollable(${findingElementTimeout}, ${locatorVarName})`)) + lines.push( + new Line( + `${elementVarName} = self.find_visible_element_on_scrollable(` + + `${findingElementTimeout}, ${locatorVarName})` + ) + ) lines.push(new Line(`self.touch_on_element(${elementVarName}, ${x}, ${y})`)) } break @@ -246,8 +266,18 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat const {x1, y1, x2, y2, duration} = action !isOnKeyboard && lines.push(new Line('self.hide_keyboard()')) const elementVarName = `element${rawLocatorVarName}` - lines.push(new Line(`${elementVarName} = self.find_visible_element(${findingElementTimeout}, ${locatorVarName})`)) - lines.push(new Line(`self.swipe_on_element(${elementVarName}, ${x1}, ${y1}, ${x2}, ${y2}, ${duration || 800})`)) + lines.push( + new Line( + `${elementVarName} = self.find_visible_element(` + + `${findingElementTimeout}, ${locatorVarName})` + ) + ) + lines.push( + new Line( + 'self.swipe_on_element(' + + `${elementVarName}, ${x1}, ${y1}, ${x2}, ${y2}, ${duration || 800})` + ) + ) } break case 'press': { @@ -266,9 +296,9 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat lines.push(new Line(`self.send_keys_to_active_element(${this._getString(value)})`)) } break - case 'idle': { + case 'idle': lines.push(new Line('self.idle()')) - } break + break case 'rotate': { const {orientation} = action @@ -314,7 +344,7 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat const configPath = path.join(outputDir, 'config.py') let configContent = await readFile(configPath, 'utf8') - const desiredCapsCode = this._buildPythonCode(desiredCapsMethodLines, 4) + const desiredCapsCode = this._buildPythonCode(desiredCapsMethodLines, 1) configContent = configContent.replace(' #{{desiredCaps}}', desiredCapsCode) configContent = configContent.replace('{{username}}', serverInfo.username || '') configContent = configContent.replace('{{appiumServerUrl}}', appiumServerUrl) @@ -323,8 +353,11 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat const testAppPath = path.join(outputDir, 'test_app.py') let testAppContent = await readFile(testAppPath, 'utf8') - const testScriptCode = this._buildPythonCode(testScriptLines, 8) - testAppContent = testAppContent.replace(' {{testScript}}', testScriptCode || ' pass') + const testScriptCode = this._buildPythonCode(testScriptLines, 2) + testAppContent = testAppContent.replace( + ' {{testScript}}', + testScriptCode || ' pass' + ) await writeFile(testAppPath, testAppContent) const testSuitePath = path.join(outputDir, 'test_suite.py') @@ -381,7 +414,7 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat throw new Error(`Unsupported selector type: ${selector.type}`) } - return `(${appiumBy}, "${value}")` + return `(${appiumBy}, '${value}')` } const lines = [] @@ -397,16 +430,24 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat ifStatement = 'else:' } else if (index === 0) { - ifStatement = `if '${deviceName}' == device_name and '${platformVersion}' == platform_version:` + ifStatement = + `if '${deviceName}' == self._device_name and ` + + `'${platformVersion}' == self._platform_version:` } else { - ifStatement = `elif '${deviceName}' == device_name and '${platformVersion}' == platform_version:` + ifStatement = + `elif '${deviceName}' == self._device_name and ` + + `'${platformVersion}' == self._platform_version:` } const locatorsStatements = selectors.map((selector) => getLocatorStatement({selector})) - lines.push(new Line(ifStatement)) - lines.push(new Line(`${locatorVarName} = [${locatorsStatements.join(', ')}]`, 1)) + const branchOffset = index === 0 ? 0 : -1 + lines.push(new Line(ifStatement, branchOffset)) + lines.push( + new Line(`${locatorVarName} = [${locatorsStatements.join(', ')}]`, 1) + ) }) + lines.push(new Line('', -1)) } else if (selectorConfigurations.length === 1) { const {selectors} = selectorConfigurations[0] @@ -426,7 +467,7 @@ export default class PythonAppiumScriptGenerator extends BaseAppiumScriptGenerat if (isString(value)) { str = value .replace(/\\/g, '\\\\') - .replace(/'/g, "\\'") + .replace(/'/g, '\\\'') } else { str = value diff --git a/src/templates/python/config.py b/src/templates/python/config.py index ff67a5f..5ba989c 100644 --- a/src/templates/python/config.py +++ b/src/templates/python/config.py @@ -21,7 +21,8 @@ class Config: @classmethod def get_appium_server_url_with_auth(cls): parsed = urlparse(cls.APPIUM_SERVER_URL) - return f"{parsed.scheme}://{cls.API_USERNAME}:{cls.API_KEY}@{parsed.hostname}:{parsed.port}{parsed.path}" + port_part = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://{cls.API_USERNAME}:{cls.API_KEY}@{parsed.hostname}{port_part}{parsed.path}" @classmethod def get_basic_auth_string(cls): diff --git a/src/templates/python/proxy_server.py b/src/templates/python/proxy_server.py index 209bde2..70c9a10 100644 --- a/src/templates/python/proxy_server.py +++ b/src/templates/python/proxy_server.py @@ -9,7 +9,7 @@ class ProxyHandler(BaseHTTPRequestHandler): current_command_id = 0 def do_request(self, method, body=None): - target_url = Config.get_appium_server_url_with_auth().replace('/wd/hub', '') + target_url = Config.APPIUM_SERVER_URL.replace('/wd/hub', '') url = f"{target_url}{self.path}" if self.current_command_id: @@ -17,6 +17,7 @@ def do_request(self, method, body=None): url = f"{url}{separator}baseCommandId={self.current_command_id}" headers = {key: val for key, val in self.headers.items()} + headers['Authorization'] = Config.get_basic_auth_string() req = Request(url, data=body, headers=headers, method=method) try: diff --git a/src/templates/python/test_app.py b/src/templates/python/test_app.py index f063118..817b59c 100644 --- a/src/templates/python/test_app.py +++ b/src/templates/python/test_app.py @@ -1,5 +1,7 @@ from test_base import TestBase from config import Config +from appium.webdriver.common.appiumby import AppiumBy +from selenium.webdriver.common.by import By class TestApp(TestBase): diff --git a/src/templates/python/test_base.py b/src/templates/python/test_base.py index a453df3..a05cb81 100644 --- a/src/templates/python/test_base.py +++ b/src/templates/python/test_base.py @@ -2,6 +2,7 @@ import base64 import requests from appium import webdriver +from appium.options import AppiumOptions from appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC @@ -50,7 +51,8 @@ def setup(self, desired_caps, retina_scale=1): print(f"Initialize Appium driver with desiredCaps: {desired_caps}") server_url = self._proxy.get_server_url() + '/wd/hub' - self._driver = webdriver.Remote(server_url, desired_caps) + options = AppiumOptions.load_capabilities(desired_caps) + self._driver = webdriver.Remote(server_url, options=options) def cleanup(self): if self._driver: @@ -89,10 +91,15 @@ def switch_to_web_context(self): self._driver.switch_to.context(web_context) self._current_context = web_context - def find_visible_element(self, timeout_ms, locator): - by, value = locator + def find_visible_element(self, timeout_ms, locators): wait = WebDriverWait(self._driver, timeout_ms / 1000) - return wait.until(EC.visibility_of_element_located((by, value))) + last_exception = None + for locator in locators: + try: + return wait.until(EC.visibility_of_element_located(locator)) + except Exception as e: + last_exception = e + raise last_exception def find_visible_element_on_scrollable(self, timeout_ms, locator): return self.find_visible_element(timeout_ms, locator) @@ -120,6 +127,10 @@ def hide_keyboard(self): except Exception: pass + def press_button_multiple(self, button_type, count): + for _ in range(count): + self.press_button(button_type) + def press_button(self, button_type): if self._is_ios: key_map = { @@ -150,6 +161,26 @@ def send_keys(self, element, text): element.send_keys(text) time.sleep(Config.SEND_KEYS_DELAY_IN_MS / 1000) + def send_keys_to_active_element(self, text): + time.sleep(Config.SEND_KEYS_DELAY_IN_MS / 1000) + self._driver.switch_to.active_element.send_keys(text) + time.sleep(Config.SEND_KEYS_DELAY_IN_MS / 1000) + + def swipe_on_element(self, element, x1, y1, x2, y2, duration=800): + location = element.location + size = element.size + start_x = int(location['x'] + size['width'] * x1) + start_y = int(location['y'] + size['height'] * y1) + end_x = int(location['x'] + size['width'] * x2) + end_y = int(location['y'] + size['height'] * y2) + self._driver.swipe(start_x, start_y, end_x, end_y, duration) + + def rotate_screen(self, orientation): + self._driver.orientation = orientation.upper() + + def set_location(self, lat, lng, altitude=0): + self._driver.set_location(lat, lng, altitude) + def idle(self): time.sleep(Config.IDLE_DELAY_IN_MS / 1000)