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