mops 0.0.1a2__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.
mops/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = '0.0.1a2'
2
+ __project_name__ = 'mops'
mops/exceptions.py ADDED
@@ -0,0 +1,133 @@
1
+ from typing import Any
2
+
3
+
4
+ class DriverWrapperException(Exception):
5
+ """
6
+ Base driver wrapper exceptions
7
+ """
8
+
9
+ def __init__(
10
+ self,
11
+ msg: str,
12
+ actual: Any = None,
13
+ expected: Any = None,
14
+ timeout: Any = None,
15
+ info: Any = None
16
+ ):
17
+ self._msg = ''
18
+ self._original_msg = msg
19
+ self._timeout = timeout
20
+ self._actual = actual
21
+ self._expected = expected
22
+ self._info = info
23
+ self.__suppress_context__ = True
24
+
25
+ def __str__(self) -> str:
26
+ return f"\nMessage: {self.msg}"
27
+
28
+ @property
29
+ def msg(self):
30
+ self._msg = f'{self._original_msg} '
31
+
32
+ if self._timeout:
33
+ self._msg += f'after {self._timeout} seconds. '
34
+ if self._expected is not None:
35
+ self._msg += f'Actual: {self.wrap_by_quotes(self._actual)}; ' \
36
+ f'Expected: {self.wrap_by_quotes(self._expected)}. '
37
+ if self._info:
38
+ self._msg += f'{self._info.get_element_info()}. '
39
+
40
+ return self._msg.rstrip()
41
+
42
+ def wrap_by_quotes(self, data):
43
+ if data is None:
44
+ data = ""
45
+
46
+ if isinstance(data, str):
47
+ return f'"{data}"'
48
+
49
+ return data
50
+
51
+
52
+ class UnexpectedElementsCountException(DriverWrapperException):
53
+ """
54
+ Thrown when elements count isn't equal to expected
55
+ """
56
+ pass
57
+
58
+
59
+ class UnexpectedElementSizeException(DriverWrapperException):
60
+ """
61
+ Thrown when element size isn't equal to expected
62
+ """
63
+ pass
64
+
65
+
66
+ class UnexpectedValueException(DriverWrapperException):
67
+ """
68
+ Thrown when element contains incorrect value
69
+ """
70
+ pass
71
+
72
+
73
+ class UnexpectedTextException(DriverWrapperException):
74
+ """
75
+ Thrown when element contains incorrect text
76
+ """
77
+ pass
78
+
79
+
80
+ class TimeoutException(DriverWrapperException):
81
+ """
82
+ Thrown when timeout exceeded
83
+ """
84
+ pass
85
+
86
+
87
+ class InvalidSelectorException(DriverWrapperException):
88
+ """
89
+ Thrown when element have invalid selector
90
+ """
91
+ pass
92
+
93
+
94
+ class NoSuchElementException(DriverWrapperException):
95
+ """
96
+ Thrown when element could not be found
97
+ """
98
+ pass
99
+
100
+
101
+ class NoSuchParentException(DriverWrapperException):
102
+ """
103
+ Thrown when parent could not be found
104
+ """
105
+ pass
106
+
107
+
108
+ class ElementNotInteractableException(DriverWrapperException):
109
+ """
110
+ Thrown when element found and enabled but not interactable
111
+ """
112
+ pass
113
+
114
+
115
+ class UnsuitableArgumentsException(DriverWrapperException):
116
+ """
117
+ Thrown when object initialised with unsuitable arguments
118
+ """
119
+ pass
120
+
121
+
122
+ class NotInitializedException(DriverWrapperException):
123
+ """
124
+ Thrown when getting access to not initialized object
125
+ """
126
+ pass
127
+
128
+
129
+ class InvalidLocatorException(DriverWrapperException):
130
+ """
131
+ Thrown when locator is invalid
132
+ """
133
+ pass
mops/js_scripts.py ADDED
@@ -0,0 +1,216 @@
1
+ get_inner_height_js = 'return window.innerHeight'
2
+ get_inner_width_js = 'return window.innerWidth'
3
+ js_click = 'arguments[0].click();'
4
+
5
+ get_element_position_on_screen_js = """
6
+ function getPositionOnScreen(elem) {
7
+ let box = elem.getBoundingClientRect();
8
+ var y;
9
+ var x;
10
+ y = Math.round(box.top)
11
+ x = Math.round(box.left)
12
+ return {
13
+ x: x,
14
+ y: y
15
+ };
16
+ };
17
+ return getPositionOnScreen(arguments[0])
18
+ """
19
+
20
+ get_element_size_js = """
21
+ function getSize(elem) {
22
+ let box = elem.getBoundingClientRect();
23
+ var width;
24
+ var height;
25
+ width = Math.round(box.width)
26
+ height = Math.round(box.height)
27
+ return {
28
+ width: width,
29
+ height: height
30
+ };
31
+ };
32
+ return getSize(arguments[0])
33
+ """
34
+
35
+ delete_element_over_js = """
36
+ const elements = document.getElementsByClassName("driver-wrapper-visual-comparison-support-element");
37
+
38
+ for (var i=0, max=elements.length; i < max; i++) {
39
+ elements[0].remove()
40
+ };
41
+ """
42
+
43
+ add_element_over_js = """
44
+ function appendElement(given_obj) {
45
+ given_obj = given_obj.getBoundingClientRect();
46
+ driver_wrapper_obj = document.createElement("div");
47
+
48
+ driver_wrapper_obj.style.zIndex=9999999;
49
+ driver_wrapper_obj.setAttribute("class","driver-wrapper-comparison-support-element");
50
+
51
+ driver_wrapper_obj.style.position = "absolute";
52
+ driver_wrapper_obj.style.backgroundColor = "#000";
53
+
54
+ driver_wrapper_obj.style.width = given_obj.width + "px";
55
+ driver_wrapper_obj.style.height = given_obj.height + "px";
56
+ driver_wrapper_obj.style.top = (given_obj.top + window.scrollY) + "px";
57
+ driver_wrapper_obj.style.left = (given_obj.left + window.scrollX) + "px";
58
+
59
+ document.body.appendChild(driver_wrapper_obj);
60
+ };
61
+
62
+ return appendElement(arguments[0]);
63
+ """
64
+
65
+
66
+ add_driver_index_comment_js = """
67
+ function addComment(driver_index) {
68
+ comment = document.createComment(" " + driver_index + " ");
69
+ document.body.appendChild(comment);
70
+ };
71
+
72
+ addComment(arguments[0])
73
+ """
74
+
75
+ find_comments_js = """
76
+ function filterNone() {
77
+ return NodeFilter.FILTER_ACCEPT;
78
+ }
79
+
80
+ function getAllComments(rootElem) {
81
+ var comments = [];
82
+ // Fourth argument, which is actually obsolete according to the DOM4 standard, is required in IE 11
83
+ var iterator = document.createNodeIterator(rootElem, NodeFilter.SHOW_COMMENT, filterNone, false);
84
+ var curNode;
85
+ while (curNode = iterator.nextNode()) {
86
+ comments.push(curNode.nodeValue);
87
+ }
88
+ return comments;
89
+ }
90
+
91
+ return getAllComments(document.body);
92
+ """
93
+
94
+ trigger_react = """
95
+ function reactTriggerChange(node) {
96
+ var supportedInputTypes = {
97
+ color: true,
98
+ date: true,
99
+ datetime: true,
100
+ 'datetime-local': true,
101
+ email: true,
102
+ month: true,
103
+ number: true,
104
+ password: true,
105
+ range: true,
106
+ search: true,
107
+ tel: true,
108
+ text: true,
109
+ time: true,
110
+ url: true,
111
+ week: true
112
+ };
113
+ var nodeName = node.nodeName.toLowerCase();
114
+ var type = node.type;
115
+ var event;
116
+ var descriptor;
117
+ var initialValue;
118
+ var initialChecked;
119
+ var initialCheckedRadio;
120
+ function deletePropertySafe(elem, prop) {
121
+ var desc = Object.getOwnPropertyDescriptor(elem, prop);
122
+ if (desc && desc.configurable) {
123
+ delete elem[prop];
124
+ }
125
+ }
126
+ function changeRangeValue(range) {
127
+ var initMin = range.min;
128
+ var initMax = range.max;
129
+ var initStep = range.step;
130
+ var initVal = Number(range.value);
131
+ range.min = initVal;
132
+ range.max = initVal + 1;
133
+ range.step = 1;
134
+ range.value = initVal + 1;
135
+ deletePropertySafe(range, 'value');
136
+ range.min = initMin;
137
+ range.max = initMax;
138
+ range.step = initStep;
139
+ range.value = initVal;
140
+ }
141
+ function getCheckedRadio(radio) {
142
+ var name = radio.name;
143
+ var radios;
144
+ var i;
145
+ if (name) {
146
+ radios = document.querySelectorAll('input[type="radio"][name="' + name + '"]');
147
+ for (i = 0; i < radios.length; i += 1) {
148
+ if (radios[i].checked) {
149
+ return radios[i] !== radio ? radios[i] : null;
150
+ }
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ function preventChecking(e) {
156
+ e.preventDefault();
157
+ if (!initialChecked) {
158
+ e.target.checked = false;
159
+ }
160
+ if (initialCheckedRadio) {
161
+ initialCheckedRadio.checked = true;
162
+ }
163
+ }
164
+ if (nodeName === 'select' ||
165
+ (nodeName === 'input' && type === 'file')) {
166
+ event = document.createEvent('HTMLEvents');
167
+ event.initEvent('change', true, false);
168
+ node.dispatchEvent(event);
169
+ } else if ((nodeName === 'input' && supportedInputTypes[type]) ||
170
+ nodeName === 'textarea') {
171
+ descriptor = Object.getOwnPropertyDescriptor(node, 'value');
172
+ event = document.createEvent('UIEvents');
173
+ event.initEvent('focus', false, false);
174
+ node.dispatchEvent(event);
175
+ if (type === 'range') {
176
+ changeRangeValue(node);
177
+ } else {
178
+ initialValue = node.value;
179
+ node.value = initialValue + '#';
180
+ deletePropertySafe(node, 'value');
181
+ node.value = initialValue;
182
+ }
183
+ event = document.createEvent('HTMLEvents');
184
+ event.initEvent('propertychange', false, false);
185
+ event.propertyName = 'value';
186
+ node.dispatchEvent(event);
187
+ event = document.createEvent('HTMLEvents');
188
+ event.initEvent('input', true, false);
189
+ node.dispatchEvent(event);
190
+ if (descriptor) {
191
+ Object.defineProperty(node, 'value', descriptor);
192
+ }
193
+ } else if (nodeName === 'input' && type === 'checkbox') {
194
+ node.checked = !node.checked;
195
+ event = document.createEvent('MouseEvents');
196
+ event.initEvent('click', true, true);
197
+ node.dispatchEvent(event);
198
+ } else if (nodeName === 'input' && type === 'radio') {
199
+ initialChecked = node.checked;
200
+ initialCheckedRadio = getCheckedRadio(node);
201
+ descriptor = Object.getOwnPropertyDescriptor(node, 'checked');
202
+ node.checked = !initialChecked;
203
+ deletePropertySafe(node, 'checked');
204
+ node.checked = initialChecked;
205
+ node.addEventListener('click', preventChecking, true);
206
+ event = document.createEvent('MouseEvents');
207
+ event.initEvent('click', true, true);
208
+ node.dispatchEvent(event);
209
+ node.removeEventListener('click', preventChecking, true);
210
+ if (descriptor) {
211
+ Object.defineProperty(node, 'checked', descriptor);
212
+ }
213
+ }
214
+ };
215
+ reactTriggerChange(arguments[0]);
216
+ """
mops/keyboard_keys.py ADDED
@@ -0,0 +1,92 @@
1
+ from selenium.webdriver import Keys as SeleniumSourceKeys
2
+
3
+ from mops.base.driver_wrapper import DriverWrapper
4
+
5
+
6
+ class SeleniumKeys(SeleniumSourceKeys):
7
+ pass
8
+
9
+
10
+ class PlaywrightKeys:
11
+
12
+ # NULL = '\ue000'
13
+ # CANCEL = '\ue001' # ^break
14
+ # HELP = '\ue002'
15
+ BACKSPACE = 'Backspace'
16
+ BACK_SPACE = BACKSPACE
17
+ TAB = 'Tab'
18
+ # CLEAR = '\ue005'
19
+ # RETURN = '\ue006'
20
+ ENTER = 'Enter'
21
+ SHIFT = 'Shift'
22
+ LEFT_SHIFT = SHIFT
23
+ CONTROL = 'Control'
24
+ LEFT_CONTROL = CONTROL
25
+ ALT = 'Alt'
26
+ LEFT_ALT = ALT
27
+ # PAUSE = '\ue00b'
28
+ ESCAPE = 'Escape'
29
+ # SPACE = '\ue00d'
30
+ PAGE_UP = 'PageUp'
31
+ PAGE_DOWN = 'PageDown'
32
+ END = 'End'
33
+ HOME = 'Home'
34
+ LEFT = 'ArrowLeft'
35
+ ARROW_LEFT = LEFT
36
+ UP = 'ArrowUp'
37
+ ARROW_UP = UP
38
+ RIGHT = 'ArrowRight'
39
+ ARROW_RIGHT = RIGHT
40
+ DOWN = 'ArrowDown'
41
+ ARROW_DOWN = DOWN
42
+ INSERT = 'Insert'
43
+ DELETE = 'Delete'
44
+ # SEMICOLON = '\ue018'
45
+ EQUALS = 'Equal'
46
+
47
+ NUMPAD0 = 'Digit0' # number pad keys
48
+ NUMPAD1 = 'Digit1'
49
+ NUMPAD2 = 'Digit2'
50
+ NUMPAD3 = 'Digit3'
51
+ NUMPAD4 = 'Digit4'
52
+ NUMPAD5 = 'Digit5'
53
+ NUMPAD6 = 'Digit6'
54
+ NUMPAD7 = 'Digit7'
55
+ NUMPAD8 = 'Digit8'
56
+ NUMPAD9 = 'Digit9'
57
+ # MULTIPLY = '\ue024'
58
+ # ADD = '\ue025'
59
+ # SEPARATOR = '\ue026'
60
+ # SUBTRACT = '\ue027'
61
+ # DECIMAL = '\ue028'
62
+ # DIVIDE = '\ue029'
63
+
64
+ F1 = 'F1'
65
+ F2 = 'F2'
66
+ F3 = 'F3'
67
+ F4 = 'F4'
68
+ F5 = 'F5'
69
+ F6 = 'F6'
70
+ F7 = 'F7'
71
+ F8 = 'F8'
72
+ F9 = 'F9'
73
+ F10 = 'F10'
74
+ F11 = 'F11'
75
+ F12 = 'F12'
76
+
77
+ META = 'Meta'
78
+ # COMMAND = '\ue03d'
79
+ # ZENKAKU_HANKAKU = '\ue040'
80
+
81
+
82
+ class Interceptor(type):
83
+
84
+ def __getattribute__(self, item):
85
+ if DriverWrapper.is_selenium:
86
+ return getattr(SeleniumKeys, item)
87
+ else:
88
+ return getattr(PlaywrightKeys, item, NotImplementedError(f'Key is not added to Mops framework'))
89
+
90
+
91
+ class KeyboardKeys(SeleniumKeys, PlaywrightKeys, metaclass=Interceptor):
92
+ pass
mops/shared_utils.py ADDED
@@ -0,0 +1,102 @@
1
+ import io
2
+ import logging
3
+ from subprocess import Popen, PIPE, run
4
+
5
+ from PIL import Image
6
+
7
+
8
+ def _scaled_screenshot(screenshot_binary: bytes, width: int) -> Image:
9
+ """
10
+ Get scaled screenshot to fit driver window / element size
11
+
12
+ :param screenshot_binary: original screenshot binary
13
+ :param width: driver or element width
14
+ :return: scaled image binary
15
+ """
16
+ img_binary = get_image(screenshot_binary)
17
+ scale = img_binary.size[0] / width
18
+
19
+ if scale != 1:
20
+ new_image_size = (int(img_binary.size[0] / scale), int(img_binary.size[1] / scale))
21
+ img_binary = img_binary.resize(new_image_size, Image.Resampling.LANCZOS)
22
+
23
+ return img_binary
24
+
25
+
26
+ def get_image(screenshot_binary: bytes):
27
+ return Image.open(io.BytesIO(screenshot_binary))
28
+
29
+
30
+ def rescale_image(screenshot_binary: bytes, scale=3, img_format='JPEG') -> bytes:
31
+ img = get_image(screenshot_binary)
32
+ img = img.resize((img.width // scale, img.height // scale), Image.Resampling.LANCZOS)
33
+
34
+ return save_image(img, img_format)
35
+
36
+
37
+ def resize_image(image1: str, image2: str, img_format='JPEG') -> bytes:
38
+ img1 = Image.open(image1)
39
+ img2 = Image.open(image2)
40
+
41
+ width, height = img2.size
42
+ img1.resize((width, height), Image.Resampling.LANCZOS)
43
+
44
+ return save_image(img1, img_format)
45
+
46
+
47
+ def save_image(img: Image, img_format='JPEG'):
48
+ result_img_binary = io.BytesIO()
49
+ img.convert('RGB').save(result_img_binary, format=img_format, optimize=True)
50
+ return result_img_binary.getvalue()
51
+
52
+
53
+ def shell_running_command(cmd, **kwargs):
54
+ return Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE, close_fds=True, **kwargs)
55
+
56
+
57
+ def shell_command(cmd, **kwargs):
58
+ process = run(cmd, shell=True, **kwargs)
59
+
60
+ if process.stdout:
61
+ process.output = process.stdout.decode('utf8').replace('\n', '')
62
+ if process.stderr:
63
+ process.errors = process.stderr.decode('utf8').replace('\n', '')
64
+ if isinstance(process.returncode, int):
65
+ process.is_success = process.returncode == 0
66
+
67
+ return process
68
+
69
+
70
+ def cut_log_data(data: str, length=50) -> str:
71
+ """
72
+ Cut given data for reducing log length
73
+
74
+ :param data: original data ~ 'very long string for typing. string endless continues'
75
+ :param length: length to cut given data ~ 20
76
+ :return: edited data ~ 'Type text: "very long string for >>> 36 characters"'
77
+ """
78
+ data = str(data)
79
+ return f'{data[:length]} >>> {len(data[length:])} characters' if len(data) > length else data
80
+
81
+
82
+ def disable_logging(loggers: list) -> None:
83
+ """
84
+ Disable logging for given loggers
85
+
86
+ :param loggers: list of loggers to be disabled
87
+ :return: None
88
+ """
89
+ for logger in loggers:
90
+ logging.getLogger(logger).disabled = True
91
+
92
+
93
+ def set_log_level(loggers: list, level: int) -> None:
94
+ """
95
+ Set log level for given loggers
96
+
97
+ :param loggers: list of loggers to be disabled
98
+ :param level: level to be set
99
+ :return: None
100
+ """
101
+ for logger in loggers:
102
+ logging.getLogger(logger).setLevel(level)
@@ -0,0 +1,461 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import time
7
+ import math
8
+ import json
9
+ import base64
10
+ import importlib
11
+ from urllib.parse import urljoin
12
+ from typing import Union, List, Any, Tuple, Optional
13
+ from string import punctuation
14
+
15
+ try:
16
+ import cv2.cv2 as cv2 # ~cv2@4.5.5.62 + python@3.8/9/10
17
+ except ImportError:
18
+ import cv2 # ~cv2@4.10.0.84 + python@3.11/12
19
+ import numpy
20
+ from skimage._shared.utils import check_shape_equality # noqa
21
+ from skimage.metrics import structural_similarity
22
+ from PIL import Image
23
+
24
+ from mops.exceptions import DriverWrapperException, TimeoutException
25
+ from mops.js_scripts import add_element_over_js, delete_element_over_js
26
+ from mops.mixins.objects.cut_box import CutBox
27
+ from mops.utils.logs import autolog
28
+ from mops.mixins.internal_mixin import get_element_info
29
+
30
+
31
+ class VisualComparison:
32
+
33
+ visual_regression_path = ''
34
+ test_item = None
35
+ attach_diff_image_path = False
36
+ skip_screenshot_comparison = False
37
+ visual_reference_generation = False
38
+ hard_visual_reference_generation = False
39
+ soft_visual_reference_generation = False
40
+ default_delay = 0.75
41
+ default_threshold = 0
42
+ dynamic_threshold_factor = 0
43
+ diff_color_scheme = (0, 255, 0)
44
+
45
+ __initialized = False
46
+
47
+ def __init__(self, driver_wrapper, element=None):
48
+ self.driver_wrapper = driver_wrapper
49
+ self.element_wrapper = element
50
+ self.screenshot_name = 'default'
51
+
52
+ if self.dynamic_threshold_factor and self.default_threshold:
53
+ raise Exception('Provide only one argument for threshold of visual comparison')
54
+
55
+ if not self.__initialized:
56
+ self.__init_session()
57
+
58
+ def __init_session(self):
59
+ root_path = self.visual_regression_path
60
+
61
+ if not root_path:
62
+ raise Exception('Provide visual regression path to environment. '
63
+ f'Example: {self.__class__.__name__}.visual_regression_path = "src"')
64
+
65
+ root_path = root_path if root_path.endswith('/') else f'{root_path}/'
66
+ self.reference_directory = f'{root_path}reference/'
67
+ self.output_directory = f'{root_path}output/'
68
+ self.diff_directory = f'{root_path}difference/'
69
+
70
+ os.makedirs(os.path.dirname(self.reference_directory), exist_ok=True)
71
+ os.makedirs(os.path.dirname(self.output_directory), exist_ok=True)
72
+ os.makedirs(os.path.dirname(self.diff_directory), exist_ok=True)
73
+
74
+ self.__initialized = True
75
+
76
+ def _save_screenshot(
77
+ self,
78
+ screenshot_name: str,
79
+ delay: Union[int, float],
80
+ remove: list,
81
+ fill_background: bool,
82
+ cut_box: Optional[CutBox],
83
+ ):
84
+ time.sleep(delay)
85
+
86
+ self._fill_background(fill_background)
87
+ self._appends_dummy_elements(remove)
88
+
89
+ if fill_background or remove:
90
+ time.sleep(0.1)
91
+
92
+ desired_obj = self.element_wrapper or self.driver_wrapper.anchor or self.driver_wrapper
93
+ image = desired_obj.screenshot_image()
94
+
95
+ if cut_box:
96
+ image = image.crop(cut_box.get_box(image.size))
97
+
98
+ desired_obj.save_screenshot(screenshot_name, screenshot_base=image)
99
+
100
+ self._remove_dummy_elements()
101
+
102
+ def assert_screenshot(
103
+ self,
104
+ filename: str,
105
+ test_name: str,
106
+ name_suffix: str,
107
+ threshold: Union[int, float],
108
+ delay: Union[int, float],
109
+ scroll: bool,
110
+ remove: List[Any],
111
+ fill_background: Union[str, bool],
112
+ cut_box: Optional[CutBox]
113
+ ) -> VisualComparison:
114
+ """
115
+ Assert given (by name) and taken screenshot equals
116
+
117
+ :param filename: full screenshot name. Custom filename will be used if empty string given
118
+ :param test_name: test name for custom filename. Will try to find it automatically if empty string given
119
+ :param name_suffix: filename suffix. Good to use for same element with positive/negative case
120
+ :param threshold: possible threshold
121
+ :param delay: delay before taking screenshot
122
+ :param scroll: scroll to element before taking the screenshot
123
+ :param remove: remove elements from screenshot
124
+ :param fill_background: fill background with given color or black color by default
125
+ :param cut_box: custom coordinates, that will be cut from original image (left, top, right, bottom)
126
+ :return: self
127
+ """
128
+ if self.skip_screenshot_comparison:
129
+ return self
130
+
131
+ remove = remove if remove else []
132
+ screenshot_params = dict(delay=delay, remove=remove, fill_background=fill_background, cut_box=cut_box)
133
+
134
+ if filename:
135
+ if name_suffix:
136
+ filename = f'{filename}_{name_suffix}'
137
+ self.screenshot_name = filename
138
+ else:
139
+ self.screenshot_name = self._get_screenshot_name(test_name, name_suffix)
140
+
141
+ reference_file = f'{self.reference_directory}{self.screenshot_name}.png'
142
+ output_file = f'{self.output_directory}{self.screenshot_name}.png'
143
+ diff_file = f'{self.diff_directory}diff_{self.screenshot_name}.png'
144
+
145
+ if scroll:
146
+ self.element_wrapper.scroll_into_view()
147
+
148
+ if self.hard_visual_reference_generation:
149
+ self._save_screenshot(reference_file, **screenshot_params)
150
+ return self
151
+
152
+ image = cv2.imread(reference_file)
153
+ if isinstance(image, type(None)):
154
+ self._save_screenshot(reference_file, **screenshot_params)
155
+
156
+ if self.visual_reference_generation or self.soft_visual_reference_generation:
157
+ return self
158
+
159
+ self._disable_reruns()
160
+
161
+ self._attach_allure_diff(reference_file, reference_file, reference_file)
162
+ raise AssertionError(f'Reference file "{reference_file}" not found, but its just saved. '
163
+ f'If it CI run, then you need to commit reference files.')
164
+
165
+ if self.visual_reference_generation and not self.soft_visual_reference_generation:
166
+ return self
167
+
168
+ self._save_screenshot(output_file, **screenshot_params)
169
+
170
+ try:
171
+ self._assert_same_images(output_file, reference_file, diff_file, threshold)
172
+ for file_path in (output_file, diff_file):
173
+ if os.path.exists(file_path):
174
+ os.remove(file_path)
175
+ except AssertionError as exc:
176
+ if self.soft_visual_reference_generation:
177
+ if os.path.exists(reference_file):
178
+ os.remove(reference_file)
179
+ shutil.move(output_file, reference_file)
180
+ else:
181
+ raise exc
182
+
183
+ return self
184
+
185
+ @staticmethod
186
+ def calculate_threshold(file: str, dynamic_threshold_factor: int = None) -> Tuple:
187
+ """
188
+ Calculate possible threshold, based on dynamic_threshold_factor
189
+
190
+ :param file: image file path for calculation
191
+ :param dynamic_threshold_factor: use provided threshold factor
192
+ :return: tuple of calculated threshold and additional data
193
+ """
194
+ factor = VisualComparison.dynamic_threshold_factor or dynamic_threshold_factor
195
+ img = Image.open(file)
196
+ width, height = img.size
197
+ pixels_grid = height * width
198
+ calculated_threshold = factor / math.sqrt(pixels_grid)
199
+ pixels_allowed = int(pixels_grid / 100 * calculated_threshold)
200
+ return calculated_threshold, \
201
+ f'\nAdditional info: {width}x{height}; {calculated_threshold=}; {pixels_allowed=} from {pixels_grid}'
202
+
203
+ def _appends_dummy_elements(self, remove_data: list) -> VisualComparison:
204
+ """
205
+ Placed an element above each from given list and paints it black
206
+
207
+ :param remove_data: list of elements to be fake removed
208
+ :return: VisualComparison
209
+ """
210
+ for obj in remove_data:
211
+
212
+ try:
213
+ obj.wait_visibility(silent=True)
214
+ except TimeoutException:
215
+ msg = f'Cannot find {obj.name} while removing background from screenshot. {get_element_info(obj)}'
216
+ raise TimeoutException(msg)
217
+
218
+ obj.execute_script(add_element_over_js)
219
+ return self
220
+
221
+ def _remove_dummy_elements(self) -> VisualComparison:
222
+ """
223
+ Remove all dummy elements from DOM
224
+
225
+ :return: VisualComparison
226
+ """
227
+ self.driver_wrapper.execute_script(delete_element_over_js)
228
+ return self
229
+
230
+ def _fill_background(self, fill_background_data: Union[bool, str]) -> VisualComparison:
231
+ """
232
+ Fill background of element
233
+
234
+ :param fill_background_data: fill background with given color or black color by default
235
+ :return: VisualComparison
236
+ """
237
+ if not fill_background_data:
238
+ return self
239
+
240
+ element_wrapper = self.element_wrapper
241
+
242
+ color = fill_background_data if type(fill_background_data) is str else 'black'
243
+ element_wrapper\
244
+ .wait_visibility(silent=True)\
245
+ .execute_script(f'arguments[0].style.background = "{color}";')
246
+
247
+ return self
248
+
249
+ def _assert_same_images(self, actual_file: str, reference_file: str, diff_file: str,
250
+ threshold: Union[int, float]) -> VisualComparison:
251
+ """
252
+ Assert that given images are equal to each other
253
+
254
+ :param actual_file: actual image path
255
+ :param reference_file: reference image path
256
+ :param diff_file: difference image name
257
+ :param threshold: possible difference in percents
258
+ :return: VisualComparison
259
+ """
260
+ reference_image = cv2.imread(reference_file)
261
+ output_image = cv2.imread(actual_file)
262
+ threshold = threshold if threshold is not None else self.default_threshold
263
+
264
+ additional_data = ''
265
+ if not threshold:
266
+ threshold, additional_data = self.calculate_threshold(reference_file)
267
+
268
+ try:
269
+ check_shape_equality(reference_image, output_image)
270
+ except ValueError:
271
+ self._attach_allure_diff(actual_file, reference_file, actual_file)
272
+ # todo: watermark / fill size difference with color on diff image is better, but need more time
273
+ # rescale output image to the size of reference image, and save it as diff image
274
+ height, width, _ = reference_image.shape
275
+ scaled_image = cv2.resize(output_image, (width, height))
276
+ cv2.imwrite(diff_file, scaled_image)
277
+ raise AssertionError(f"↓\nImage size (width, height) is not same for '{self.screenshot_name}':"
278
+ f"\nExpected: {reference_image.shape[0:2]};"
279
+ f"\nActual: {output_image.shape[0:2]}.")
280
+
281
+ diff, actual_threshold = self._get_difference(reference_image, output_image, threshold)
282
+ is_different = actual_threshold > threshold
283
+
284
+ if is_different:
285
+ cv2.imwrite(diff_file, diff)
286
+ self._attach_allure_diff(actual_file, reference_file, diff_file)
287
+
288
+ diff_data = ""
289
+ if self.attach_diff_image_path:
290
+ diff_data = f"\nDiff image {urljoin('file:', diff_file)}"
291
+
292
+ base_error = f"↓\nVisual mismatch found for '{self.screenshot_name}'{diff_data}"
293
+
294
+ if is_different:
295
+ raise AssertionError(f"{base_error}:"
296
+ f"\nThreshold is: {actual_threshold};"
297
+ f"\nPossible threshold is: {threshold}"
298
+ + additional_data)
299
+
300
+ return self
301
+
302
+ def _get_screenshot_name(self, test_function_name: str = '', name_suffix: str = '') -> str:
303
+ """
304
+ Get screenshot name
305
+
306
+ :param test_function_name: execution test name. Will try to find it automatically if empty string given
307
+ :return: custom screenshot filename:
308
+ :::
309
+ - playwright: test_screenshot_rubiks_cube_playwright_chromium
310
+ - selenium: test_screenshot_rubiks_cube_mac_os_x_selenium_chrome
311
+ - appium ios: test_screenshot_rubiks_cube_iphone_13_v_15_4_appium_safari
312
+ - appium android: test_screenshot_rubiks_cube_pixel5_v_12_appium_chrome
313
+ :::
314
+ """
315
+ test_function_name = test_function_name if test_function_name else getattr(self.test_item, 'name', '')
316
+ if not test_function_name:
317
+ raise Exception('Draft: provide test item self.test_item')
318
+
319
+ test_function_name = test_function_name.replace('[', '_') # required here for better separation
320
+
321
+ if self.driver_wrapper.is_android or self.driver_wrapper.is_ios:
322
+ caps = self.driver_wrapper.driver.caps
323
+ device_name = caps.get('customDeviceName', '')
324
+
325
+ if self.driver_wrapper.is_android and not device_name:
326
+ device_name = caps.get('avd', f'{caps.get("deviceManufacturer")}_{caps.get("deviceModel", "none")}')
327
+ elif self.driver_wrapper.is_ios and not device_name:
328
+ device_name = caps['deviceName']
329
+
330
+ platform_version = caps['platformVersion']
331
+ screenshot_name = f'{device_name}_v_{platform_version}_appium_{self.driver_wrapper.browser_name}'
332
+ elif self.driver_wrapper.is_selenium:
333
+ platform_name = self.driver_wrapper.driver.caps["platformName"]
334
+ screenshot_name = f'{platform_name}_selenium_{self.driver_wrapper.browser_name}'
335
+ elif self.driver_wrapper.is_playwright:
336
+ screenshot_name = f'playwright_{self.driver_wrapper.browser_name}'
337
+ else:
338
+ raise DriverWrapperException('Cant find current platform')
339
+
340
+ name_suffix = f'_{name_suffix}_' if name_suffix else '_'
341
+ location_name = self.element_wrapper.name if self.element_wrapper else 'entire_screen'
342
+ base_name = f'{test_function_name}{location_name}{name_suffix}'
343
+ if 'mobile' not in base_name and self.driver_wrapper.is_mobile_resolution:
344
+ location_name += '_mobile_'
345
+ screenshot_name = f'{test_function_name}_{location_name}{name_suffix}{screenshot_name}'
346
+
347
+ for item in (']', '"', "'"):
348
+ screenshot_name = screenshot_name.replace(item, '')
349
+
350
+ for item in punctuation + ' ':
351
+ screenshot_name = screenshot_name.replace(item, '_')
352
+
353
+ return self._remove_unexpected_underscores(screenshot_name).lower()
354
+
355
+ def _get_difference(
356
+ self,
357
+ reference_img: numpy.ndarray,
358
+ actual_img: numpy.ndarray,
359
+ possible_threshold: Union[int, float]
360
+ ) -> tuple[numpy.ndarray, float]:
361
+ """
362
+ Calculate difference between two images
363
+
364
+ :param reference_img: image 1, numpy.ndarray
365
+ :param actual_img: image 2, numpy.ndarray
366
+ :return: (diff image, diff float value )
367
+ """
368
+ # Convert images to grayscale
369
+ reference_img_gray = cv2.cvtColor(reference_img, cv2.COLOR_BGR2GRAY)
370
+ actual_img_gray = cv2.cvtColor(actual_img, cv2.COLOR_BGR2GRAY)
371
+
372
+ # Compute SSIM between the two images
373
+ score, diff = structural_similarity(reference_img_gray, actual_img_gray, full=True)
374
+ score *= 100
375
+
376
+ # The diff image contains the actual image differences between the two images
377
+ # and is represented as a floating point data type in the range [0,1]
378
+ # so we must convert the array to 8-bit unsigned integers in the range
379
+ # [0,255] before we can use it with OpenCV
380
+ diff = (diff * 255).astype("uint8")
381
+ diff_box = cv2.merge([diff, diff, diff])
382
+
383
+ # Threshold the difference image, followed by finding contours to
384
+ # obtain the regions of the two input images that differ
385
+ thresh = cv2.threshold(diff, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
386
+ contours = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
387
+ contours = contours[0] if len(contours) == 2 else contours[1]
388
+
389
+ mask = numpy.zeros(reference_img.shape, dtype='uint8')
390
+ filled_after = actual_img.copy()
391
+ percent_diff = 100 - score
392
+ is_different_enough = percent_diff > possible_threshold
393
+
394
+ for c in contours:
395
+ if is_different_enough or cv2.contourArea(c) > 40:
396
+ x, y, w, h = cv2.boundingRect(c)
397
+ cv2.rectangle(reference_img, (x, y), (x + w, y + h), self.diff_color_scheme, 2)
398
+ cv2.rectangle(actual_img, (x, y), (x + w, y + h), self.diff_color_scheme, 2)
399
+ cv2.rectangle(diff_box, (x, y), (x + w, y + h), self.diff_color_scheme, 2)
400
+ cv2.drawContours(mask, [c], 0, (255, 255, 255), -1)
401
+ cv2.drawContours(filled_after, [c], 0, self.diff_color_scheme, -1)
402
+
403
+ diff_image, percent_diff = filled_after, 100 - score
404
+ return diff_image, percent_diff
405
+
406
+ def _attach_allure_diff(self, actual_path: str, expected_path: str, diff_path: str = None) -> None:
407
+ """
408
+ Attach screenshots to allure screen diff plugin
409
+ https://github.com/allure-framework/allure2/blob/master/plugins/screen-diff-plugin/README.md
410
+
411
+ :param actual_path: path of actual image
412
+ :param expected_path: path of expected image
413
+ :param diff_path: path of diff image
414
+ :return: None
415
+ """
416
+ allure = None
417
+
418
+ try:
419
+ allure = importlib.import_module('allure')
420
+ except ModuleNotFoundError:
421
+ autolog('Skip screenshot attaching due to allure module not found')
422
+
423
+ if allure:
424
+ data = [('actual', actual_path), ('expected', expected_path)]
425
+ diff_dict = {}
426
+
427
+ if diff_path:
428
+ data.append(('diff', diff_path))
429
+
430
+ for name, path in data:
431
+ with open(path, 'rb') as image:
432
+ diff_dict.update({name: f'data:image/png;base64,{base64.b64encode(image.read()).decode("ascii")}'})
433
+
434
+ allure.attach(
435
+ name=f'diff_for_{self.screenshot_name}',
436
+ body=json.dumps(diff_dict),
437
+ attachment_type='application/vnd.allure.image.diff'
438
+ )
439
+
440
+ def _disable_reruns(self) -> None:
441
+ """
442
+ Disable reruns for pytest
443
+
444
+ :return: None
445
+ """
446
+ try:
447
+ pytest_rerun = importlib.import_module('pytest_rerunfailures')
448
+ except ModuleNotFoundError:
449
+ return None
450
+
451
+ if hasattr(self.test_item, 'execution_count'):
452
+ self.test_item.execution_count = pytest_rerun.get_reruns_count(self.test_item) + 1
453
+
454
+ @staticmethod
455
+ def _remove_unexpected_underscores(text) -> str:
456
+ """
457
+ Remove multiple underscores from given text
458
+
459
+ :return: test_screenshot__data___name -> test_screenshot_data_name
460
+ """
461
+ return re.sub(r'_{2,}', '_', text)
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.1
2
+ Name: mops
3
+ Version: 0.0.1a2
4
+ Summary: Wrapper of Selenium, Appium and Playwright with single API
5
+ Author-email: Podolian Vladimir <vladimir.podolyan64@gmail.com>
6
+ License: MIT
7
+ Project-URL: Changelog, https://github.com/CustomEnv/mops/blob/master/CHANGELOG.md
8
+ Project-URL: Documentation, https://mops.readthedocs.io
9
+ Project-URL: Homepage, https://github.com/CustomEnv/mops
10
+ Project-URL: Source, https://github.com/CustomEnv/mops
11
+ Project-URL: Tracker, https://github.com/CustomEnv/mops/issues
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Classifier: Topic :: Software Development :: Testing :: Acceptance
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: Appium-Python-Client >=3.1.0
25
+ Requires-Dist: playwright >=1.41.0
26
+ Requires-Dist: selenium >=4.12.0
27
+ Requires-Dist: numpy >=2.0.1 ; python_version >= "3.11"
28
+ Requires-Dist: opencv-python >=4.10.0.84 ; python_version >= "3.11"
29
+ Requires-Dist: scikit-image >=0.24.0 ; python_version >= "3.11"
30
+ Requires-Dist: Pillow >=10.4.0 ; python_version >= "3.12"
31
+ Requires-Dist: numpy <2.0.0,>=1.24.2 ; python_version >= "3.8" and python_version <= "3.10"
32
+ Requires-Dist: opencv-python <4.10.0.84,>=4.5.5.64 ; python_version >= "3.8" and python_version <= "3.10"
33
+ Requires-Dist: scikit-image <0.24.0,>=0.20.0 ; python_version >= "3.8" and python_version <= "3.10"
34
+ Requires-Dist: Pillow <10.4.0,>=9.4.0 ; python_version >= "3.8" and python_version <= "3.11"
35
+
36
+ <p align="center">
37
+ <a href="https://mops.readthedocs.io"><img src="docs/source/_static/preview.png"></a>
38
+ </p>
39
+
40
+ <h2 align="center">Automation Beyond Limits</h2>
41
+
42
+ <p align="center">
43
+ <a href="https://github.com/CustomEnv/mops/blob/master/LICENSE"><img alt="GitHub License" src="https://img.shields.io/github/license/CustomEnv/mops?logo=github&color=%234F2684&labelColor=%232E353B"></a>
44
+ <a href="https://pypi.org/project/mops/"><img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/mops?logo=pypi&labelColor=%232E353B"></a>
45
+ <a href="https://pypi.org/project/mops/"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/mops?logo=pypi&labelColor=%232E353B"></a>
46
+ </p>
47
+
48
+ <p align="center">
49
+ <a href="https://mops.readthedocs.io"><img alt="Documentation Status" src="https://img.shields.io/readthedocs/mops?logo=readthedocs&labelColor=%232E353B&label=docs"></a>
50
+ <a href="https://github.com/CustomEnv/mops/actions/workflows/static_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/static_tests.yml?logo=github&label=Unit%20Tests&labelColor=%232E353B"></a>
51
+ <a href="https://github.com/CustomEnv/mops/actions/workflows/playwright_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/playwright_tests.yml?logo=github&label=Playwright%20Tests&labelColor=%232E353B"></a>
52
+ </p>
53
+
54
+ <p align="center">
55
+ <a href="https://github.com/CustomEnv/mops/actions/workflows/selenium_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/selenium_tests.yml?logo=github&label=Selenium%20Tests&labelColor=%232E353B"></a>
56
+ <a href="https://github.com/CustomEnv/mops/actions/workflows/selenium_safari_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/selenium_safari_tests.yml?logo=github&label=Selenium%20Safari%20Tests&labelColor=%232E353B"></a>
57
+ <a href="https://github.com/CustomEnv/mops/actions/workflows/appium_android_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/appium_android_tests.yml?logo=github&label=Android%20Tests&labelColor=%232E353B"></a>
58
+ <a href="https://github.com/CustomEnv/mops/actions/workflows/appium_ios_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/appium_ios_tests.yml?logo=github&label=iOS%20Tests&labelColor=%232E353B"></a>
59
+ </p>
60
+
61
+
62
+ Mops is a Python framework that seamlessly wraps over Selenium, Appium, and sync Playwright,
63
+ providing a unified interface for browser and mobile automation. With Mops, you can effortlessly switch
64
+ between these engines within the same test, allowing you to leverage the unique features of each framework without boundaries.
65
+
66
+ Whether you're running tests on web browsers, mobile devices, or a combination of both, Mops simplifies the
67
+ process, giving you the flexibility and power to automate complex testing scenarios with ease.
68
+
69
+ ## Key Features
70
+
71
+ - **Seamless Integration**: Mops integrates with Selenium, Appium, and Playwright, allowing you to use the best-suited engine for your specific testing needs.
72
+ - **Unified API**: A single, easy-to-use API that abstracts away the differences between Selenium, Appium, and Playwright, making your test scripts more readable and maintainable.
73
+ - **Engine Switching**: Switch between Selenium, Appium, and Playwright within the same test case, enabling cross-platform and cross-browser testing with minimal effort.
74
+ - **Visual Regression Testing**: Perform visual regression tests using the integrated visual regression tool, available across all supported frameworks. This ensures your UI remains consistent across different browsers and devices.
75
+ - **Advanced Features**: Leverage the advanced features of each framework, such as Playwright's mocks and Appium's real mobile devices support, all while using the same testing framework.
76
+ - **Extensibility**: Extend the framework with custom functionality tailored to your project's specific requirements.
77
+ - **Automatic Locator Type Definition**: The locator type will be automatically determined based on the provided locator string or `Locator` object.
78
+
79
+
80
+ ## Installation and usage
81
+ For information on installation and usage, please refer to our **[ReadTheDocs documentation](https://mops.readthedocs.io)**. Check it out for more details.
82
+
83
+
84
+ ## Contributing
85
+
86
+ Mops is an open-source project, and we welcome contributions from the community. If you'd like to contribute, please open an pull request from your fork
87
+
88
+ ## License
89
+
90
+ Mops is licensed under the Apache License. See the [LICENSE](https://github.com/CustomEnv/mops/blob/master/LICENSE) file for more details.
91
+
92
+ ## Support
93
+
94
+ If you encounter any issues or have questions, please feel free to reach out via our [GitHub Issues](https://github.com/CustomEnv/mops/issues) page.
95
+
96
+ Thank you for choosing Mops for your automation needs!
@@ -0,0 +1,10 @@
1
+ mops/__init__.py,sha256=O8PeeqHUMoZdz4VsQRUNib2bQAcvWGIWuzPTkKRbdww,50
2
+ mops/exceptions.py,sha256=sKNSi2oMZTAMCE5enmuHoLomF7FIJoyXzmKRfXZ8PnU,2841
3
+ mops/js_scripts.py,sha256=rJ-L91X_y_-A7kONazE5LM7uR-KuVrBZL6LhkjHOsao,6118
4
+ mops/keyboard_keys.py,sha256=0LT-O0bdpOh_Lcn64hWD45201G7x2n-CJ1bPiTuBi5g,1960
5
+ mops/shared_utils.py,sha256=a7BWbHqj7EFRuvMXhRlwUhxqNZQ3sGWtbHcDmeA8J5Q,2999
6
+ mops/visual_comparison.py,sha256=YwOYKAD_5nBy6b7mjLHwIXTYzCkzdd0BfuayQWDTREk,18703
7
+ mops-0.0.1a2.dist-info/METADATA,sha256=sVf0KnKHtlXxiDIwcTY_zZg2zmoMyFhvvknU1PM8fxw,6999
8
+ mops-0.0.1a2.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
9
+ mops-0.0.1a2.dist-info/top_level.txt,sha256=OJY8xq2xmWlDOn4RvZo6EvDQGV3d4dN4SyPVkcpM8A8,5
10
+ mops-0.0.1a2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mops