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 +2 -0
- mops/exceptions.py +133 -0
- mops/js_scripts.py +216 -0
- mops/keyboard_keys.py +92 -0
- mops/shared_utils.py +102 -0
- mops/visual_comparison.py +461 -0
- mops-0.0.1a2.dist-info/METADATA +96 -0
- mops-0.0.1a2.dist-info/RECORD +10 -0
- mops-0.0.1a2.dist-info/WHEEL +5 -0
- mops-0.0.1a2.dist-info/top_level.txt +1 -0
mops/__init__.py
ADDED
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 @@
|
|
|
1
|
+
mops
|