From 2834058d0d1450e9346717da70cbecf2dbf4a96c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 22:34:37 +0000 Subject: [PATCH 1/2] ci: only run the release workflow on the upstream repository The Release workflow triggers on every push to master. On a fork that means semantic-release rewrites the version in pyproject.toml, commits the change to the default branch, tags it and then tries to publish the result to PyPI under the upstream package name. Gate the job on the repository name so forks keep a clean master. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PjZRpyoEfSCLjTx5ctzQmJ --- .github/workflows/publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8df3ca7f..2119bfad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,10 @@ permissions: jobs: release: + # Releases are cut from the upstream repository only. Without this guard the + # workflow also runs on forks, where semantic-release rewrites the version in + # pyproject.toml, commits to the default branch and attempts a PyPI upload. + if: github.repository == 'appium/python-client' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From cd00a2d64e83ae38a79859a95d7c49d6292fcdd2 Mon Sep 17 00:00:00 2001 From: Dor-bl <59066376+Dor-bl@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:37:43 +0000 Subject: [PATCH 2/2] test: add unit tests for format_stacktrace and MobileErrorHandler --- test/unit/webdriver/errorhandler_test.py | 197 +++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 test/unit/webdriver/errorhandler_test.py diff --git a/test/unit/webdriver/errorhandler_test.py b/test/unit/webdriver/errorhandler_test.py new file mode 100644 index 00000000..378ac81e --- /dev/null +++ b/test/unit/webdriver/errorhandler_test.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +import pytest +import selenium.common.exceptions as sel_exceptions + +import appium.common.exceptions as appium_exceptions +from appium.webdriver.errorhandler import MobileErrorHandler, format_stacktrace + + +class TestFormatStacktrace: + def test_empty_or_none(self) -> None: + assert format_stacktrace(None) == [] + assert format_stacktrace('') == [] + assert format_stacktrace([]) == [] + + def test_string_stacktrace(self) -> None: + trace = 'line1\nline2\nline3' + assert format_stacktrace(trace) == ['line1', 'line2', 'line3'] + + def test_sequence_of_frames_full(self) -> None: + frames = [ + { + 'lineNumber': 42, + 'fileName': 'app.js', + 'methodName': 'onClick', + 'className': 'Button', + }, + { + 'lineNumber': 100, + 'fileName': 'index.js', + 'methodName': 'main', + 'className': 'App', + }, + ] + expected = [ + ' at Button.onClick (app.js:42)', + ' at App.main (index.js:100)', + ] + assert format_stacktrace(frames) == expected + + def test_sequence_of_frames_missing_fields(self) -> None: + frames = [ + { + 'lineNumber': 10, + }, + {}, + ] + expected = [ + ' at (:10)', + ' at ()', + ] + assert format_stacktrace(frames) == expected + + def test_sequence_of_frames_non_dict_elements(self) -> None: + frames = [ + 'invalid_frame', + 123, + None, + { + 'methodName': 'doSomething', + }, + ] + expected = [ + ' at doSomething ()', + ] + assert format_stacktrace(frames) == expected + + def test_type_error_handling(self) -> None: + class NonIterable: + def __bool__(self) -> bool: + return True + + def __iter__(self): + raise TypeError('Not iterable') + + assert format_stacktrace(NonIterable()) == [] + + +class TestMobileErrorHandler: + def setup_method(self) -> None: + self.handler = MobileErrorHandler() + + def test_check_response_dict_payload_success_no_error(self) -> None: + response = {'value': {'some_key': 'some_value'}} + # Should not raise any exception + self.handler.check_response(response) + + def test_check_response_invalid_payload(self) -> None: + # Malformed json string + self.handler.check_response({'value': 'not json'}) + # Payload is json string but not dict + self.handler.check_response({'value': json.dumps([1, 2, 3])}) + # Payload dict value is not dict + self.handler.check_response({'value': {'value': 'not a dict'}}) + # Payload dict value has no error key + self.handler.check_response({'value': {'value': {'message': 'no error'}}}) + + def test_check_response_standard_error(self) -> None: + response = { + 'value': { + 'value': { + 'error': 'no such element', + 'message': 'Element was not found', + 'stacktrace': 'trace line 1\ntrace line 2', + } + } + } + with pytest.raises(sel_exceptions.NoSuchElementException) as exc_info: + self.handler.check_response(response) + + assert 'Element was not found' in exc_info.value.msg + assert exc_info.value.stacktrace == ['trace line 1', 'trace line 2'] + + def test_check_response_json_string_payload(self) -> None: + inner_value = { + 'value': { + 'error': 'invalid selector', + 'message': 'The selector is invalid', + } + } + response = {'value': json.dumps(inner_value)} + with pytest.raises(sel_exceptions.InvalidSelectorException) as exc_info: + self.handler.check_response(response) + + assert 'The selector is invalid' in exc_info.value.msg + + def test_check_response_no_such_context_message(self) -> None: + response = { + 'value': { + 'value': { + 'error': 'unknown error', + 'message': 'No such context found.', + } + } + } + with pytest.raises(appium_exceptions.NoSuchContextException) as exc_info: + self.handler.check_response(response) + + assert exc_info.value.msg == 'No such context found.' + + def test_check_response_invalid_switch_to_target_message(self) -> None: + response = { + 'value': { + 'value': { + 'error': 'unknown error', + 'message': 'That command could not be executed in the current context.', + } + } + } + with pytest.raises(appium_exceptions.InvalidSwitchToTargetException) as exc_info: + self.handler.check_response(response) + + assert exc_info.value.msg == 'That command could not be executed in the current context.' + + def test_check_response_unexpected_alert_present(self) -> None: + response = { + 'value': { + 'value': { + 'error': 'unexpected alert open', + 'message': 'Alert is present', + 'data': 'Alert text content', + } + } + } + with pytest.raises(sel_exceptions.UnexpectedAlertPresentException) as exc_info: + self.handler.check_response(response) + + assert exc_info.value.msg == 'Alert is present' + assert exc_info.value.alert_text == 'Alert text content' + + def test_check_response_unknown_mapped_error(self) -> None: + response = { + 'value': { + 'value': { + 'error': 'unmapped error name', + 'message': 'Some custom error', + } + } + } + with pytest.raises(sel_exceptions.WebDriverException) as exc_info: + self.handler.check_response(response) + + assert exc_info.value.msg == 'Some custom error'