[KOB-52290] Fix Python pytest generator compatibility with Appium-Python-Client 5.x#61
Conversation
…overage Generated Python scripts were malformed in several ways: @classmethod landed outside the class body, indent levels drifted with each device block causing pytest to miss all but the first test function, finally was nested inside except causing an immediate SyntaxError, JS true/false appeared as Python literals, XPath selectors with double-quoted attributes broke string quoting, and multi-device if/elif/else branches were nested inside each other. Runtime template gaps were also fixed: missing AppiumBy/By imports in test_app.py, deprecated webdriver.Remote API updated to AppiumOptions in test_base.py, missing action helpers added (send_keys_to_active_element, swipe_on_element, press_button_multiple, rotate_screen, set_location), port handling corrected in config.py, and proxy auth switched from URL-embedded credentials to an Authorization header in proxy_server.py. A direct Python regression test (no gRPC) now generates a real zip from a 2-device fixture and runs ast.parse plus six structural checks over every .py file to protect against recurrence. Note: the pre-existing open-handles warning from the gRPC integration tests in generate-script.test.js is unrelated and left for a separate cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The short import "from appium.options import AppiumOptions" does not exist in Appium-Python-Client 3.1.0 and caused ImportError at pytest collection. The class lives at appium.options.common.base, and load_capabilities is an instance method — both corrected in test_base.py. The validator now asserts the full import path is present so a regression is caught by the test suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se shape Live-testing a generated pytest script against api-test-green surfaced two runtime issues in the templates: proxy_server.py forwarded the client's Host header (localhost:<port>) to Kobiton verbatim, so upstream routed on the wrong vhost and returned 404 for every request. It also swallowed HTTPError responses as a generic 502, hiding the real upstream error body. Now strips hop-by-hop/routing headers (host, content-length, connection, transfer-encoding, etc.) before forwarding so urllib sets Host from the target URL, and propagates the upstream status + body on HTTPError so client errors reach the Appium driver with meaningful messages. test_base.py find_online_device only checked the legacy deviceListData key on /v1/devices. Newer Kobiton environments return categorized lists (privateDevices / favoriteDevices / cloudDevices / itaTrialCloudDevices / virtualDevices) and no deviceListData, so the helper printed "Device not available" five times every run before falling through to session create. Now unions all category keys. Validator updated with matching checks so a regression on either fix is caught by the Python integration test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ient 5.x compatibility
… proper auth, timeouts, W3C format conversion and kobitonSessionId extraction - Add 15-minute timeout configuration (SOCKET_TIMEOUT_SECONDS) matching Java implementation - Apply timeouts to all requests.get/post/delete calls - Implement JSON Wire to W3C format conversion for /session POST responses - Extract and store kobitonSessionId from session creation responses - Add W3C error format conversion with Appium error code mapping - Pass ProxyServer instance to ProxyHandler for proper state sharing - Add detailed proxy logging for debugging request/response flow
- Add device source check — only add baseCommandId when DEVICE_SOURCE is KOBITON and current_command_id > 0 - Add force_w3c flag that's set to True only on successful /session POST with JSON Wire format detected - Only apply W3C error conversion when force_w3c is True (prevents converting already W3C-formatted errors) - Replace hardcoded success check with 200-299 range (200 <= status_code <= 299) - Add do_PUT and do_PATCH HTTP methods to support PUT and PATCH requests - Import DEVICE_SOURCES from constants for device source checking - All changes validated with Kobiton cloud integration testing
There was a problem hiding this comment.
Pull request overview
This PR updates the Python pytest script generator/templates to run correctly with Appium-Python-Client 5.x, aligning generated code and proxy behavior with newer client/server expectations.
Changes:
- Update generated Python templates for Appium 5.x driver initialization and add helper APIs used by generated steps.
- Revise Python proxy forwarding to use
requests, add timeouts/method support, and improve session/response handling for Kobiton. - Add a Python output validator + Jest integration test fixture to catch syntax/indentation/import regressions in generated output.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/templates/python/test_base.py | Updates driver initialization and expands helper methods used by generated tests. |
| src/templates/python/test_app.py | Adds missing module-scope imports needed by generated locator code. |
| src/templates/python/proxy_server.py | Reworks proxying implementation (requests-based forwarding, timeouts, session handling). |
| src/templates/python/config.py | Fixes URL auth construction when server URL omits an explicit port. |
| src/services/python.js | Fixes Python code generation (indentation, booleans, locator generation, action mapping). |
| tests/resource/python-pytest-input.json | Adds a fixture input representing a 2-device pytest generation scenario. |
| tests/integration/python-pytest.test.js | Adds an integration test that generates a zip and validates the Python output. |
| tests/helpers/validate-python-output.py | Adds a validator script to assert generated Python syntax/structure invariants. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| response = requests.get(url, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) | ||
| elif method == 'POST': | ||
| response = requests.post(url, data=body, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) | ||
| elif method == 'DELETE': | ||
| response = requests.delete(url, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) | ||
| else: | ||
| response = requests.request(method, url, data=body, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) |
There was a problem hiding this comment.
Outbound proxy requests set verify=False, disabling TLS certificate verification. This is insecure by default and allows MITM attacks. Prefer keeping verification enabled, or make disabling verification an explicit, documented opt-in setting.
| response = requests.get(url, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) | |
| elif method == 'POST': | |
| response = requests.post(url, data=body, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) | |
| elif method == 'DELETE': | |
| response = requests.delete(url, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) | |
| else: | |
| response = requests.request(method, url, data=body, headers=headers, verify=False, timeout=SOCKET_TIMEOUT_SECONDS) | |
| response = requests.get(url, headers=headers, timeout=SOCKET_TIMEOUT_SECONDS) | |
| elif method == 'POST': | |
| response = requests.post(url, data=body, headers=headers, timeout=SOCKET_TIMEOUT_SECONDS) | |
| elif method == 'DELETE': | |
| response = requests.delete(url, headers=headers, timeout=SOCKET_TIMEOUT_SECONDS) | |
| else: | |
| response = requests.request(method, url, data=body, headers=headers, timeout=SOCKET_TIMEOUT_SECONDS) |
| base_url = Config.get_appium_server_url_with_auth() | ||
| print(f"[PROXY] Base URL from config: {base_url}", file=sys.stderr, flush=True) | ||
|
|
There was a problem hiding this comment.
base_url is built from get_appium_server_url_with_auth() (which embeds username:apiKey@...) and then printed. This leaks credentials into logs. Avoid embedding secrets in URLs where possible, and never log URLs/strings that contain credentials.
| # Log the Authorization header | ||
| auth_header = headers.get('Authorization', 'NOT SET') | ||
| print(f"[PROXY] Authorization header: {auth_header}", file=sys.stderr, flush=True) | ||
| print(f"[PROXY] All request headers: {headers}", file=sys.stderr, flush=True) |
There was a problem hiding this comment.
The proxy prints the Authorization header value to stderr. This will leak credentials into CI logs and local output. Mask the header (e.g., show only scheme / last 4 chars) or remove this log entirely.
| self.send_response(response.status_code) | ||
| for key, val in response.headers.items(): | ||
| self.send_header(key, val) | ||
| self.end_headers() |
There was a problem hiding this comment.
The proxy may modify response_body (session/error conversion) but still forwards upstream headers unchanged. If the upstream response contains Content-Length, it can become incorrect and cause client hangs/truncation. Drop/recompute Content-Length (and other hop-by-hop headers) based on the final body you write.
| # 2b. test_base.py must import AppiumOptions from its real module path. | ||
| # In Appium-Python-Client 3.x/4.x, AppiumOptions lives at | ||
| # appium.options.common.base, NOT appium.options — the shorter | ||
| # path raises ImportError at collection time. | ||
| # ------------------------------------------------------------------ | ||
| test_base = py_files.get('test_base.py', '') | ||
| if 'from appium.options.common.base import AppiumOptions' not in test_base: | ||
| errors.append( | ||
| 'test_base.py: missing or wrong AppiumOptions import — ' | ||
| 'must be "from appium.options.common.base import AppiumOptions"' |
There was a problem hiding this comment.
This check still requires test_base.py to import AppiumOptions from appium.options.common.base, but the template now imports UiAutomator2Options. As written, the integration test will fail even when generation is correct. Update the assertion to match the intended options import(s) (and consider covering both Android and iOS options if supported).
| # 2b. test_base.py must import AppiumOptions from its real module path. | |
| # In Appium-Python-Client 3.x/4.x, AppiumOptions lives at | |
| # appium.options.common.base, NOT appium.options — the shorter | |
| # path raises ImportError at collection time. | |
| # ------------------------------------------------------------------ | |
| test_base = py_files.get('test_base.py', '') | |
| if 'from appium.options.common.base import AppiumOptions' not in test_base: | |
| errors.append( | |
| 'test_base.py: missing or wrong AppiumOptions import — ' | |
| 'must be "from appium.options.common.base import AppiumOptions"' | |
| # 2b. test_base.py must import a supported Appium options class. | |
| # Current templates use platform-specific options such as | |
| # UiAutomator2Options (Android) and XCUITestOptions (iOS). | |
| # Keep AppiumOptions accepted as a legacy fallback so validation | |
| # does not fail for otherwise-correct generated output. | |
| # ------------------------------------------------------------------ | |
| test_base = py_files.get('test_base.py', '') | |
| valid_options_imports = ( | |
| 'from appium.options.android import UiAutomator2Options', | |
| 'from appium.options.ios import XCUITestOptions', | |
| 'from appium.options.common.base import AppiumOptions', | |
| ) | |
| if not any(import_line in test_base for import_line in valid_options_imports): | |
| errors.append( | |
| 'test_base.py: missing or wrong Appium options import — ' | |
| 'must import one of ' | |
| '"from appium.options.android import UiAutomator2Options", ' | |
| '"from appium.options.ios import XCUITestOptions", or ' | |
| '"from appium.options.common.base import AppiumOptions"' |
| 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 = UiAutomator2Options().load_capabilities(desired_caps) | ||
| self._driver = webdriver.Remote(server_url, options=options) |
There was a problem hiding this comment.
setup() always constructs UiAutomator2Options regardless of platformName. This will break iOS runs (and any non-UiAutomator2 driver) because iOS should use the iOS option class (e.g., XCUITestOptions) or a generic options builder compatible with both platforms. Consider selecting the options class based on platformName and falling back to a common options type when the platform is not Android.
| raise last_exception | ||
|
|
||
| def find_visible_element_on_scrollable(self, timeout_ms, locator): | ||
| return self.find_visible_element(timeout_ms, locator) |
There was a problem hiding this comment.
find_visible_element_on_scrollable() passes a single locator tuple into find_visible_element(), but find_visible_element() now iterates over locators. Passing a tuple will iterate over its two items (by/value) and then call visibility_of_element_located() with invalid locator data. Wrap the single locator in a list/tuple, or update find_visible_element() to accept both a single locator and a list of locators.
| return self.find_visible_element(timeout_ms, locator) | |
| return self.find_visible_element(timeout_ms, [locator]) |
…rver.py - Convert force_w3c and kobiton_session_id to per-request state (local variables in do_request) - Strip Content-Length header when modifying response body to prevent truncation - Mask Authorization header in logs to prevent credential exposure - Add threading.Lock for safe kobiton_session_id storage on server_instance - Add get_kobiton_session_id() getter method matching Java implementation - Add Kobiton portal URL logging in test_app.py after session creation - Ensure proper W3C error conversion logic without cross-session contamination
Here's a clean PR description you can copy in:
Title:
[KOB-52290] Fix Python pytest generator compatibility with Appium-Python-Client 5.xDescription: