pyxecm 2.0.0__py3-none-any.whl → 2.0.1__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.
Potentially problematic release.
This version of pyxecm might be problematic. Click here for more details.
- pyxecm/__init__.py +2 -1
- pyxecm/avts.py +79 -33
- pyxecm/customizer/api/app.py +45 -796
- pyxecm/customizer/api/auth/__init__.py +1 -0
- pyxecm/customizer/api/{auth.py → auth/functions.py} +2 -64
- pyxecm/customizer/api/auth/router.py +78 -0
- pyxecm/customizer/api/common/__init__.py +1 -0
- pyxecm/customizer/api/common/functions.py +47 -0
- pyxecm/customizer/api/{metrics.py → common/metrics.py} +1 -1
- pyxecm/customizer/api/common/models.py +21 -0
- pyxecm/customizer/api/{payload_list.py → common/payload_list.py} +6 -1
- pyxecm/customizer/api/common/router.py +72 -0
- pyxecm/customizer/api/settings.py +25 -0
- pyxecm/customizer/api/terminal/__init__.py +1 -0
- pyxecm/customizer/api/terminal/router.py +87 -0
- pyxecm/customizer/api/v1_csai/__init__.py +1 -0
- pyxecm/customizer/api/v1_csai/router.py +87 -0
- pyxecm/customizer/api/v1_maintenance/__init__.py +1 -0
- pyxecm/customizer/api/v1_maintenance/functions.py +100 -0
- pyxecm/customizer/api/v1_maintenance/models.py +12 -0
- pyxecm/customizer/api/v1_maintenance/router.py +76 -0
- pyxecm/customizer/api/v1_otcs/__init__.py +1 -0
- pyxecm/customizer/api/v1_otcs/functions.py +61 -0
- pyxecm/customizer/api/v1_otcs/router.py +179 -0
- pyxecm/customizer/api/v1_payload/__init__.py +1 -0
- pyxecm/customizer/api/v1_payload/functions.py +179 -0
- pyxecm/customizer/api/v1_payload/models.py +51 -0
- pyxecm/customizer/api/v1_payload/router.py +499 -0
- pyxecm/customizer/browser_automation.py +568 -326
- pyxecm/customizer/customizer.py +204 -430
- pyxecm/customizer/guidewire.py +907 -43
- pyxecm/customizer/k8s.py +243 -56
- pyxecm/customizer/m365.py +104 -15
- pyxecm/customizer/payload.py +1943 -885
- pyxecm/customizer/pht.py +19 -2
- pyxecm/customizer/servicenow.py +22 -5
- pyxecm/customizer/settings.py +9 -6
- pyxecm/helper/xml.py +69 -0
- pyxecm/otac.py +1 -1
- pyxecm/otawp.py +2104 -1535
- pyxecm/otca.py +569 -0
- pyxecm/otcs.py +201 -37
- pyxecm/otds.py +35 -13
- {pyxecm-2.0.0.dist-info → pyxecm-2.0.1.dist-info}/METADATA +6 -29
- pyxecm-2.0.1.dist-info/RECORD +76 -0
- {pyxecm-2.0.0.dist-info → pyxecm-2.0.1.dist-info}/WHEEL +1 -1
- pyxecm-2.0.0.dist-info/RECORD +0 -54
- /pyxecm/customizer/api/{models.py → auth/models.py} +0 -0
- {pyxecm-2.0.0.dist-info → pyxecm-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {pyxecm-2.0.0.dist-info → pyxecm-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
"""browser_automation Module to automate configuration via a browser interface.
|
|
2
2
|
|
|
3
3
|
These are typically used as fallback options if no REST API or LLConfig can be used.
|
|
4
|
+
|
|
5
|
+
This module uses playwright: https://playwright.dev for broweser-based automation
|
|
6
|
+
and testing.
|
|
7
|
+
|
|
8
|
+
Here are few few examples of the most typical page matches with the different selector types:
|
|
9
|
+
|
|
10
|
+
| **Element to Match** | **CSS** | **XPath** | **Playwright `get_by_*` Method** |
|
|
11
|
+
| ------------------------ | ------------------------------ | ------------------------------------------------- | ----------------------------------------- |
|
|
12
|
+
| Element with ID | `#myId` | `//*[@id='myId']` | *Not available directly; use `locator()`* |
|
|
13
|
+
| Element with class | `.myClass` | `//*[@class='myClass']` | *Not available directly; use `locator()`* |
|
|
14
|
+
| Button with exact text | `button:has-text("Submit")` | `//button[text()='Submit']` | `get_by_role("button", name="Submit")` |
|
|
15
|
+
| Button with partial text | `button:has-text("Sub")` | `//button[contains(text(), 'Sub')]` | `get_by_text("Sub")` |
|
|
16
|
+
| Input with name | `input[name="email"]` | `//input[@name='email']` | *Not available directly; use `locator()`* |
|
|
17
|
+
| Link by text | `a:has-text("Home")` | `//a[text()='Home']` | `get_by_role("link", name="Home")` |
|
|
18
|
+
| Element with title | `[title="Info"]` | `//*[@title='Info']` | `get_by_title("Info")` |
|
|
19
|
+
| Placeholder text | `input[placeholder="Search"]` | `//input[@placeholder='Search']` | `get_by_placeholder("Search")` |
|
|
20
|
+
| Label text (form input) | `label:has-text("Email")` | `//label[text()='Email']` | `get_by_label("Email")` |
|
|
21
|
+
| Alt text (image) | `img[alt="Logo"]` | `//img[@alt='Logo']` | `get_by_alt_text("Logo")` |
|
|
22
|
+
| Role and name (ARIA) | `[role="button"][name="Save"]` | `//*[@role='button' and @name='Save']` | `get_by_role("button", name="Save")` |
|
|
23
|
+
| Visible text anywhere | `:text("Welcome")` | `//*[contains(text(), "Welcome")]` | `get_by_text("Welcome")` |
|
|
24
|
+
| nth element in a list | `ul > li:nth-child(2)` | `(//ul/li)[2]` | `locator("ul > li").nth(1)` |
|
|
25
|
+
| Element with attribute | `[data-test-id="main"]` | `//*[@data-test-id='main']` | *Not available directly; use `locator()`* |
|
|
26
|
+
| Nested element | `.container .button` | `//div[@class='container']//div[@class='button']` | `locator(".container .button")` |
|
|
27
|
+
|
|
4
28
|
"""
|
|
5
29
|
|
|
6
30
|
__author__ = "Dr. Marc Diefenbruch"
|
|
@@ -9,57 +33,38 @@ __credits__ = ["Kai-Philip Gatzweiler"]
|
|
|
9
33
|
__maintainer__ = "Dr. Marc Diefenbruch"
|
|
10
34
|
__email__ = "mdiefenb@opentext.com"
|
|
11
35
|
|
|
12
|
-
|
|
13
36
|
import logging
|
|
14
37
|
import os
|
|
15
38
|
import tempfile
|
|
16
39
|
import time
|
|
17
|
-
|
|
18
|
-
import urllib3
|
|
40
|
+
from http import HTTPStatus
|
|
19
41
|
|
|
20
42
|
default_logger = logging.getLogger("pyxecm.customizer.browser_automation")
|
|
21
43
|
|
|
22
44
|
# For backwards compatibility we also want to handle
|
|
23
|
-
# cases where the
|
|
24
|
-
#
|
|
45
|
+
# cases where the playwright modules have not been installed
|
|
46
|
+
# in the customizer container:
|
|
25
47
|
try:
|
|
26
|
-
from
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
NoSuchElementException,
|
|
33
|
-
StaleElementReferenceException,
|
|
34
|
-
TimeoutException,
|
|
35
|
-
WebDriverException,
|
|
48
|
+
from playwright.sync_api import (
|
|
49
|
+
Browser,
|
|
50
|
+
BrowserContext,
|
|
51
|
+
ElementHandle,
|
|
52
|
+
Page,
|
|
53
|
+
sync_playwright,
|
|
36
54
|
)
|
|
37
|
-
from
|
|
38
|
-
from selenium.webdriver.common.action_chains import ActionChains
|
|
39
|
-
from selenium.webdriver.common.by import By
|
|
40
|
-
from selenium.webdriver.remote.webelement import WebElement
|
|
41
|
-
from selenium.webdriver.support.ui import Select
|
|
42
|
-
|
|
55
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
43
56
|
except ModuleNotFoundError:
|
|
44
|
-
default_logger.warning("Module
|
|
45
|
-
|
|
46
|
-
class Options:
|
|
47
|
-
"""Dummy class to avoid errors if selenium module cannot be imported."""
|
|
48
|
-
|
|
49
|
-
class By:
|
|
50
|
-
"""Dummy class to avoid errors if selenium module cannot be imported."""
|
|
51
|
-
|
|
52
|
-
ID: str = ""
|
|
53
|
-
|
|
54
|
-
class WebElement:
|
|
55
|
-
"""Dummy class to avoid errors if selenium module cannot be imported."""
|
|
56
|
-
|
|
57
|
+
default_logger.warning("Module playwright is not installed")
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
# We use "networkidle" as default "wait until" strategy as
|
|
60
|
+
# this seems to best harmonize with OTCS. Especially login
|
|
61
|
+
# procedure for OTDS / OTCS seems to not work with the "load"
|
|
62
|
+
# "wait until" strategy.
|
|
63
|
+
DEFAULT_WAIT_UNTIL_STRATEGY = "networkidle"
|
|
62
64
|
|
|
65
|
+
REQUEST_TIMEOUT = 30
|
|
66
|
+
REQUEST_RETRY_DELAY = 2
|
|
67
|
+
REQUEST_MAX_RETRIES = 3
|
|
63
68
|
|
|
64
69
|
class BrowserAutomation:
|
|
65
70
|
"""Class to automate settings via a browser interface."""
|
|
@@ -74,14 +79,16 @@ class BrowserAutomation:
|
|
|
74
79
|
download_directory: str | None = None,
|
|
75
80
|
take_screenshots: bool = False,
|
|
76
81
|
automation_name: str = "",
|
|
82
|
+
headless: bool = True,
|
|
77
83
|
logger: logging.Logger = default_logger,
|
|
84
|
+
wait_until: str | None = None,
|
|
78
85
|
) -> None:
|
|
79
86
|
"""Initialize the object.
|
|
80
87
|
|
|
81
88
|
Args:
|
|
82
89
|
base_url (str, optional):
|
|
83
90
|
The base URL of the website to automate. Defaults to "".
|
|
84
|
-
user_name (str, optional):
|
|
91
|
+
user_name (str, optional):
|
|
85
92
|
If an authentication at the web site is required, this is the user name.
|
|
86
93
|
Defaults to "".
|
|
87
94
|
user_password (str, optional):
|
|
@@ -94,7 +101,15 @@ class BrowserAutomation:
|
|
|
94
101
|
For debugging purposes, screenshots can be taken.
|
|
95
102
|
Defaults to False.
|
|
96
103
|
automation_name (str, optional):
|
|
97
|
-
The name of the automation. Defaults to "
|
|
104
|
+
The name of the automation. Defaults to "".
|
|
105
|
+
headless (bool, optional):
|
|
106
|
+
If True, the browser will be started in headless mode. Defaults to True.
|
|
107
|
+
wait_until (str | None, optional):
|
|
108
|
+
Wait until a certain condition. Options are:
|
|
109
|
+
* "load" - Waits for the load event (after all resources like images/scripts load)
|
|
110
|
+
* "networkidle" - Waits until there are no network connections for at least 500 ms.
|
|
111
|
+
* "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
|
|
112
|
+
but subresources may still load).
|
|
98
113
|
logger (logging.Logger, optional):
|
|
99
114
|
The logging object to use for all log messages. Defaults to default_logger.
|
|
100
115
|
|
|
@@ -121,7 +136,8 @@ class BrowserAutomation:
|
|
|
121
136
|
|
|
122
137
|
self.take_screenshots = take_screenshots
|
|
123
138
|
self.screenshot_names = automation_name
|
|
124
|
-
self.
|
|
139
|
+
self.screenshot_counter = 1
|
|
140
|
+
self.wait_until = wait_until if wait_until else DEFAULT_WAIT_UNTIL_STRATEGY
|
|
125
141
|
|
|
126
142
|
self.screenshot_directory = os.path.join(
|
|
127
143
|
tempfile.gettempdir(),
|
|
@@ -129,50 +145,15 @@ class BrowserAutomation:
|
|
|
129
145
|
automation_name,
|
|
130
146
|
"screenshots",
|
|
131
147
|
)
|
|
132
|
-
|
|
133
148
|
if self.take_screenshots and not os.path.exists(self.screenshot_directory):
|
|
134
149
|
os.makedirs(self.screenshot_directory)
|
|
135
|
-
chromedriver_autoinstaller.install()
|
|
136
|
-
self.browser = webdriver.Chrome(options=self.set_chrome_options())
|
|
137
|
-
|
|
138
|
-
# end method definition
|
|
139
|
-
|
|
140
|
-
def __del__(self) -> None:
|
|
141
|
-
"""Object destructor."""
|
|
142
|
-
|
|
143
|
-
try:
|
|
144
|
-
if self.browser:
|
|
145
|
-
self.browser.quit()
|
|
146
|
-
self.browser = None
|
|
147
|
-
except (WebDriverException, AttributeError, TypeError, OSError):
|
|
148
|
-
# Log or silently handle exceptions during interpreter shutdown
|
|
149
|
-
pass
|
|
150
|
-
|
|
151
|
-
# end method definition
|
|
152
150
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
Returns:
|
|
159
|
-
Options: Options to call the browser with
|
|
160
|
-
|
|
161
|
-
"""
|
|
162
|
-
|
|
163
|
-
chrome_options = Options()
|
|
164
|
-
chrome_options.add_argument("--headless")
|
|
165
|
-
chrome_options.add_argument("--no-sandbox")
|
|
166
|
-
chrome_options.add_argument("--disable-dev-shm-usage")
|
|
167
|
-
chrome_prefs = {}
|
|
168
|
-
chrome_options.experimental_options["prefs"] = chrome_prefs
|
|
169
|
-
|
|
170
|
-
chrome_options.add_experimental_option(
|
|
171
|
-
"prefs",
|
|
172
|
-
{"download.default_directory": self.download_directory},
|
|
151
|
+
self.playwright = sync_playwright().start()
|
|
152
|
+
self.browser: Browser = self.playwright.chromium.launch(headless=headless)
|
|
153
|
+
self.context: BrowserContext = self.browser.new_context(
|
|
154
|
+
accept_downloads=True,
|
|
173
155
|
)
|
|
174
|
-
|
|
175
|
-
return chrome_options
|
|
156
|
+
self.page: Page = self.context.new_page()
|
|
176
157
|
|
|
177
158
|
# end method definition
|
|
178
159
|
|
|
@@ -188,75 +169,127 @@ class BrowserAutomation:
|
|
|
188
169
|
screenshot_file = "{}/{}-{}.png".format(
|
|
189
170
|
self.screenshot_directory,
|
|
190
171
|
self.screenshot_names,
|
|
191
|
-
self.
|
|
172
|
+
self.screenshot_counter,
|
|
192
173
|
)
|
|
193
174
|
self.logger.debug("Save browser screenshot to -> %s", screenshot_file)
|
|
194
|
-
result = self.browser.get_screenshot_as_file(screenshot_file)
|
|
195
|
-
self.screen_counter += 1
|
|
196
175
|
|
|
197
|
-
|
|
176
|
+
try:
|
|
177
|
+
self.page.screenshot(path=screenshot_file)
|
|
178
|
+
self.screenshot_counter += 1
|
|
179
|
+
except Exception as e:
|
|
180
|
+
self.logger.error("Failed to take screenshot; error -> %s", e)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
return True
|
|
198
184
|
|
|
199
|
-
|
|
185
|
+
# end method definition
|
|
186
|
+
|
|
187
|
+
def get_page(self, url: str = "", wait_until: str | None = None) -> bool:
|
|
200
188
|
"""Load a page into the browser based on a given URL.
|
|
201
189
|
|
|
202
190
|
Args:
|
|
203
191
|
url (str):
|
|
204
|
-
URL to load. If empty just the base URL will be used
|
|
192
|
+
URL to load. If empty just the base URL will be used.
|
|
193
|
+
wait_until (str | None, optional):
|
|
194
|
+
Wait until a certain condition. Options are:
|
|
195
|
+
* "load" - Waits for the load event (after all resources like images/scripts load)
|
|
196
|
+
This is the safest strategy for pages that keep loading content in the background
|
|
197
|
+
like Salesforce.
|
|
198
|
+
* "networkidle" - Waits until there are no network connections for at least 500 ms.
|
|
199
|
+
This seems to be the safest one for OpenText Content Server.
|
|
200
|
+
* "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
|
|
201
|
+
but subresources may still load).
|
|
202
|
+
|
|
205
203
|
Returns:
|
|
206
204
|
bool:
|
|
207
|
-
True if successful, False otherwise
|
|
205
|
+
True if successful, False otherwise.
|
|
208
206
|
|
|
209
207
|
"""
|
|
210
208
|
|
|
209
|
+
# If no specific wait until strategy is provided in the
|
|
210
|
+
# parameter, we take the one from the browser automation class:
|
|
211
|
+
if wait_until is None:
|
|
212
|
+
wait_until = self.wait_until
|
|
213
|
+
|
|
211
214
|
page_url = self.base_url + url
|
|
212
215
|
|
|
213
216
|
try:
|
|
214
217
|
self.logger.debug("Load page -> %s", page_url)
|
|
215
|
-
self.browser.get(page_url)
|
|
216
218
|
|
|
217
|
-
|
|
218
|
-
self.
|
|
219
|
-
|
|
220
|
-
page_url
|
|
221
|
-
|
|
222
|
-
|
|
219
|
+
# The Playwright Response object is different from the requests.response object!
|
|
220
|
+
response = self.page.goto(page_url, wait_until=wait_until)
|
|
221
|
+
if response is None:
|
|
222
|
+
self.logger.warning("Loading of page -> %s completed but no response object was returned.", page_url)
|
|
223
|
+
elif not response.ok:
|
|
224
|
+
# Try to get standard phrase, fall back if unknown
|
|
225
|
+
try:
|
|
226
|
+
phrase = HTTPStatus(response.status).phrase
|
|
227
|
+
except ValueError:
|
|
228
|
+
phrase = "Unknown Status"
|
|
229
|
+
self.logger.error(
|
|
230
|
+
"Response for page -> %s is not OK. Status -> %s/%s",
|
|
231
|
+
page_url,
|
|
232
|
+
response.status,
|
|
233
|
+
phrase,
|
|
234
|
+
)
|
|
235
|
+
return False
|
|
223
236
|
|
|
224
|
-
|
|
237
|
+
except PlaywrightError as e:
|
|
238
|
+
self.logger.error("Navigation to page -> %s has failed; error -> %s", page_url, str(e))
|
|
239
|
+
return False
|
|
225
240
|
|
|
226
241
|
if self.take_screenshots:
|
|
227
242
|
self.take_screenshot()
|
|
228
243
|
|
|
229
|
-
# Wait a second before proceeding
|
|
230
|
-
time.sleep(1)
|
|
231
|
-
|
|
232
244
|
return True
|
|
233
245
|
|
|
234
246
|
# end method definition
|
|
235
247
|
|
|
236
|
-
def get_title(
|
|
248
|
+
def get_title(
|
|
249
|
+
self,
|
|
250
|
+
wait_until: str | None = None,
|
|
251
|
+
) -> str | None:
|
|
237
252
|
"""Get the browser title.
|
|
238
253
|
|
|
239
254
|
This is handy to validate a certain page is loaded after get_page()
|
|
240
255
|
|
|
256
|
+
Retry-safe way to get the page title, even if there's an in-flight navigation.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
wait_until (str | None, optional):
|
|
260
|
+
Wait until a certain condition. Options are:
|
|
261
|
+
* "load" - Waits for the load event (after all resources like images/scripts load)
|
|
262
|
+
This is the safest strategy for pages that keep loading content in the background
|
|
263
|
+
like Salesforce.
|
|
264
|
+
* "networkidle" - Waits until there are no network connections for at least 500 ms.
|
|
265
|
+
This seems to be the safest one for OpenText Content Server.
|
|
266
|
+
* "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
|
|
267
|
+
but subresources may still load).
|
|
268
|
+
|
|
241
269
|
Returns:
|
|
242
270
|
str:
|
|
243
271
|
The title of the browser window.
|
|
244
272
|
|
|
245
273
|
"""
|
|
246
274
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
275
|
+
for _ in range(REQUEST_MAX_RETRIES):
|
|
276
|
+
try:
|
|
277
|
+
return self.page.title()
|
|
278
|
+
except Exception as e:
|
|
279
|
+
if "Execution context was destroyed" in str(e):
|
|
280
|
+
time.sleep(REQUEST_RETRY_DELAY)
|
|
281
|
+
self.page.wait_for_load_state(state=wait_until, timeout=REQUEST_TIMEOUT)
|
|
282
|
+
else:
|
|
283
|
+
self.logger.error("Could not get page title; error -> %s", e)
|
|
252
284
|
|
|
285
|
+
return None
|
|
253
286
|
# end method definition
|
|
254
287
|
|
|
255
|
-
def scroll_to_element(self, element:
|
|
288
|
+
def scroll_to_element(self, element: ElementHandle) -> None:
|
|
256
289
|
"""Scroll an element into view to make it clickable.
|
|
257
290
|
|
|
258
291
|
Args:
|
|
259
|
-
element (
|
|
292
|
+
element (ElementHandle):
|
|
260
293
|
Web element that has been identified before.
|
|
261
294
|
|
|
262
295
|
"""
|
|
@@ -266,92 +299,93 @@ class BrowserAutomation:
|
|
|
266
299
|
return
|
|
267
300
|
|
|
268
301
|
try:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
self.logger.error("Element not found in the DOM!")
|
|
273
|
-
except TimeoutException:
|
|
274
|
-
self.logger.error(
|
|
275
|
-
"Timed out waiting for the element to be present or visible!",
|
|
276
|
-
)
|
|
277
|
-
except ElementNotInteractableException:
|
|
278
|
-
self.logger.error("Element is not interactable!")
|
|
279
|
-
except MoveTargetOutOfBoundsException:
|
|
280
|
-
self.logger.error("Element is out of bounds!")
|
|
281
|
-
except WebDriverException:
|
|
282
|
-
self.logger.error("WebDriverException occurred!")
|
|
302
|
+
element.scroll_into_view_if_needed()
|
|
303
|
+
except PlaywrightError as e:
|
|
304
|
+
self.logger.error("Error while scrolling element into view -> %s", str(e))
|
|
283
305
|
|
|
284
306
|
# end method definition
|
|
285
307
|
|
|
286
308
|
def find_elem(
|
|
287
309
|
self,
|
|
288
|
-
|
|
289
|
-
|
|
310
|
+
selector: str,
|
|
311
|
+
selector_type: str = "id",
|
|
312
|
+
role_type: str | None = None,
|
|
290
313
|
show_error: bool = True,
|
|
291
|
-
) ->
|
|
292
|
-
"""Find
|
|
314
|
+
) -> ElementHandle | None:
|
|
315
|
+
"""Find a page element.
|
|
293
316
|
|
|
294
317
|
Args:
|
|
295
|
-
|
|
296
|
-
The name of the page element.
|
|
297
|
-
|
|
298
|
-
|
|
318
|
+
selector (str):
|
|
319
|
+
The name of the page element or accessible name (for role).
|
|
320
|
+
selector_type (str, optional):
|
|
321
|
+
One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
|
|
322
|
+
"label", "placeholder", "alt".
|
|
323
|
+
role_type (str | None, optional):
|
|
324
|
+
ARIA role when using selector_type="role", e.g., "button", "textbox".
|
|
325
|
+
If irrelevant then None should be passed for role_type.
|
|
299
326
|
show_error (bool, optional):
|
|
300
|
-
Show an error if
|
|
327
|
+
Show an error if not found or not visible.
|
|
301
328
|
|
|
302
329
|
Returns:
|
|
303
|
-
|
|
330
|
+
ElementHandle:
|
|
304
331
|
The web element or None in case an error occured.
|
|
305
332
|
|
|
306
333
|
"""
|
|
307
334
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
find_method = By.CLASS_NAME
|
|
316
|
-
elif find_method == "xpath":
|
|
317
|
-
find_method = By.XPATH
|
|
318
|
-
else:
|
|
319
|
-
self.logger.error("Unsupported find method!")
|
|
320
|
-
return None
|
|
335
|
+
locator = None
|
|
336
|
+
failure_message = "Cannot find page element with selector -> '{}' ({}){}".format(
|
|
337
|
+
selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
|
|
338
|
+
)
|
|
339
|
+
success_message = "Found page element with selector -> '{}' ('{}'){}".format(
|
|
340
|
+
selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
|
|
341
|
+
)
|
|
321
342
|
|
|
322
343
|
try:
|
|
323
|
-
|
|
324
|
-
|
|
344
|
+
match selector_type:
|
|
345
|
+
case "id":
|
|
346
|
+
locator = self.page.locator("#{}".format(selector))
|
|
347
|
+
case "name":
|
|
348
|
+
locator = self.page.locator("[name='{}']".format(selector))
|
|
349
|
+
case "class_name":
|
|
350
|
+
locator = self.page.locator(".{}".format(selector))
|
|
351
|
+
case "xpath":
|
|
352
|
+
locator = self.page.locator("xpath={}".format(selector))
|
|
353
|
+
case "css":
|
|
354
|
+
locator = self.page.locator(selector)
|
|
355
|
+
case "text":
|
|
356
|
+
locator = self.page.get_by_text(selector)
|
|
357
|
+
case "title":
|
|
358
|
+
locator = self.page.get_by_title(selector)
|
|
359
|
+
case "label":
|
|
360
|
+
locator = self.page.get_by_label(selector)
|
|
361
|
+
case "placeholder":
|
|
362
|
+
locator = self.page.get_by_placeholder(selector)
|
|
363
|
+
case "alt":
|
|
364
|
+
locator = self.page.get_by_alt_text(selector)
|
|
365
|
+
case "role":
|
|
366
|
+
if not role_type:
|
|
367
|
+
self.logger.error("Role type must be specified when using find method 'role'!")
|
|
368
|
+
return None
|
|
369
|
+
locator = self.page.get_by_role(role=role_type, name=selector)
|
|
370
|
+
case _:
|
|
371
|
+
self.logger.error("Unsupported selector type -> '%s'", selector_type)
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
elem = locator.element_handle() if locator is not None else None
|
|
375
|
+
if elem is None:
|
|
376
|
+
if show_error:
|
|
377
|
+
self.logger.error(failure_message)
|
|
378
|
+
else:
|
|
379
|
+
self.logger.warning(failure_message)
|
|
380
|
+
else:
|
|
381
|
+
self.logger.debug(success_message)
|
|
382
|
+
|
|
383
|
+
except PlaywrightError as e:
|
|
325
384
|
if show_error:
|
|
326
|
-
self.logger.error(
|
|
327
|
-
"Cannot find page element -> %s by -> %s",
|
|
328
|
-
find_elem,
|
|
329
|
-
find_method,
|
|
330
|
-
)
|
|
331
|
-
return None
|
|
385
|
+
self.logger.error("%s; error -> %s", failure_message, str(e))
|
|
332
386
|
else:
|
|
333
|
-
self.logger.warning(
|
|
334
|
-
"Cannot find page element -> %s by -> %s",
|
|
335
|
-
find_elem,
|
|
336
|
-
find_method,
|
|
337
|
-
)
|
|
338
|
-
return None
|
|
339
|
-
except TimeoutException:
|
|
340
|
-
self.logger.error(
|
|
341
|
-
"Timed out waiting for the element to be present or visible!",
|
|
342
|
-
)
|
|
343
|
-
return None
|
|
344
|
-
except ElementNotInteractableException:
|
|
345
|
-
self.logger.error("Element is not interactable!")
|
|
346
|
-
return None
|
|
347
|
-
except MoveTargetOutOfBoundsException:
|
|
348
|
-
self.logger.error("Element is out of bounds!")
|
|
387
|
+
self.logger.warning("%s; error -> %s", failure_message, str(e))
|
|
349
388
|
return None
|
|
350
|
-
except WebDriverException:
|
|
351
|
-
self.logger.error("WebDriverException occurred!")
|
|
352
|
-
return None
|
|
353
|
-
|
|
354
|
-
self.logger.debug("Found page element -> %s by -> %s", find_elem, find_method)
|
|
355
389
|
|
|
356
390
|
return elem
|
|
357
391
|
|
|
@@ -359,24 +393,42 @@ class BrowserAutomation:
|
|
|
359
393
|
|
|
360
394
|
def find_elem_and_click(
|
|
361
395
|
self,
|
|
362
|
-
|
|
363
|
-
|
|
396
|
+
selector: str,
|
|
397
|
+
selector_type: str = "id",
|
|
398
|
+
role_type: str | None = None,
|
|
364
399
|
scroll_to_element: bool = True,
|
|
365
400
|
desired_checkbox_state: bool | None = None,
|
|
401
|
+
is_navigation_trigger: bool = False,
|
|
402
|
+
wait_until: str | None = None,
|
|
366
403
|
show_error: bool = True,
|
|
367
404
|
) -> bool:
|
|
368
|
-
"""Find
|
|
405
|
+
"""Find a page element and click it.
|
|
369
406
|
|
|
370
407
|
Args:
|
|
371
|
-
|
|
372
|
-
The
|
|
373
|
-
|
|
374
|
-
|
|
408
|
+
selector (str):
|
|
409
|
+
The selector of the page element.
|
|
410
|
+
selector_type (str, optional):
|
|
411
|
+
One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
|
|
412
|
+
"label", "placeholder", "alt".
|
|
413
|
+
role_type (str | None, optional):
|
|
414
|
+
ARIA role when using selector_type="role", e.g., "button", "textbox".
|
|
415
|
+
If irrelevant then None should be passed for role_type.
|
|
375
416
|
scroll_to_element (bool, optional):
|
|
376
417
|
Scroll the element into view.
|
|
377
418
|
desired_checkbox_state (bool | None, optional):
|
|
378
419
|
If True/False, ensures checkbox matches state.
|
|
379
420
|
If None then click it in any case.
|
|
421
|
+
is_navigation_trigger (bool, optional):
|
|
422
|
+
Is the click causing a navigation. Default is False.
|
|
423
|
+
wait_until (str | None, optional):
|
|
424
|
+
Wait until a certain condition. Options are:
|
|
425
|
+
* "load" - Waits for the load event (after all resources like images/scripts load)
|
|
426
|
+
This is the safest strategy for pages that keep loading content in the background
|
|
427
|
+
like Salesforce.
|
|
428
|
+
* "networkidle" - Waits until there are no network connections for at least 500 ms.
|
|
429
|
+
This seems to be the safest one for OpenText Content Server.
|
|
430
|
+
* "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
|
|
431
|
+
but subresources may still load).
|
|
380
432
|
show_error (bool, optional):
|
|
381
433
|
Show an error if the element is not found or not clickable.
|
|
382
434
|
|
|
@@ -387,99 +439,95 @@ class BrowserAutomation:
|
|
|
387
439
|
|
|
388
440
|
"""
|
|
389
441
|
|
|
390
|
-
|
|
442
|
+
# If no specific wait until strategy is provided in the
|
|
443
|
+
# parameter, we take the one from the browser automation class:
|
|
444
|
+
if wait_until is None:
|
|
445
|
+
wait_until = self.wait_until
|
|
446
|
+
|
|
447
|
+
if not selector:
|
|
448
|
+
failure_message = "Missing element selector! Cannot find page element!"
|
|
391
449
|
if show_error:
|
|
392
|
-
self.logger.error(
|
|
450
|
+
self.logger.error(failure_message)
|
|
393
451
|
else:
|
|
394
|
-
self.logger.warning(
|
|
452
|
+
self.logger.warning(failure_message)
|
|
395
453
|
return False
|
|
396
454
|
|
|
397
455
|
elem = self.find_elem(
|
|
398
|
-
|
|
399
|
-
find_method=find_method,
|
|
400
|
-
show_error=show_error,
|
|
456
|
+
selector=selector, selector_type=selector_type, role_type=role_type, show_error=show_error
|
|
401
457
|
)
|
|
402
|
-
|
|
403
458
|
if not elem:
|
|
404
459
|
return not show_error
|
|
405
460
|
|
|
406
|
-
is_checkbox = elem.get_attribute("type") == "checkbox"
|
|
407
|
-
checkbox_state = None
|
|
408
|
-
|
|
409
461
|
try:
|
|
410
462
|
if scroll_to_element:
|
|
411
463
|
self.scroll_to_element(elem)
|
|
412
464
|
|
|
413
465
|
# Handle checkboxes
|
|
466
|
+
is_checkbox = elem.get_attribute("type") == "checkbox"
|
|
467
|
+
checkbox_state = None
|
|
468
|
+
|
|
414
469
|
if is_checkbox and desired_checkbox_state is not None:
|
|
415
|
-
checkbox_state = elem.
|
|
470
|
+
checkbox_state = elem.is_checked()
|
|
416
471
|
if checkbox_state == desired_checkbox_state:
|
|
417
472
|
self.logger.debug(
|
|
418
|
-
"Checkbox -> '%s' already in desired state -> %s",
|
|
419
|
-
find_elem,
|
|
420
|
-
desired_checkbox_state,
|
|
473
|
+
"Checkbox -> '%s' is already in desired state -> %s", selector, desired_checkbox_state
|
|
421
474
|
)
|
|
422
475
|
return True # No need to click
|
|
423
476
|
else:
|
|
424
|
-
self.logger.debug("Checkbox -> '%s' state mismatch. Clicking to change state.",
|
|
477
|
+
self.logger.debug("Checkbox -> '%s' has state mismatch. Clicking to change state.", selector)
|
|
425
478
|
|
|
426
|
-
|
|
427
|
-
|
|
479
|
+
if is_navigation_trigger:
|
|
480
|
+
self.logger.info("Clicking on navigation-triggering element -> '%s'", selector)
|
|
481
|
+
try:
|
|
482
|
+
with self.page.expect_navigation(wait_until=wait_until):
|
|
483
|
+
elem.click()
|
|
484
|
+
except PlaywrightError as e:
|
|
485
|
+
self.logger.error(
|
|
486
|
+
"Navigation after clicking on element -> '%s' did not happen or failed; likely wrong parameter passed; error -> %s",
|
|
487
|
+
selector,
|
|
488
|
+
str(e),
|
|
489
|
+
)
|
|
490
|
+
return False
|
|
491
|
+
else:
|
|
492
|
+
self.logger.info("Clicking on non-navigating element -> '%s'", selector)
|
|
493
|
+
try:
|
|
494
|
+
elem.click()
|
|
495
|
+
time.sleep(1)
|
|
496
|
+
except PlaywrightError as e:
|
|
497
|
+
self.logger.error("Click failed -> %s", str(e))
|
|
498
|
+
return False
|
|
428
499
|
|
|
429
|
-
# Handle checkboxes
|
|
430
500
|
if is_checkbox and desired_checkbox_state is not None:
|
|
431
|
-
|
|
432
|
-
elem = self.find_elem(
|
|
433
|
-
find_elem=find_elem,
|
|
434
|
-
find_method=find_method,
|
|
435
|
-
show_error=show_error,
|
|
436
|
-
)
|
|
437
|
-
# Is the element still there?
|
|
501
|
+
elem = self.find_elem(selector=selector, selector_type=selector_type, show_error=show_error)
|
|
438
502
|
if elem:
|
|
439
|
-
checkbox_state = elem.
|
|
503
|
+
checkbox_state = elem.is_checked()
|
|
440
504
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
505
|
+
if checkbox_state is not None:
|
|
506
|
+
if checkbox_state == desired_checkbox_state:
|
|
507
|
+
self.logger.debug(
|
|
508
|
+
"Successfully clicked checkbox element -> '%s'. It's state is now -> %s",
|
|
509
|
+
selector,
|
|
510
|
+
checkbox_state,
|
|
511
|
+
)
|
|
512
|
+
else:
|
|
513
|
+
self.logger.error(
|
|
514
|
+
"Failed to flip checkbox element -> '%s' to desired state. It's state is still -> %s and not -> %s",
|
|
515
|
+
selector,
|
|
516
|
+
checkbox_state,
|
|
517
|
+
desired_checkbox_state,
|
|
518
|
+
)
|
|
453
519
|
else:
|
|
454
|
-
self.logger.
|
|
455
|
-
return True
|
|
456
|
-
except TimeoutException:
|
|
457
|
-
if show_error:
|
|
458
|
-
self.logger.error("Timeout waiting for element -> %s to be clickable!", find_elem)
|
|
459
|
-
return not show_error
|
|
520
|
+
self.logger.debug("Successfully clicked element -> '%s'", selector)
|
|
460
521
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
self.logger.debug(
|
|
464
|
-
"Successfully clicked checkbox element -> %s. It's state is now -> %s",
|
|
465
|
-
find_elem,
|
|
466
|
-
checkbox_state,
|
|
467
|
-
)
|
|
468
|
-
else:
|
|
469
|
-
self.logger.error(
|
|
470
|
-
"Failed to flip checkbox element -> %s to desired state. It's state is still -> %s and not -> %s",
|
|
471
|
-
find_elem,
|
|
472
|
-
checkbox_state,
|
|
473
|
-
desired_checkbox_state,
|
|
474
|
-
)
|
|
475
|
-
else:
|
|
476
|
-
self.logger.debug(
|
|
477
|
-
"Successfully clicked element -> %s",
|
|
478
|
-
find_elem,
|
|
479
|
-
)
|
|
522
|
+
if self.take_screenshots:
|
|
523
|
+
self.take_screenshot()
|
|
480
524
|
|
|
481
|
-
|
|
482
|
-
|
|
525
|
+
except PlaywrightError as e:
|
|
526
|
+
if show_error:
|
|
527
|
+
self.logger.error("Cannot click page element -> '%s'; error -> %s", selector, str(e))
|
|
528
|
+
else:
|
|
529
|
+
self.logger.warning("Cannot click page element -> '%s'; warning -> %s", selector, str(e))
|
|
530
|
+
return not show_error
|
|
483
531
|
|
|
484
532
|
return True
|
|
485
533
|
|
|
@@ -487,63 +535,94 @@ class BrowserAutomation:
|
|
|
487
535
|
|
|
488
536
|
def find_elem_and_set(
|
|
489
537
|
self,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
538
|
+
selector: str,
|
|
539
|
+
value: str | bool,
|
|
540
|
+
selector_type: str = "id",
|
|
541
|
+
role_type: str | None = None,
|
|
493
542
|
is_sensitive: bool = False,
|
|
543
|
+
show_error: bool = True,
|
|
494
544
|
) -> bool:
|
|
495
545
|
"""Find an page element and fill it with a new text.
|
|
496
546
|
|
|
497
547
|
Args:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
548
|
+
selector (str):
|
|
549
|
+
The name of the page element.
|
|
550
|
+
value (str | bool):
|
|
551
|
+
The new value (text string) for the page element.
|
|
552
|
+
selector_type (str, optional):
|
|
553
|
+
One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
|
|
554
|
+
"label", "placeholder", "alt".
|
|
555
|
+
role_type (str | None, optional):
|
|
556
|
+
ARIA role when using selector_type="role", e.g., "button", "textbox".
|
|
557
|
+
If irrelevant then None should be passed for role_type.
|
|
558
|
+
is_sensitive (bool, optional):
|
|
559
|
+
True for suppressing sensitive information in logging.
|
|
560
|
+
show_error (bool, optional):
|
|
561
|
+
Show an error if the element is not found or not clickable.
|
|
562
|
+
|
|
502
563
|
Returns:
|
|
503
|
-
bool:
|
|
564
|
+
bool:
|
|
565
|
+
True if successful, False otherwise
|
|
504
566
|
|
|
505
567
|
"""
|
|
506
568
|
|
|
507
|
-
elem = self.find_elem(
|
|
508
|
-
find_elem=find_elem,
|
|
509
|
-
find_method=find_method,
|
|
510
|
-
show_error=True,
|
|
511
|
-
)
|
|
512
|
-
|
|
569
|
+
elem = self.find_elem(selector=selector, selector_type=selector_type, role_type=role_type, show_error=True)
|
|
513
570
|
if not elem:
|
|
514
571
|
return False
|
|
515
572
|
|
|
516
|
-
|
|
517
|
-
|
|
573
|
+
is_enabled = elem.is_enabled()
|
|
574
|
+
if not is_enabled:
|
|
575
|
+
message = "Cannot set elem -> '{}' ({}) to value -> '{}'. It is not enabled!".format(
|
|
576
|
+
selector, selector_type, value
|
|
577
|
+
)
|
|
578
|
+
if show_error:
|
|
579
|
+
self.logger.error(message)
|
|
580
|
+
else:
|
|
581
|
+
self.logger.warning(message)
|
|
582
|
+
|
|
518
583
|
return False
|
|
519
584
|
|
|
520
585
|
if not is_sensitive:
|
|
521
|
-
self.logger.debug(
|
|
522
|
-
"Set element -> %s to value -> %s...",
|
|
523
|
-
find_elem,
|
|
524
|
-
elem_value,
|
|
525
|
-
)
|
|
586
|
+
self.logger.debug("Set element -> %s to value -> '%s'...", selector, value)
|
|
526
587
|
else:
|
|
527
|
-
self.logger.debug("Set element -> %s to value -> <sensitive>...",
|
|
588
|
+
self.logger.debug("Set element -> %s to value -> <sensitive>...", selector)
|
|
528
589
|
|
|
529
590
|
try:
|
|
530
|
-
#
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
591
|
+
# HTML '<select>' can only be identified based on its tag name:
|
|
592
|
+
tag_name = elem.evaluate("el => el.tagName.toLowerCase()")
|
|
593
|
+
# Checkboxes have tag name '<input type="checkbox">':
|
|
594
|
+
input_type = elem.get_attribute("type")
|
|
595
|
+
|
|
596
|
+
if tag_name == "select":
|
|
597
|
+
options = elem.query_selector_all("option")
|
|
598
|
+
option_values = [opt.inner_text().strip().replace("\n", "") for opt in options]
|
|
599
|
+
if value not in option_values:
|
|
600
|
+
self.logger.warning(
|
|
601
|
+
"Provided value -> '%s' not in available drop-down options -> %s. Cannot set it!",
|
|
602
|
+
value,
|
|
603
|
+
option_values,
|
|
604
|
+
)
|
|
605
|
+
return False
|
|
606
|
+
# We set the value over the (visible) label:
|
|
607
|
+
elem.select_option(label=value)
|
|
608
|
+
elif tag_name == "input" and input_type == "checkbox":
|
|
609
|
+
# Handle checkbox
|
|
610
|
+
if not isinstance(value, bool):
|
|
611
|
+
self.logger.error("Checkbox value must be a boolean!")
|
|
537
612
|
return False
|
|
613
|
+
is_checked = elem.is_checked()
|
|
614
|
+
if value != is_checked:
|
|
615
|
+
elem.check() if value else elem.uncheck()
|
|
538
616
|
else:
|
|
539
|
-
elem.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
"Cannot set page element -> %s to value -> %s",
|
|
544
|
-
find_elem,
|
|
545
|
-
elem_value,
|
|
617
|
+
elem.fill(value)
|
|
618
|
+
except PlaywrightError as e:
|
|
619
|
+
message = "Cannot set page element selected by -> '{}' ({}) to value -> '{}'; error -> {}".format(
|
|
620
|
+
selector, selector_type, value, str(e)
|
|
546
621
|
)
|
|
622
|
+
if show_error:
|
|
623
|
+
self.logger.error(message)
|
|
624
|
+
else:
|
|
625
|
+
self.logger.warning(message)
|
|
547
626
|
return False
|
|
548
627
|
|
|
549
628
|
if self.take_screenshots:
|
|
@@ -555,45 +634,184 @@ class BrowserAutomation:
|
|
|
555
634
|
|
|
556
635
|
def find_element_and_download(
|
|
557
636
|
self,
|
|
558
|
-
|
|
559
|
-
|
|
637
|
+
selector: str,
|
|
638
|
+
selector_type: str = "id",
|
|
639
|
+
role_type: str | None = None,
|
|
560
640
|
download_time: int = 30,
|
|
561
641
|
) -> str | None:
|
|
562
642
|
"""Click a page element to initiate a download.
|
|
563
643
|
|
|
564
644
|
Args:
|
|
565
|
-
|
|
645
|
+
selector (str):
|
|
566
646
|
The page element to click for download.
|
|
567
|
-
|
|
568
|
-
|
|
647
|
+
selector_type (str, optional):
|
|
648
|
+
One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
|
|
649
|
+
"label", "placeholder", "alt".
|
|
650
|
+
role_type (str | None, optional):
|
|
651
|
+
ARIA role when using selector_type="role", e.g., "button", "textbox".
|
|
652
|
+
If irrelevant then None should be passed for role_type.
|
|
569
653
|
download_time (int, optional):
|
|
570
|
-
Time in seconds to wait for the download to complete
|
|
654
|
+
Time in seconds to wait for the download to complete.
|
|
571
655
|
|
|
572
656
|
Returns:
|
|
573
657
|
str | None:
|
|
574
|
-
The
|
|
658
|
+
The full file path of the downloaded file.
|
|
575
659
|
|
|
576
660
|
"""
|
|
577
661
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
662
|
+
try:
|
|
663
|
+
with self.page.expect_download(timeout=download_time * 1000) as download_info:
|
|
664
|
+
clicked = self.find_elem_and_click(selector=selector, selector_type=selector_type, role_type=role_type)
|
|
665
|
+
if not clicked:
|
|
666
|
+
self.logger.error("Element not found to initiate download.")
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
download = download_info.value
|
|
670
|
+
filename = download.suggested_filename
|
|
671
|
+
save_path = os.path.join(self.download_directory, filename)
|
|
672
|
+
download.save_as(save_path)
|
|
673
|
+
except Exception as e:
|
|
674
|
+
self.logger.error("Download failed; error -> %s", str(e))
|
|
585
675
|
return None
|
|
586
676
|
|
|
587
|
-
|
|
588
|
-
|
|
677
|
+
self.logger.info("Download file to -> %s", save_path)
|
|
678
|
+
|
|
679
|
+
return save_path
|
|
680
|
+
|
|
681
|
+
# end method definition
|
|
682
|
+
|
|
683
|
+
def check_elems_exist(
|
|
684
|
+
self,
|
|
685
|
+
selector: str,
|
|
686
|
+
selector_type: str = "id",
|
|
687
|
+
role_type: str | None = None,
|
|
688
|
+
value: str | None = None,
|
|
689
|
+
attribute: str | None = None,
|
|
690
|
+
substring: bool = True,
|
|
691
|
+
min_count: int = 1,
|
|
692
|
+
wait_time: float = 0.0,
|
|
693
|
+
show_error: bool = True,
|
|
694
|
+
) -> tuple[bool | None, int]:
|
|
695
|
+
"""Check if (multiple) elements with defined attributes exist on page and return the number.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
selector (str):
|
|
699
|
+
Base selector.
|
|
700
|
+
selector_type (str):
|
|
701
|
+
One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
|
|
702
|
+
"label", "placeholder", "alt".
|
|
703
|
+
When using css, the selector becomes a raw CSS selector, and you can skip attribute
|
|
704
|
+
and value filtering entirely if your selector already narrows it down.
|
|
705
|
+
Examples for CSS:
|
|
706
|
+
* selector="img" - find all img tags (images)
|
|
707
|
+
* selector="img[title]" - find all img tags (images) that have a title attribute - independent of its value
|
|
708
|
+
* selector="img[title*='Microsoft Teams']" - find all images with a title that contains "Microsoft Teams"
|
|
709
|
+
* selector=".toolbar button" - find all buttons inside a .toolbar class
|
|
710
|
+
role_type (str | None, optional):
|
|
711
|
+
ARIA role when using selector_type="role", e.g., "button", "textbox".
|
|
712
|
+
If irrelevant then None should be passed for role_type.
|
|
713
|
+
value (str, optional):
|
|
714
|
+
Value to match in attribute or element content.
|
|
715
|
+
attribute (str, optional):
|
|
716
|
+
Attribute name to inspect. If None, uses element's text.
|
|
717
|
+
substring (bool):
|
|
718
|
+
If True, allow partial match.
|
|
719
|
+
min_count (int):
|
|
720
|
+
Minimum number of required matches (# elements on page).
|
|
721
|
+
wait_time (float):
|
|
722
|
+
Time in seconds to wait for elements to appear.
|
|
723
|
+
show_error (bool):
|
|
724
|
+
Whether to log warnings/errors.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
bool | None:
|
|
728
|
+
True if sufficient elements exist. False otherwise.
|
|
729
|
+
None if an error occurs.
|
|
730
|
+
int:
|
|
731
|
+
Number of matched elements.
|
|
732
|
+
|
|
733
|
+
"""
|
|
589
734
|
|
|
590
|
-
#
|
|
591
|
-
|
|
735
|
+
# Some operations that are done server-side and dynamically update
|
|
736
|
+
# the page may require a waiting time:
|
|
737
|
+
if wait_time > 0.0:
|
|
738
|
+
self.logger.info("Wait for %d milliseconds before checking...", wait_time * 1000)
|
|
739
|
+
self.page.wait_for_timeout(wait_time * 1000)
|
|
740
|
+
|
|
741
|
+
try:
|
|
742
|
+
match selector_type:
|
|
743
|
+
case "id":
|
|
744
|
+
locator = self.page.locator("#{}".format(selector))
|
|
745
|
+
case "name":
|
|
746
|
+
locator = self.page.locator("[name='{}']".format(selector))
|
|
747
|
+
case "class_name":
|
|
748
|
+
locator = self.page.locator(".{}".format(selector))
|
|
749
|
+
case "xpath":
|
|
750
|
+
locator = self.page.locator("xpath={}".format(selector))
|
|
751
|
+
case "css":
|
|
752
|
+
locator = self.page.locator(selector)
|
|
753
|
+
case "text":
|
|
754
|
+
locator = self.page.get_by_text(selector)
|
|
755
|
+
case "title":
|
|
756
|
+
locator = self.page.get_by_title(selector)
|
|
757
|
+
case "label":
|
|
758
|
+
locator = self.page.get_by_label(selector)
|
|
759
|
+
case "placeholder":
|
|
760
|
+
locator = self.page.get_by_placeholder(selector)
|
|
761
|
+
case "alt":
|
|
762
|
+
locator = self.page.get_by_alt_text(selector)
|
|
763
|
+
case "role":
|
|
764
|
+
if not role_type:
|
|
765
|
+
self.logger.error("Role type must be specified when using find method 'role'!")
|
|
766
|
+
return (None, 0)
|
|
767
|
+
locator = self.page.get_by_role(role=role_type, name=selector)
|
|
768
|
+
case _:
|
|
769
|
+
self.logger.error("Unsupported selector type -> '%s'", selector_type)
|
|
770
|
+
return (None, 0)
|
|
771
|
+
|
|
772
|
+
matching_elems = []
|
|
773
|
+
|
|
774
|
+
count = locator.count() if locator is not None else 0
|
|
775
|
+
if count == 0:
|
|
776
|
+
if show_error:
|
|
777
|
+
self.logger.error("No elements found using selector -> '%s' ('%s')", selector, selector_type)
|
|
778
|
+
return (None, 0)
|
|
779
|
+
|
|
780
|
+
for i in range(count):
|
|
781
|
+
elem_handle = locator.nth(i).element_handle()
|
|
782
|
+
if not elem_handle:
|
|
783
|
+
continue
|
|
784
|
+
|
|
785
|
+
if value is None:
|
|
786
|
+
# No filtering, accept all elements
|
|
787
|
+
matching_elems.append(elem_handle)
|
|
788
|
+
continue
|
|
789
|
+
|
|
790
|
+
# Get attribute or text content
|
|
791
|
+
attr_value = elem_handle.get_attribute(attribute) if attribute else elem_handle.text_content()
|
|
792
|
+
|
|
793
|
+
if not attr_value:
|
|
794
|
+
continue
|
|
795
|
+
|
|
796
|
+
if (substring and value in attr_value) or (not substring and value == attr_value):
|
|
797
|
+
matching_elems.append(elem_handle)
|
|
798
|
+
|
|
799
|
+
matching_elements_count = len(matching_elems)
|
|
800
|
+
|
|
801
|
+
if matching_elements_count < min_count and show_error:
|
|
802
|
+
self.logger.warning(
|
|
803
|
+
"%s matching elements found, expected at least %d",
|
|
804
|
+
"Only {}".format(matching_elements_count) if matching_elems else "No",
|
|
805
|
+
min_count,
|
|
806
|
+
)
|
|
807
|
+
return (False, matching_elements_count)
|
|
592
808
|
|
|
593
|
-
|
|
594
|
-
|
|
809
|
+
except PlaywrightError as e:
|
|
810
|
+
if show_error:
|
|
811
|
+
self.logger.error("Failed to check if elements -> '%s' exist; errors -> %s", selector, str(e))
|
|
812
|
+
return (None, 0)
|
|
595
813
|
|
|
596
|
-
return
|
|
814
|
+
return (True, matching_elements_count)
|
|
597
815
|
|
|
598
816
|
# end method definition
|
|
599
817
|
|
|
@@ -603,6 +821,8 @@ class BrowserAutomation:
|
|
|
603
821
|
password_field: str = "otds_password",
|
|
604
822
|
login_button: str = "loginbutton",
|
|
605
823
|
page: str = "",
|
|
824
|
+
wait_until: str | None = None,
|
|
825
|
+
selector_type: str = "id",
|
|
606
826
|
) -> bool:
|
|
607
827
|
"""Login to target system via the browser.
|
|
608
828
|
|
|
@@ -615,48 +835,64 @@ class BrowserAutomation:
|
|
|
615
835
|
The name of the HTML login button. Defaults to "loginbutton".
|
|
616
836
|
page (str, optional):
|
|
617
837
|
The URL to the login page. Defaults to "".
|
|
838
|
+
wait_until (str | None, optional):
|
|
839
|
+
Wait until a certain condition. Options are:
|
|
840
|
+
* "load" - Waits for the load event (after all resources like images/scripts load)
|
|
841
|
+
This is the safest strategy for pages that keep loading content in the background
|
|
842
|
+
like Salesforce.
|
|
843
|
+
* "networkidle" - Waits until there are no network connections for at least 500 ms.
|
|
844
|
+
This seems to be the safest one for OpenText Content Server.
|
|
845
|
+
* "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
|
|
846
|
+
but subresources may still load).
|
|
847
|
+
selector_type (str, optional):
|
|
848
|
+
One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
|
|
849
|
+
"label", "placeholder", "alt".
|
|
850
|
+
Default is "id".
|
|
618
851
|
|
|
619
852
|
Returns:
|
|
620
853
|
bool: True = success, False = error.
|
|
621
854
|
|
|
622
855
|
"""
|
|
623
856
|
|
|
857
|
+
# If no specific wait until strategy is provided in the
|
|
858
|
+
# parameter, we take the one from the browser automation class:
|
|
859
|
+
if wait_until is None:
|
|
860
|
+
wait_until = self.wait_until
|
|
861
|
+
|
|
624
862
|
self.logged_in = False
|
|
625
863
|
|
|
626
864
|
if (
|
|
627
|
-
not self.get_page(
|
|
628
|
-
|
|
629
|
-
) # assuming the base URL leads towards the login page
|
|
865
|
+
not self.get_page(url=page, wait_until=wait_until)
|
|
866
|
+
or not self.find_elem_and_set(selector=user_field, selector_type=selector_type, value=self.user_name)
|
|
630
867
|
or not self.find_elem_and_set(
|
|
631
|
-
|
|
632
|
-
elem_value=self.user_name,
|
|
868
|
+
selector=password_field, selector_type=selector_type, value=self.user_password, is_sensitive=True
|
|
633
869
|
)
|
|
634
|
-
or not self.
|
|
635
|
-
|
|
636
|
-
elem_value=self.user_password,
|
|
637
|
-
is_sensitive=True,
|
|
870
|
+
or not self.find_elem_and_click(
|
|
871
|
+
selector=login_button, selector_type=selector_type, is_navigation_trigger=True, wait_until=wait_until
|
|
638
872
|
)
|
|
639
|
-
or not self.find_elem_and_click(find_elem=login_button)
|
|
640
873
|
):
|
|
641
874
|
self.logger.error(
|
|
642
|
-
"Cannot log into target system using URL -> %s and user -> %s",
|
|
875
|
+
"Cannot log into target system using URL -> %s and user -> '%s'!",
|
|
643
876
|
self.base_url,
|
|
644
877
|
self.user_name,
|
|
645
878
|
)
|
|
646
879
|
return False
|
|
647
880
|
|
|
648
|
-
self.
|
|
881
|
+
self.page.wait_for_load_state(wait_until)
|
|
649
882
|
|
|
650
|
-
|
|
651
|
-
if
|
|
883
|
+
title = self.get_title()
|
|
884
|
+
if not title:
|
|
652
885
|
self.logger.error(
|
|
653
|
-
"
|
|
886
|
+
"Cannot read page title after login - you may have the wrong 'wait until' strategy configured!",
|
|
654
887
|
)
|
|
655
888
|
return False
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
if "Verify" in title:
|
|
892
|
+
self.logger.error("Site is asking for a Verification Token. You may need to whitelist your IP!")
|
|
893
|
+
return False
|
|
894
|
+
if "Login" in title:
|
|
895
|
+
self.logger.error("Authentication failed. You may have given the wrong password!")
|
|
660
896
|
return False
|
|
661
897
|
|
|
662
898
|
self.logged_in = True
|
|
@@ -665,26 +901,32 @@ class BrowserAutomation:
|
|
|
665
901
|
|
|
666
902
|
# end method definition
|
|
667
903
|
|
|
668
|
-
def
|
|
904
|
+
def set_timeout(self, wait_time: float) -> None:
|
|
669
905
|
"""Wait for the browser to finish tasks (e.g. fully loading a page).
|
|
670
906
|
|
|
671
907
|
This setting is valid for the whole browser session and not just
|
|
672
908
|
for a single command.
|
|
673
909
|
|
|
674
910
|
Args:
|
|
675
|
-
wait_time (float):
|
|
911
|
+
wait_time (float):
|
|
912
|
+
The time in seconds to wait.
|
|
676
913
|
|
|
677
914
|
"""
|
|
678
915
|
|
|
679
|
-
self.logger.debug("
|
|
680
|
-
self.
|
|
916
|
+
self.logger.debug("Setting default timeout to -> %s seconds...", str(wait_time))
|
|
917
|
+
self.page.set_default_timeout(wait_time * 1000)
|
|
918
|
+
self.logger.debug("Setting navigation timeout to -> %s seconds...", str(wait_time))
|
|
919
|
+
self.page.set_default_navigation_timeout(wait_time * 1000)
|
|
681
920
|
|
|
682
921
|
# end method definition
|
|
683
922
|
|
|
684
923
|
def end_session(self) -> None:
|
|
685
|
-
"""End the browser session
|
|
924
|
+
"""End the browser session and close the browser."""
|
|
686
925
|
|
|
926
|
+
self.logger.debug("Ending browser automation session...")
|
|
927
|
+
self.context.close()
|
|
687
928
|
self.browser.close()
|
|
688
929
|
self.logged_in = False
|
|
930
|
+
self.playwright.stop()
|
|
689
931
|
|
|
690
932
|
# end method definition
|