robotframework-appiumwindows 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- AppiumLibrary/__init__.py +106 -0
- AppiumLibrary/appium_path.py +10 -0
- AppiumLibrary/keywords/__init__.py +21 -0
- AppiumLibrary/keywords/_applicationmanagement.py +515 -0
- AppiumLibrary/keywords/_element.py +1282 -0
- AppiumLibrary/keywords/_logging.py +63 -0
- AppiumLibrary/keywords/_powershell.py +553 -0
- AppiumLibrary/keywords/_runonfailure.py +74 -0
- AppiumLibrary/keywords/_screenrecord.py +138 -0
- AppiumLibrary/keywords/_screenshot.py +109 -0
- AppiumLibrary/keywords/_waiting.py +163 -0
- AppiumLibrary/keywords/_windows.py +215 -0
- AppiumLibrary/keywords/keywordgroup.py +70 -0
- AppiumLibrary/locators/__init__.py +7 -0
- AppiumLibrary/locators/elementfinder.py +264 -0
- AppiumLibrary/utils/__init__.py +50 -0
- AppiumLibrary/utils/applicationcache.py +48 -0
- AppiumLibrary/version.py +2 -0
- robotframework_appiumwindows-0.1.0.dist-info/METADATA +148 -0
- robotframework_appiumwindows-0.1.0.dist-info/RECORD +23 -0
- robotframework_appiumwindows-0.1.0.dist-info/WHEEL +5 -0
- robotframework_appiumwindows-0.1.0.dist-info/licenses/LICENSE +21 -0
- robotframework_appiumwindows-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from robot.api import logger
|
|
6
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
7
|
+
from robot.libraries.BuiltIn import RobotNotRunningError
|
|
8
|
+
|
|
9
|
+
from .keywordgroup import KeywordGroup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _LoggingKeywords(KeywordGroup):
|
|
13
|
+
LOG_LEVEL_DEBUG = ['DEBUG']
|
|
14
|
+
LOG_LEVEL_INFO = ['DEBUG', 'INFO']
|
|
15
|
+
LOG_LEVEL_WARN = ['DEBUG', 'INFO', 'WARN']
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def _log_level(self):
|
|
19
|
+
try:
|
|
20
|
+
level = BuiltIn().get_variable_value("${APPIUM_LOG_LEVEL}", default='DEBUG')
|
|
21
|
+
except RobotNotRunningError:
|
|
22
|
+
level = 'DEBUG'
|
|
23
|
+
return level
|
|
24
|
+
|
|
25
|
+
def _debug(self, message):
|
|
26
|
+
if self._log_level in self.LOG_LEVEL_DEBUG:
|
|
27
|
+
logger.debug(message)
|
|
28
|
+
|
|
29
|
+
def _info(self, message):
|
|
30
|
+
if self._log_level in self.LOG_LEVEL_INFO:
|
|
31
|
+
logger.info(message)
|
|
32
|
+
|
|
33
|
+
def _warn(self, message):
|
|
34
|
+
if self._log_level in self.LOG_LEVEL_WARN:
|
|
35
|
+
logger.warn(message)
|
|
36
|
+
|
|
37
|
+
def _html(self, message):
|
|
38
|
+
logger.info(message, True, False)
|
|
39
|
+
|
|
40
|
+
def _get_log_dir(self):
|
|
41
|
+
variables = BuiltIn().get_variables()
|
|
42
|
+
logfile = variables['${LOG FILE}']
|
|
43
|
+
if logfile != 'NONE':
|
|
44
|
+
return os.path.dirname(logfile)
|
|
45
|
+
return variables['${OUTPUTDIR}']
|
|
46
|
+
|
|
47
|
+
def _log(self, message, level='INFO'):
|
|
48
|
+
level = level.upper()
|
|
49
|
+
if (level == 'INFO'):
|
|
50
|
+
self._info(message)
|
|
51
|
+
elif (level == 'DEBUG'):
|
|
52
|
+
self._debug(message)
|
|
53
|
+
elif (level == 'WARN'):
|
|
54
|
+
self._warn(message)
|
|
55
|
+
elif (level == 'HTML'):
|
|
56
|
+
self._html(message)
|
|
57
|
+
|
|
58
|
+
def _log_list(self, items, what='item'):
|
|
59
|
+
msg = ['Altogether %d %s%s.' % (len(items), what, ['s', ''][len(items) == 1])]
|
|
60
|
+
for index, item in enumerate(items):
|
|
61
|
+
msg.append('%d: %s' % (index + 1, item))
|
|
62
|
+
self._info('\n'.join(msg))
|
|
63
|
+
return items
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from appium.webdriver.mobilecommand import MobileCommand as Command
|
|
9
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
10
|
+
from selenium.common import InvalidArgumentException
|
|
11
|
+
|
|
12
|
+
from AppiumLibrary import utils
|
|
13
|
+
from AppiumLibrary.locators import ElementFinder
|
|
14
|
+
from .keywordgroup import KeywordGroup
|
|
15
|
+
|
|
16
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "scripts"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _PowershellKeywords(KeywordGroup):
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._element_finder = ElementFinder()
|
|
22
|
+
self._bi = BuiltIn()
|
|
23
|
+
|
|
24
|
+
# Public, element lookups
|
|
25
|
+
# Powershell command, need appium server allow shell, eg: appium --relaxed-security
|
|
26
|
+
|
|
27
|
+
def appium_ps_click(
|
|
28
|
+
self,
|
|
29
|
+
locator=None,
|
|
30
|
+
x=0,
|
|
31
|
+
y=0,
|
|
32
|
+
button='left',
|
|
33
|
+
**kwargs
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Click using PowerShell-level mouse simulation.
|
|
37
|
+
|
|
38
|
+
Supports:
|
|
39
|
+
- Absolute coordinates or element-based location via locator
|
|
40
|
+
- Optional offsets (absolute or 'center')
|
|
41
|
+
- Custom mouse button
|
|
42
|
+
|
|
43
|
+
kwargs:
|
|
44
|
+
- offset: sets both x/y offset globally (overrides individual)
|
|
45
|
+
- x_offset / y_offset
|
|
46
|
+
|
|
47
|
+
@param locator:
|
|
48
|
+
@param x:
|
|
49
|
+
@param y:
|
|
50
|
+
@param button:
|
|
51
|
+
'left' - single left click
|
|
52
|
+
'right' - single right click
|
|
53
|
+
'middle' - single middle click
|
|
54
|
+
'double' - double left click
|
|
55
|
+
'triple' - triple left click
|
|
56
|
+
'right-double' - double right click
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
self._info(f"Appium Ps Click")
|
|
60
|
+
|
|
61
|
+
if locator:
|
|
62
|
+
rect = self.get_element_rect(locator)
|
|
63
|
+
x, y = self._parse_location(rect, kwargs, 'x_offset', 'y_offset')
|
|
64
|
+
root = self._current_application().get_window_rect()
|
|
65
|
+
self._info(f"[DEBUG] App window rect: {root}")
|
|
66
|
+
x, y = x + root['x'], y + root['y']
|
|
67
|
+
|
|
68
|
+
self._info(f"[CLICK] {button.upper()} Button: ({x},{y}))")
|
|
69
|
+
|
|
70
|
+
ps_command = self._generate_click_command(x, y, button)
|
|
71
|
+
self._info(f"ps_command: \n{ps_command}")
|
|
72
|
+
|
|
73
|
+
self.appium_execute_powershell_command(ps_command)
|
|
74
|
+
|
|
75
|
+
def appium_ps_sendkeys(self, text: str):
|
|
76
|
+
"""
|
|
77
|
+
SendKeys can found at: https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.sendkeys?view=windowsdesktop-10.0
|
|
78
|
+
|
|
79
|
+
To specify that any combination of SHIFT, CTRL, and ALT should be held down while several other keys are pressed,
|
|
80
|
+
enclose the code for those keys in parentheses. For example, to specify to hold down SHIFT while E and C are pressed,
|
|
81
|
+
use "+(EC)". To specify to hold down SHIFT while E is pressed, followed by C without SHIFT, use "+EC".
|
|
82
|
+
|
|
83
|
+
To specify repeating keys, use the form {key number}. You must put a space between key and number.
|
|
84
|
+
For example, {LEFT 42} means press the LEFT ARROW key 42 times; {h 10} means press H 10 times.
|
|
85
|
+
|
|
86
|
+
@param text: text to sendkeys
|
|
87
|
+
eg1: text = 123qwe{TAB}iop{ENTER}+a~ABC~
|
|
88
|
+
eg2: text = "{+}" ({%}) ({^})
|
|
89
|
+
eg3: text = This is test{LEFT}{BACKSPACE}x
|
|
90
|
+
eg4: text = 123qwe{BACKSPACE 3}{TAB}{ENTER}
|
|
91
|
+
@return:
|
|
92
|
+
"""
|
|
93
|
+
self._info(f"Appium Ps Sendkeys: {text}")
|
|
94
|
+
text = text.replace('"', '""')
|
|
95
|
+
|
|
96
|
+
ps_command = (
|
|
97
|
+
f'Add-Type -AssemblyName System.Windows.Forms;'
|
|
98
|
+
f'[System.Windows.Forms.SendKeys]::SendWait("{text}")'
|
|
99
|
+
)
|
|
100
|
+
self._info(f'command: {ps_command}')
|
|
101
|
+
|
|
102
|
+
self.appium_execute_powershell_command(ps_command)
|
|
103
|
+
|
|
104
|
+
def appium_ps_drag_and_drop(
|
|
105
|
+
self,
|
|
106
|
+
start_locator=None,
|
|
107
|
+
end_locator=None,
|
|
108
|
+
x_start=0,
|
|
109
|
+
y_start=0,
|
|
110
|
+
x_end=0,
|
|
111
|
+
y_end=0,
|
|
112
|
+
button='left',
|
|
113
|
+
**kwargs
|
|
114
|
+
):
|
|
115
|
+
"""
|
|
116
|
+
Drag and drop using PowerShell-level mouse simulation.
|
|
117
|
+
|
|
118
|
+
Supports:
|
|
119
|
+
- Absolute coordinates or element-based location via start_locator/end_locator
|
|
120
|
+
- Optional offsets (absolute or 'center')
|
|
121
|
+
- Custom mouse button (left/right/mid)
|
|
122
|
+
- Drag duration (default 0.5 seconds)
|
|
123
|
+
|
|
124
|
+
kwargs:
|
|
125
|
+
- offset: sets both x/y offset globally (overrides individual)
|
|
126
|
+
- x_start_offset / y_start_offset
|
|
127
|
+
- x_end_offset / y_end_offset
|
|
128
|
+
- duration_sec: total drag time in seconds (default 0.5)
|
|
129
|
+
"""
|
|
130
|
+
self._info(f"Appium Ps Drag And Drop")
|
|
131
|
+
# Get actual coordinates from locators (if any)
|
|
132
|
+
if start_locator:
|
|
133
|
+
start_rect = self.get_element_rect(start_locator)
|
|
134
|
+
x_start, y_start = self._parse_location(start_rect, kwargs, 'x_start_offset', 'y_start_offset')
|
|
135
|
+
|
|
136
|
+
if end_locator:
|
|
137
|
+
end_rect = self.get_element_rect(end_locator)
|
|
138
|
+
x_end, y_end = self._parse_location(end_rect, kwargs, 'x_end_offset', 'y_end_offset')
|
|
139
|
+
|
|
140
|
+
# Normalize against window position
|
|
141
|
+
root = self._current_application().get_window_rect()
|
|
142
|
+
self._info(f"[DEBUG] App window rect: {root}")
|
|
143
|
+
|
|
144
|
+
x_start, y_start = x_start + root['x'], y_start + root['y']
|
|
145
|
+
x_end, y_end = x_end + root['x'], y_end + root['y']
|
|
146
|
+
|
|
147
|
+
self._info(f"[DRAG] {button.upper()} Button: From ({x_start},{y_start}) → ({x_end},{y_end})")
|
|
148
|
+
|
|
149
|
+
# Get drag duration
|
|
150
|
+
duration_sec = float(kwargs.get('duration_sec', 0.5))
|
|
151
|
+
ps_command = self._generate_drag_command(x_start, y_start, x_end, y_end, button, duration_sec)
|
|
152
|
+
self._info(f"ps_command: \n{ps_command}")
|
|
153
|
+
|
|
154
|
+
# Execute PowerShell command
|
|
155
|
+
self.appium_execute_powershell_command(ps_command)
|
|
156
|
+
|
|
157
|
+
def appium_execute_powershell_command(self, command, handle_exception=False):
|
|
158
|
+
"""
|
|
159
|
+
Executes a PowerShell command using Appium's execute_script method.
|
|
160
|
+
|
|
161
|
+
Note:
|
|
162
|
+
PowerShell command execution must be allowed on the Appium server.
|
|
163
|
+
For this, Appium must be started with the `--relaxed-security` flag:
|
|
164
|
+
appium --relaxed-security
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
command (str): The PowerShell command to be executed.
|
|
168
|
+
handle_exception (bool): If True, return the exception object on error. Otherwise, return None.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
str | dict | Exception: The result of the execution or the exception object.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
Exception: If handle_exception is False and an error occurs.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
driver = self._current_application()
|
|
178
|
+
result = driver.execute_script("powerShell", {"command": command})
|
|
179
|
+
return result
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
if handle_exception:
|
|
182
|
+
return exc
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
def appium_execute_powershell_script(self, ps_script=None, file_path=None, handle_exception=False):
|
|
186
|
+
"""
|
|
187
|
+
Executes a PowerShell script using Appium's execute_script method.
|
|
188
|
+
|
|
189
|
+
Note:
|
|
190
|
+
PowerShell command execution must be allowed on the Appium server.
|
|
191
|
+
For this, Appium must be started with the `--relaxed-security` flag:
|
|
192
|
+
appium --relaxed-security
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
ps_script (str): The full PowerShell script to be executed.
|
|
196
|
+
file_path (str): The file ps1 to be executed.
|
|
197
|
+
handle_exception (bool): If True, return the exception object on failure. If False, return None on failure.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
str | dict | Exception: The result of the script execution or the exception object.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
Exception: If handle_exception is False and an error occurs.
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
if file_path:
|
|
207
|
+
self._info(f'file_path: {file_path}')
|
|
208
|
+
ps_script = utils.read_file(file_path)
|
|
209
|
+
self._info(f"ps_script: \n{ps_script}")
|
|
210
|
+
driver = self._current_application()
|
|
211
|
+
result = driver.execute_script("powerShell", {"script": ps_script})
|
|
212
|
+
return result
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
if handle_exception:
|
|
215
|
+
return exc
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
def appium_pull_file(self, path: str, save_path: str = None) -> str:
|
|
219
|
+
"""Retrieves the file at `path`.
|
|
220
|
+
|
|
221
|
+
Powershell command must be allowed. eg: appium --relaxed-security
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
path: the path to the file on the device, eg: c:/users/user1/desktop/screenshot_file.png
|
|
225
|
+
save_path: path to save, eg: /Users/user1/desktop/screenshot.png
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The file's contents encoded as Base64.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
# base64data = self._current_application().pull_file(path)
|
|
232
|
+
base64data = self._current_application().execute(Command.PULL_FILE, {'path': path})['value']
|
|
233
|
+
|
|
234
|
+
if save_path:
|
|
235
|
+
with open(save_path, "wb") as file:
|
|
236
|
+
file.write(base64.b64decode(base64data))
|
|
237
|
+
|
|
238
|
+
return base64data
|
|
239
|
+
|
|
240
|
+
def appium_pull_folder(self, path: str, save_path_as_zip: str = '') -> str:
|
|
241
|
+
"""Retrieves a folder at `path`.
|
|
242
|
+
|
|
243
|
+
Powershell command must be allowed. eg: appium --relaxed-security
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
path: the path to the folder on the device. eg: c:/users/user1/desktop/folder1
|
|
247
|
+
save_path_as_zip: zip file. eg: /Users/user1/desktop/file.zip
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The folder's contents zipped and encoded as Base64.
|
|
251
|
+
"""
|
|
252
|
+
# base64data = self._current_application().pull_folder(path)
|
|
253
|
+
base64data = self._current_application().execute(Command.PULL_FOLDER, {'path': path})['value']
|
|
254
|
+
|
|
255
|
+
if save_path_as_zip:
|
|
256
|
+
with open(save_path_as_zip, "wb") as file:
|
|
257
|
+
file.write(base64.b64decode(base64data))
|
|
258
|
+
|
|
259
|
+
return base64data
|
|
260
|
+
|
|
261
|
+
def appium_push_file(self, destination_path: str, source_path: str = None, base64data: str = None):
|
|
262
|
+
"""Puts the data from the file at `source_path`, encoded as Base64, in the file specified as `path`.
|
|
263
|
+
|
|
264
|
+
Specify either `base64data` or `source_path`, if both specified default to `source_path`
|
|
265
|
+
|
|
266
|
+
Powershell command must be allowed. eg: appium --relaxed-security
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
destination_path: the location on the device/simulator where the local file contents should be saved.
|
|
270
|
+
eg: c:/users/user1/desktop/screenshot_file.png
|
|
271
|
+
base64data: file contents, encoded as Base64, to be written
|
|
272
|
+
to the file on the device/simulator. Eg: iVBORw0KGgoAAAANSUh...
|
|
273
|
+
source_path: local file path for the file to be loaded on device. Eg: /Users/user1/desktop/source_file.png
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
base64data
|
|
277
|
+
"""
|
|
278
|
+
if source_path is None and base64data is None:
|
|
279
|
+
raise InvalidArgumentException('Must either pass base64 data or a local file path')
|
|
280
|
+
|
|
281
|
+
if source_path is not None:
|
|
282
|
+
try:
|
|
283
|
+
with open(source_path, 'rb') as f:
|
|
284
|
+
file_data = f.read()
|
|
285
|
+
except IOError as e:
|
|
286
|
+
message = f'source_path "{source_path}" could not be found. Are you sure the file exists?'
|
|
287
|
+
raise InvalidArgumentException(message) from e
|
|
288
|
+
base64data = base64.b64encode(file_data).decode('utf-8')
|
|
289
|
+
|
|
290
|
+
# result = self._current_application().push_file(destination_path, base64data, source_path)
|
|
291
|
+
|
|
292
|
+
self._current_application().execute(Command.PUSH_FILE, {'path': destination_path, 'data': base64data})
|
|
293
|
+
|
|
294
|
+
return base64data
|
|
295
|
+
|
|
296
|
+
def appium_transfer_file(self, file_path, remote_path):
|
|
297
|
+
"""
|
|
298
|
+
Streams a binary file, base64-encodes it chunk by chunk,
|
|
299
|
+
and sends it directly to a remote machine via PowerShell commands.
|
|
300
|
+
|
|
301
|
+
Powershell command must be allowed. eg: appium --relaxed-security
|
|
302
|
+
|
|
303
|
+
file_path: source file path, eg: c:/users/user1/desktop/screenshot_file.png
|
|
304
|
+
remote_path: destination path, eg: c:/users/user1/download/screenshot_file.png
|
|
305
|
+
"""
|
|
306
|
+
file_path = Path(file_path)
|
|
307
|
+
remote_path = str(remote_path)
|
|
308
|
+
remote_b64_path = remote_path + ".b64.tmp"
|
|
309
|
+
chunk_size = 6000 # chunk size in raw bytes (will expand when base64 encoded)
|
|
310
|
+
|
|
311
|
+
# 1. Ensure remote parent directory exists
|
|
312
|
+
remote_directory = str(Path(remote_path).parent)
|
|
313
|
+
mkdir_cmd = f'New-Item -Path "{remote_directory}" -ItemType Directory -Force'
|
|
314
|
+
self.execute_script("powerShell", command=mkdir_cmd)
|
|
315
|
+
self._info(f"Ensured remote directory: {remote_directory}")
|
|
316
|
+
|
|
317
|
+
# 2. Open and stream file, encoding and sending each chunk
|
|
318
|
+
with open(file_path, "rb") as f:
|
|
319
|
+
chunk_index = 0
|
|
320
|
+
while True:
|
|
321
|
+
chunk = f.read(chunk_size)
|
|
322
|
+
if not chunk:
|
|
323
|
+
break
|
|
324
|
+
chunk_b64 = base64.b64encode(chunk).decode('utf-8')
|
|
325
|
+
escaped_chunk = chunk_b64.replace("`", "``").replace('"', '`"')
|
|
326
|
+
ps = f'Add-Content -Path "{remote_b64_path}" -Value "{escaped_chunk}"'
|
|
327
|
+
self.execute_script("powerShell", command=ps)
|
|
328
|
+
chunk_index += 1
|
|
329
|
+
self._info(f"Sent chunk {chunk_index}")
|
|
330
|
+
|
|
331
|
+
# 3. Decode and write binary file on remote side
|
|
332
|
+
decode_script = (
|
|
333
|
+
f'[IO.File]::WriteAllBytes("{remote_path}";'
|
|
334
|
+
f'[Convert]::FromBase64String((Get-Content "{remote_b64_path}" -Raw)))'
|
|
335
|
+
)
|
|
336
|
+
self.execute_script("powerShell", command=decode_script)
|
|
337
|
+
self._info(f"File written to: {remote_path}")
|
|
338
|
+
|
|
339
|
+
# 4. Optional cleanup
|
|
340
|
+
cleanup_script = f'Remove-Item "{remote_b64_path}" -ErrorAction SilentlyContinue'
|
|
341
|
+
self.execute_script("powerShell", command=cleanup_script)
|
|
342
|
+
self._info("Cleaned up temporary base64 file.")
|
|
343
|
+
|
|
344
|
+
def appium_split_and_push_file(self, source_path: str, remote_path: str, chunk_size_mb: int = 20):
|
|
345
|
+
"""
|
|
346
|
+
Splits a binary file into chunks, pushes each to a remote machine via Appium,
|
|
347
|
+
then executes a PowerShell script remotely to recombine and clean up chunk files.
|
|
348
|
+
|
|
349
|
+
Powershell command must be allowed. eg: appium --relaxed-security
|
|
350
|
+
|
|
351
|
+
Parameters:
|
|
352
|
+
source_path (str): Local path to the binary file.
|
|
353
|
+
remote_path (str): Full remote file path to create from recombination.
|
|
354
|
+
chunk_size_mb (int): Size of each chunk in MB (default: 20MB).
|
|
355
|
+
"""
|
|
356
|
+
chunk_size = chunk_size_mb * 1024 * 1024
|
|
357
|
+
file_size = os.path.getsize(source_path)
|
|
358
|
+
|
|
359
|
+
file_name = os.path.basename(source_path)
|
|
360
|
+
remote_dir = os.path.dirname(remote_path)
|
|
361
|
+
total_chunks = math.ceil(file_size / chunk_size)
|
|
362
|
+
# chunk_index_digits = max(4, len(str(total_chunks - 1))) # at least 4 digits
|
|
363
|
+
chunk_index_digits = len(str(total_chunks - 1))
|
|
364
|
+
|
|
365
|
+
self._info(f"Splitting '{file_name}' ({file_size} bytes) into {total_chunks} chunks of {chunk_size_mb}MB each")
|
|
366
|
+
|
|
367
|
+
# Step 1: Split and push chunks
|
|
368
|
+
with open(source_path, "rb") as f:
|
|
369
|
+
for index in range(total_chunks):
|
|
370
|
+
chunk = f.read(chunk_size)
|
|
371
|
+
if not chunk:
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
b64_chunk = base64.b64encode(chunk).decode("utf-8")
|
|
375
|
+
chunk_suffix = f"{index:0{chunk_index_digits}d}"
|
|
376
|
+
remote_chunk_path = os.path.join(remote_dir, f"{file_name}.part{chunk_suffix}")
|
|
377
|
+
|
|
378
|
+
self.appium_push_file(destination_path=remote_chunk_path, base64data=b64_chunk)
|
|
379
|
+
self._info(f"Pushed chunk {chunk_suffix} to {remote_chunk_path}")
|
|
380
|
+
|
|
381
|
+
# Step 2: Build PowerShell recombine script
|
|
382
|
+
escaped_dir = remote_dir.replace("'", "''")
|
|
383
|
+
escaped_out = remote_path.replace("'", "''")
|
|
384
|
+
escaped_base = file_name.replace("'", "''")
|
|
385
|
+
pad_format = f"D{chunk_index_digits}"
|
|
386
|
+
|
|
387
|
+
ps_script_lines = [
|
|
388
|
+
f"$out = [System.IO.File]::OpenWrite('{escaped_out}')",
|
|
389
|
+
"$out.SetLength(0)",
|
|
390
|
+
f"for ($i = 0; $i -lt {total_chunks}; $i++) {{",
|
|
391
|
+
f" $chunkName = '{escaped_base}.part' + $i.ToString('{pad_format}')",
|
|
392
|
+
f" $chunkPath = Join-Path '{escaped_dir}' $chunkName",
|
|
393
|
+
' if (-not (Test-Path $chunkPath)) { throw "Missing chunk: $chunkPath" }',
|
|
394
|
+
" $in = [System.IO.File]::OpenRead($chunkPath)",
|
|
395
|
+
" $buffer = New-Object byte[] (1MB)",
|
|
396
|
+
" while (($n = $in.Read($buffer, 0, $buffer.Length)) -gt 0) {",
|
|
397
|
+
" $out.Write($buffer, 0, $n)",
|
|
398
|
+
" }",
|
|
399
|
+
" $in.Close()",
|
|
400
|
+
"}",
|
|
401
|
+
"$out.Close()",
|
|
402
|
+
f"for ($i = 0; $i -lt {total_chunks}; $i++) {{",
|
|
403
|
+
f" $chunkName = '{escaped_base}.part' + $i.ToString('{pad_format}')",
|
|
404
|
+
f" $chunkPath = Join-Path '{escaped_dir}' $chunkName",
|
|
405
|
+
" Remove-Item -Path $chunkPath -Force -ErrorAction SilentlyContinue",
|
|
406
|
+
"}",
|
|
407
|
+
f"Write-Output 'Combine and cleanup complete: {escaped_out}'"
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
ps_script = "\n".join(ps_script_lines)
|
|
411
|
+
|
|
412
|
+
# Step 3: Execute recombine script remotely
|
|
413
|
+
self._info("Combining chunks and cleaning up on remote machine...")
|
|
414
|
+
result = self.appium_execute_powershell_script(ps_script)
|
|
415
|
+
self._info(f"Remote PowerShell result: {result}")
|
|
416
|
+
return escaped_out
|
|
417
|
+
|
|
418
|
+
# Private
|
|
419
|
+
|
|
420
|
+
def _parse_location(self, rect, kwargs, x_offset_key, y_offset_key):
|
|
421
|
+
"""Parse offset inside a rect."""
|
|
422
|
+
offset = kwargs.get('offset')
|
|
423
|
+
x_offset = offset if offset is not None else kwargs.get(x_offset_key, 'center')
|
|
424
|
+
y_offset = offset if offset is not None else kwargs.get(y_offset_key, 'center')
|
|
425
|
+
|
|
426
|
+
x = rect['x'] + (rect['width'] // 2 if x_offset == 'center' else int(x_offset))
|
|
427
|
+
y = rect['y'] + (rect['height'] // 2 if y_offset == 'center' else int(y_offset))
|
|
428
|
+
return x, y
|
|
429
|
+
|
|
430
|
+
def _generate_click_command(self, x, y, button='left'):
|
|
431
|
+
"""
|
|
432
|
+
Generate a PowerShell command to click at the given coordinates.
|
|
433
|
+
|
|
434
|
+
https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-mouse_event
|
|
435
|
+
|
|
436
|
+
@param x: X coordinate
|
|
437
|
+
@param y: Y coordinate
|
|
438
|
+
@param button:
|
|
439
|
+
'left' - single left click
|
|
440
|
+
'right' - single right click
|
|
441
|
+
'middle' - single middle click
|
|
442
|
+
'double' - double left click
|
|
443
|
+
'triple' - triple left click
|
|
444
|
+
'right-double' - double right click
|
|
445
|
+
@return: PowerShell one-liner as string
|
|
446
|
+
"""
|
|
447
|
+
button = button.lower()
|
|
448
|
+
|
|
449
|
+
# Mouse event codes
|
|
450
|
+
left_code = 6 # MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_LEFTUP
|
|
451
|
+
right_code = 24 # MOUSEEVENTF_RIGHTDOWN | MOUSEEVENTF_RIGHTUP
|
|
452
|
+
middle_code = 40 # MOUSEEVENTF_MIDDLEDOWN | MOUSEEVENTF_MIDDLEUP
|
|
453
|
+
|
|
454
|
+
# Define all buttons
|
|
455
|
+
click_events = {
|
|
456
|
+
'left': f'[M]::mouse_event({left_code},0,0,0,[UIntPtr]::Zero);',
|
|
457
|
+
'right': f'[M]::mouse_event({right_code},0,0,0,[UIntPtr]::Zero);',
|
|
458
|
+
'middle': f'[M]::mouse_event({middle_code},0,0,0,[UIntPtr]::Zero);',
|
|
459
|
+
'double': (
|
|
460
|
+
f'[M]::mouse_event({left_code},0,0,0,[UIntPtr]::Zero);'
|
|
461
|
+
f'Start-Sleep -m 100;'
|
|
462
|
+
f'[M]::mouse_event({left_code},0,0,0,[UIntPtr]::Zero);'
|
|
463
|
+
),
|
|
464
|
+
'triple': (
|
|
465
|
+
f'[M]::mouse_event({left_code},0,0,0,[UIntPtr]::Zero);'
|
|
466
|
+
f'Start-Sleep -m 100;'
|
|
467
|
+
f'[M]::mouse_event({left_code},0,0,0,[UIntPtr]::Zero);'
|
|
468
|
+
f'Start-Sleep -m 100;'
|
|
469
|
+
f'[M]::mouse_event({left_code},0,0,0,[UIntPtr]::Zero);'
|
|
470
|
+
),
|
|
471
|
+
'right-double': (
|
|
472
|
+
f'[M]::mouse_event({right_code},0,0,0,[UIntPtr]::Zero);'
|
|
473
|
+
f'Start-Sleep -m 100;'
|
|
474
|
+
f'[M]::mouse_event({right_code},0,0,0,[UIntPtr]::Zero);'
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if button not in click_events:
|
|
479
|
+
valid = ', '.join(click_events.keys())
|
|
480
|
+
raise ValueError(f"button must be one of: {valid}")
|
|
481
|
+
|
|
482
|
+
event_code = click_events[button]
|
|
483
|
+
|
|
484
|
+
ps_cmd = (
|
|
485
|
+
'if (-not ("M" -as [type])) {'
|
|
486
|
+
"Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;"
|
|
487
|
+
'public class M{'
|
|
488
|
+
'[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y);'
|
|
489
|
+
'[DllImport("user32.dll")]public static extern void mouse_event(uint f,uint dx,uint dy,uint d,UIntPtr i);}'
|
|
490
|
+
"'|Out-Null;};"
|
|
491
|
+
f'[M]::SetCursorPos({x},{y});Start-Sleep -m 300;'
|
|
492
|
+
f'{event_code}'
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return ps_cmd
|
|
496
|
+
|
|
497
|
+
def _generate_drag_command(self, x_start, y_start, x_end, y_end, button='left', duration_sec=0.5):
|
|
498
|
+
"""
|
|
499
|
+
https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-mouse_event
|
|
500
|
+
|
|
501
|
+
Generate a PowerShell command to simulate mouse drag from (x_start, y_start) to (x_end, y_end)
|
|
502
|
+
over the given duration in seconds.
|
|
503
|
+
|
|
504
|
+
@param x_start: Start X coordinate
|
|
505
|
+
@param y_start: Start Y coordinate
|
|
506
|
+
@param x_end: End X coordinate
|
|
507
|
+
@param y_end: End Y coordinate
|
|
508
|
+
@param button: 'left' or 'right'
|
|
509
|
+
@param duration_sec: Total drag duration in seconds
|
|
510
|
+
@return: PowerShell command string
|
|
511
|
+
"""
|
|
512
|
+
button = button.lower()
|
|
513
|
+
button_codes = {
|
|
514
|
+
'left': (0x0002, 0x0004), # MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP
|
|
515
|
+
'right': (0x0008, 0x0010), # MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP
|
|
516
|
+
'middle': (0x0020, 0x0040), # MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if button not in button_codes:
|
|
520
|
+
raise ValueError("button must be 'left', 'right', or 'middle'")
|
|
521
|
+
|
|
522
|
+
down_code, up_code = button_codes[button]
|
|
523
|
+
|
|
524
|
+
delay_ms = 50
|
|
525
|
+
steps = round((float(duration_sec) * 1000) / delay_ms)
|
|
526
|
+
dx = x_end - x_start
|
|
527
|
+
dy = y_end - y_start
|
|
528
|
+
|
|
529
|
+
ps_cmd = (
|
|
530
|
+
'if (-not ("M" -as [type])) {'
|
|
531
|
+
"Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;"
|
|
532
|
+
'public class M{'
|
|
533
|
+
'[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y);'
|
|
534
|
+
'[DllImport("user32.dll")]public static extern void mouse_event(uint f,uint dx,uint dy,uint d,UIntPtr i);}'
|
|
535
|
+
"'|Out-Null;};"
|
|
536
|
+
f'[M]::SetCursorPos({x_start},{y_start})|Out-Null; Start-Sleep -m 300;'
|
|
537
|
+
f'[M]::mouse_event({down_code},0,0,0,[UIntPtr]::Zero); Start-Sleep -m 300;'
|
|
538
|
+
f'for ($i=1; $i -le {steps}; $i++){{'
|
|
539
|
+
f'$x={x_start}+[math]::Round({dx}*$i/{steps});'
|
|
540
|
+
f'$y={y_start}+[math]::Round({dy}*$i/{steps});'
|
|
541
|
+
f'[M]::SetCursorPos($x,$y)|Out-Null; Start-Sleep -m {delay_ms};'
|
|
542
|
+
'};'
|
|
543
|
+
f'[M]::SetCursorPos({x_end},{y_end})|Out-Null; Start-Sleep -m 300;'
|
|
544
|
+
f'[M]::mouse_event({up_code},0,0,0,[UIntPtr]::Zero);'
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
return ps_cmd
|
|
548
|
+
|
|
549
|
+
def _generate_keyboard_command(self, sequences):
|
|
550
|
+
raise Exception('Not Implement yet')
|
|
551
|
+
|
|
552
|
+
def _script_path(self, name):
|
|
553
|
+
return os.path.join(SCRIPTS_DIR, name)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from robot.libraries import BuiltIn
|
|
4
|
+
|
|
5
|
+
from .keywordgroup import KeywordGroup
|
|
6
|
+
|
|
7
|
+
BUILTIN = BuiltIn.BuiltIn()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _RunOnFailureKeywords(KeywordGroup):
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._run_on_failure_keyword = None
|
|
14
|
+
self._running_on_failure_routine = False
|
|
15
|
+
|
|
16
|
+
# Public
|
|
17
|
+
|
|
18
|
+
def register_keyword_to_run_on_failure(self, keyword):
|
|
19
|
+
"""Sets the keyword to execute when a AppiumLibrary keyword fails.
|
|
20
|
+
|
|
21
|
+
`keyword_name` is the name of a keyword (from any available
|
|
22
|
+
libraries) that will be executed if a AppiumLibrary keyword fails.
|
|
23
|
+
It is not possible to use a keyword that requires arguments.
|
|
24
|
+
Using the value "Nothing" will disable this feature altogether.
|
|
25
|
+
|
|
26
|
+
The initial keyword to use is set in `importing`, and the
|
|
27
|
+
keyword that is used by default is `Capture Page Screenshot`.
|
|
28
|
+
Taking a screenshot when something failed is a very useful
|
|
29
|
+
feature, but notice that it can slow down the execution.
|
|
30
|
+
|
|
31
|
+
This keyword returns the name of the previously registered
|
|
32
|
+
failure keyword. It can be used to restore the original
|
|
33
|
+
value later.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
| Register Keyword To Run On Failure | Log Source | # Run `Log Source` on failure. |
|
|
37
|
+
| ${previous kw}= | Register Keyword To Run On Failure | Nothing | # Disables run-on-failure functionality and stores the previous kw name in a variable. |
|
|
38
|
+
| Register Keyword To Run On Failure | ${previous kw} | # Restore to the previous keyword. |
|
|
39
|
+
|
|
40
|
+
This run-on-failure functionality only works when running tests on Python/Jython 2.4
|
|
41
|
+
or newer and it does not work on IronPython at all.
|
|
42
|
+
"""
|
|
43
|
+
old_keyword = self._run_on_failure_keyword
|
|
44
|
+
old_keyword_text = old_keyword if old_keyword is not None else "Nothing"
|
|
45
|
+
|
|
46
|
+
new_keyword = keyword if keyword.strip().lower() != "nothing" else None
|
|
47
|
+
new_keyword_text = new_keyword if new_keyword is not None else "Nothing"
|
|
48
|
+
|
|
49
|
+
self._run_on_failure_keyword = new_keyword
|
|
50
|
+
self._info('%s will be run on failure.' % new_keyword_text)
|
|
51
|
+
|
|
52
|
+
return old_keyword_text
|
|
53
|
+
|
|
54
|
+
# Private
|
|
55
|
+
|
|
56
|
+
def _run_on_failure(self):
|
|
57
|
+
if self._run_on_failure_keyword is None:
|
|
58
|
+
return
|
|
59
|
+
if self._running_on_failure_routine:
|
|
60
|
+
return
|
|
61
|
+
self._running_on_failure_routine = True
|
|
62
|
+
try:
|
|
63
|
+
BUILTIN.run_keyword(self._run_on_failure_keyword)
|
|
64
|
+
except Exception as err:
|
|
65
|
+
self._run_on_failure_error(err)
|
|
66
|
+
finally:
|
|
67
|
+
self._running_on_failure_routine = False
|
|
68
|
+
|
|
69
|
+
def _run_on_failure_error(self, err):
|
|
70
|
+
err = "Keyword '%s' could not be run on failure: %s" % (self._run_on_failure_keyword, err)
|
|
71
|
+
if hasattr(self, '_warn'):
|
|
72
|
+
self._warn(err)
|
|
73
|
+
return
|
|
74
|
+
raise Exception(err)
|