pyxecm 1.6__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 +7 -4
- pyxecm/avts.py +727 -254
- pyxecm/coreshare.py +686 -467
- pyxecm/customizer/__init__.py +16 -4
- pyxecm/customizer/__main__.py +58 -0
- pyxecm/customizer/api/__init__.py +5 -0
- pyxecm/customizer/api/__main__.py +6 -0
- pyxecm/customizer/api/app.py +163 -0
- pyxecm/customizer/api/auth/__init__.py +1 -0
- pyxecm/customizer/api/auth/functions.py +92 -0
- pyxecm/customizer/api/auth/models.py +13 -0
- 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/common/metrics.py +92 -0
- pyxecm/customizer/api/common/models.py +21 -0
- pyxecm/customizer/api/common/payload_list.py +870 -0
- pyxecm/customizer/api/common/router.py +72 -0
- pyxecm/customizer/api/settings.py +128 -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 +721 -286
- pyxecm/customizer/customizer.py +1076 -1425
- pyxecm/customizer/exceptions.py +35 -0
- pyxecm/customizer/guidewire.py +1186 -0
- pyxecm/customizer/k8s.py +901 -379
- pyxecm/customizer/log.py +107 -0
- pyxecm/customizer/m365.py +2967 -920
- pyxecm/customizer/nhc.py +1169 -0
- pyxecm/customizer/openapi.py +258 -0
- pyxecm/customizer/payload.py +18228 -7820
- pyxecm/customizer/pht.py +717 -286
- pyxecm/customizer/salesforce.py +516 -342
- pyxecm/customizer/sap.py +58 -41
- pyxecm/customizer/servicenow.py +611 -372
- pyxecm/customizer/settings.py +445 -0
- pyxecm/customizer/successfactors.py +408 -346
- pyxecm/customizer/translate.py +83 -48
- pyxecm/helper/__init__.py +5 -2
- pyxecm/helper/assoc.py +83 -43
- pyxecm/helper/data.py +2406 -870
- pyxecm/helper/logadapter.py +27 -0
- pyxecm/helper/web.py +229 -101
- pyxecm/helper/xml.py +596 -171
- pyxecm/maintenance_page/__init__.py +5 -0
- pyxecm/maintenance_page/__main__.py +6 -0
- pyxecm/maintenance_page/app.py +51 -0
- pyxecm/maintenance_page/settings.py +28 -0
- pyxecm/maintenance_page/static/favicon.avif +0 -0
- pyxecm/maintenance_page/templates/maintenance.html +165 -0
- pyxecm/otac.py +235 -141
- pyxecm/otawp.py +2668 -1220
- pyxecm/otca.py +569 -0
- pyxecm/otcs.py +7956 -3237
- pyxecm/otds.py +2178 -925
- pyxecm/otiv.py +36 -21
- pyxecm/otmm.py +1272 -325
- pyxecm/otpd.py +231 -127
- pyxecm-2.0.1.dist-info/METADATA +122 -0
- pyxecm-2.0.1.dist-info/RECORD +76 -0
- {pyxecm-1.6.dist-info → pyxecm-2.0.1.dist-info}/WHEEL +1 -1
- pyxecm-1.6.dist-info/METADATA +0 -53
- pyxecm-1.6.dist-info/RECORD +0 -32
- {pyxecm-1.6.dist-info → pyxecm-2.0.1.dist-info/licenses}/LICENSE +0 -0
- {pyxecm-1.6.dist-info → pyxecm-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -1,81 +1,133 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
"""browser_automation Module to automate configuration via a browser interface.
|
|
2
|
+
|
|
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
|
+
|
|
21
28
|
"""
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
__author__ = "Dr. Marc Diefenbruch"
|
|
31
|
+
__copyright__ = "Copyright 2025, OpenText"
|
|
32
|
+
__credits__ = ["Kai-Philip Gatzweiler"]
|
|
33
|
+
__maintainer__ = "Dr. Marc Diefenbruch"
|
|
34
|
+
__email__ = "mdiefenb@opentext.com"
|
|
35
|
+
|
|
24
36
|
import logging
|
|
37
|
+
import os
|
|
38
|
+
import tempfile
|
|
39
|
+
import time
|
|
40
|
+
from http import HTTPStatus
|
|
25
41
|
|
|
26
|
-
|
|
42
|
+
default_logger = logging.getLogger("pyxecm.customizer.browser_automation")
|
|
27
43
|
|
|
28
44
|
# For backwards compatibility we also want to handle
|
|
29
|
-
# cases where the
|
|
30
|
-
#
|
|
45
|
+
# cases where the playwright modules have not been installed
|
|
46
|
+
# in the customizer container:
|
|
31
47
|
try:
|
|
32
|
-
from
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
WebDriverException,
|
|
39
|
-
NoSuchElementException,
|
|
40
|
-
ElementNotInteractableException,
|
|
41
|
-
ElementClickInterceptedException,
|
|
42
|
-
TimeoutException,
|
|
43
|
-
MoveTargetOutOfBoundsException,
|
|
48
|
+
from playwright.sync_api import (
|
|
49
|
+
Browser,
|
|
50
|
+
BrowserContext,
|
|
51
|
+
ElementHandle,
|
|
52
|
+
Page,
|
|
53
|
+
sync_playwright,
|
|
44
54
|
)
|
|
55
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
56
|
+
except ModuleNotFoundError:
|
|
57
|
+
default_logger.warning("Module playwright is not installed")
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class By:
|
|
53
|
-
"""Dummy class to avoid errors if selenium module cannot be imported"""
|
|
54
|
-
|
|
55
|
-
ID: str = ""
|
|
56
|
-
|
|
57
|
-
class WebElement:
|
|
58
|
-
"""Dummy class to avoid errors if selenium module cannot be imported"""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
try:
|
|
62
|
-
import chromedriver_autoinstaller
|
|
63
|
-
except ModuleNotFoundError as module_exception:
|
|
64
|
-
logger.warning("Module chromedriver_autoinstaller is not installed!")
|
|
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"
|
|
65
64
|
|
|
65
|
+
REQUEST_TIMEOUT = 30
|
|
66
|
+
REQUEST_RETRY_DELAY = 2
|
|
67
|
+
REQUEST_MAX_RETRIES = 3
|
|
66
68
|
|
|
67
69
|
class BrowserAutomation:
|
|
68
70
|
"""Class to automate settings via a browser interface."""
|
|
69
71
|
|
|
72
|
+
logger: logging.Logger = default_logger
|
|
73
|
+
|
|
70
74
|
def __init__(
|
|
71
75
|
self,
|
|
72
76
|
base_url: str = "",
|
|
73
77
|
user_name: str = "",
|
|
74
78
|
user_password: str = "",
|
|
75
|
-
download_directory: str =
|
|
79
|
+
download_directory: str | None = None,
|
|
76
80
|
take_screenshots: bool = False,
|
|
77
|
-
automation_name: str = "
|
|
81
|
+
automation_name: str = "",
|
|
82
|
+
headless: bool = True,
|
|
83
|
+
logger: logging.Logger = default_logger,
|
|
84
|
+
wait_until: str | None = None,
|
|
78
85
|
) -> None:
|
|
86
|
+
"""Initialize the object.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
base_url (str, optional):
|
|
90
|
+
The base URL of the website to automate. Defaults to "".
|
|
91
|
+
user_name (str, optional):
|
|
92
|
+
If an authentication at the web site is required, this is the user name.
|
|
93
|
+
Defaults to "".
|
|
94
|
+
user_password (str, optional):
|
|
95
|
+
If an authentication at the web site is required, this is the user password.
|
|
96
|
+
Defaults to "".
|
|
97
|
+
download_directory (str | None, optional):
|
|
98
|
+
A download directory used for download links. If None,
|
|
99
|
+
a temporary directory is automatically used.
|
|
100
|
+
take_screenshots (bool, optional):
|
|
101
|
+
For debugging purposes, screenshots can be taken.
|
|
102
|
+
Defaults to False.
|
|
103
|
+
automation_name (str, optional):
|
|
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).
|
|
113
|
+
logger (logging.Logger, optional):
|
|
114
|
+
The logging object to use for all log messages. Defaults to default_logger.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
if not download_directory:
|
|
119
|
+
download_directory = os.path.join(
|
|
120
|
+
tempfile.gettempdir(),
|
|
121
|
+
"browser_automations",
|
|
122
|
+
automation_name,
|
|
123
|
+
"downloads",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if logger != default_logger:
|
|
127
|
+
self.logger = logger.getChild("browserautomation")
|
|
128
|
+
for logfilter in logger.filters:
|
|
129
|
+
self.logger.addFilter(logfilter)
|
|
130
|
+
|
|
79
131
|
self.base_url = base_url
|
|
80
132
|
self.user_name = user_name
|
|
81
133
|
self.user_password = user_password
|
|
@@ -84,84 +136,107 @@ class BrowserAutomation:
|
|
|
84
136
|
|
|
85
137
|
self.take_screenshots = take_screenshots
|
|
86
138
|
self.screenshot_names = automation_name
|
|
87
|
-
self.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
139
|
+
self.screenshot_counter = 1
|
|
140
|
+
self.wait_until = wait_until if wait_until else DEFAULT_WAIT_UNTIL_STRATEGY
|
|
141
|
+
|
|
142
|
+
self.screenshot_directory = os.path.join(
|
|
143
|
+
tempfile.gettempdir(),
|
|
144
|
+
"browser_automations",
|
|
145
|
+
automation_name,
|
|
146
|
+
"screenshots",
|
|
91
147
|
)
|
|
92
|
-
|
|
93
148
|
if self.take_screenshots and not os.path.exists(self.screenshot_directory):
|
|
94
149
|
os.makedirs(self.screenshot_directory)
|
|
95
|
-
chromedriver_autoinstaller.install()
|
|
96
|
-
self.browser = webdriver.Chrome(options=self.set_chrome_options())
|
|
97
|
-
|
|
98
|
-
# end method definition
|
|
99
|
-
|
|
100
|
-
def __del__(self):
|
|
101
|
-
if self.browser:
|
|
102
|
-
self.browser.close()
|
|
103
|
-
del self.browser
|
|
104
|
-
self.browser = None
|
|
105
|
-
|
|
106
|
-
def set_chrome_options(self) -> Options:
|
|
107
|
-
"""Sets chrome options for Selenium.
|
|
108
|
-
Chrome options for headless browser is enabled.
|
|
109
150
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
chrome_options = Options()
|
|
115
|
-
chrome_options.add_argument("--headless")
|
|
116
|
-
chrome_options.add_argument("--no-sandbox")
|
|
117
|
-
chrome_options.add_argument("--disable-dev-shm-usage")
|
|
118
|
-
chrome_prefs = {}
|
|
119
|
-
chrome_options.experimental_options["prefs"] = chrome_prefs
|
|
120
|
-
chrome_prefs["profile.default_content_settings"] = {"images": 2}
|
|
121
|
-
|
|
122
|
-
chrome_options.add_experimental_option(
|
|
123
|
-
"prefs", {"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,
|
|
124
155
|
)
|
|
125
|
-
|
|
126
|
-
return chrome_options
|
|
156
|
+
self.page: Page = self.context.new_page()
|
|
127
157
|
|
|
128
158
|
# end method definition
|
|
129
159
|
|
|
130
160
|
def take_screenshot(self) -> bool:
|
|
131
|
-
"""Take a screenshot of the current browser window and save it as PNG file
|
|
161
|
+
"""Take a screenshot of the current browser window and save it as PNG file.
|
|
132
162
|
|
|
133
163
|
Returns:
|
|
134
|
-
bool:
|
|
164
|
+
bool:
|
|
165
|
+
True if successful, False otherwise
|
|
166
|
+
|
|
135
167
|
"""
|
|
136
168
|
|
|
137
169
|
screenshot_file = "{}/{}-{}.png".format(
|
|
138
|
-
self.screenshot_directory,
|
|
170
|
+
self.screenshot_directory,
|
|
171
|
+
self.screenshot_names,
|
|
172
|
+
self.screenshot_counter,
|
|
139
173
|
)
|
|
140
|
-
logger.debug("Save browser screenshot to -> %s", screenshot_file)
|
|
141
|
-
|
|
142
|
-
|
|
174
|
+
self.logger.debug("Save browser screenshot to -> %s", screenshot_file)
|
|
175
|
+
|
|
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
|
|
143
182
|
|
|
144
|
-
return
|
|
183
|
+
return True
|
|
145
184
|
|
|
146
|
-
|
|
185
|
+
# end method definition
|
|
186
|
+
|
|
187
|
+
def get_page(self, url: str = "", wait_until: str | None = None) -> bool:
|
|
147
188
|
"""Load a page into the browser based on a given URL.
|
|
148
189
|
|
|
149
190
|
Args:
|
|
150
|
-
url (str):
|
|
191
|
+
url (str):
|
|
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
|
+
|
|
151
203
|
Returns:
|
|
152
|
-
bool:
|
|
204
|
+
bool:
|
|
205
|
+
True if successful, False otherwise.
|
|
206
|
+
|
|
153
207
|
"""
|
|
154
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
|
+
|
|
155
214
|
page_url = self.base_url + url
|
|
156
215
|
|
|
157
216
|
try:
|
|
158
|
-
logger.debug("Load page -> %s", page_url)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
217
|
+
self.logger.debug("Load page -> %s", page_url)
|
|
218
|
+
|
|
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
|
|
163
236
|
|
|
164
|
-
|
|
237
|
+
except PlaywrightError as e:
|
|
238
|
+
self.logger.error("Navigation to page -> %s has failed; error -> %s", page_url, str(e))
|
|
239
|
+
return False
|
|
165
240
|
|
|
166
241
|
if self.take_screenshots:
|
|
167
242
|
self.take_screenshot()
|
|
@@ -170,113 +245,147 @@ class BrowserAutomation:
|
|
|
170
245
|
|
|
171
246
|
# end method definition
|
|
172
247
|
|
|
173
|
-
def get_title(
|
|
174
|
-
|
|
248
|
+
def get_title(
|
|
249
|
+
self,
|
|
250
|
+
wait_until: str | None = None,
|
|
251
|
+
) -> str | None:
|
|
252
|
+
"""Get the browser title.
|
|
175
253
|
|
|
176
|
-
|
|
177
|
-
str: Title of the browser window
|
|
178
|
-
"""
|
|
254
|
+
This is handy to validate a certain page is loaded after get_page()
|
|
179
255
|
|
|
180
|
-
if
|
|
181
|
-
logger.error("Browser not initialized!")
|
|
182
|
-
return None
|
|
256
|
+
Retry-safe way to get the page title, even if there's an in-flight navigation.
|
|
183
257
|
|
|
184
|
-
|
|
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).
|
|
185
268
|
|
|
269
|
+
Returns:
|
|
270
|
+
str:
|
|
271
|
+
The title of the browser window.
|
|
272
|
+
|
|
273
|
+
"""
|
|
274
|
+
|
|
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)
|
|
284
|
+
|
|
285
|
+
return None
|
|
186
286
|
# end method definition
|
|
187
287
|
|
|
188
|
-
def scroll_to_element(self, element:
|
|
189
|
-
"""Scroll an element into view to make it clickable
|
|
288
|
+
def scroll_to_element(self, element: ElementHandle) -> None:
|
|
289
|
+
"""Scroll an element into view to make it clickable.
|
|
190
290
|
|
|
191
291
|
Args:
|
|
192
|
-
element (
|
|
292
|
+
element (ElementHandle):
|
|
293
|
+
Web element that has been identified before.
|
|
294
|
+
|
|
193
295
|
"""
|
|
194
296
|
|
|
195
297
|
if not element:
|
|
196
|
-
logger.error("Undefined element!")
|
|
298
|
+
self.logger.error("Undefined element!")
|
|
197
299
|
return
|
|
198
300
|
|
|
199
301
|
try:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
logger.error("Element not found in the DOM")
|
|
204
|
-
except TimeoutException:
|
|
205
|
-
logger.error("Timed out waiting for the element to be present or visible")
|
|
206
|
-
except ElementNotInteractableException:
|
|
207
|
-
logger.error("Element is not interactable!")
|
|
208
|
-
except MoveTargetOutOfBoundsException:
|
|
209
|
-
logger.error("Element is out of bounds!")
|
|
210
|
-
except WebDriverException as e:
|
|
211
|
-
logger.error("WebDriverException occurred -> %s", str(e))
|
|
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))
|
|
212
305
|
|
|
213
306
|
# end method definition
|
|
214
307
|
|
|
215
308
|
def find_elem(
|
|
216
309
|
self,
|
|
217
|
-
|
|
218
|
-
|
|
310
|
+
selector: str,
|
|
311
|
+
selector_type: str = "id",
|
|
312
|
+
role_type: str | None = None,
|
|
219
313
|
show_error: bool = True,
|
|
220
|
-
) ->
|
|
221
|
-
"""Find
|
|
314
|
+
) -> ElementHandle | None:
|
|
315
|
+
"""Find a page element.
|
|
222
316
|
|
|
223
317
|
Args:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
326
|
+
show_error (bool, optional):
|
|
327
|
+
Show an error if not found or not visible.
|
|
328
|
+
|
|
227
329
|
Returns:
|
|
228
|
-
|
|
330
|
+
ElementHandle:
|
|
331
|
+
The web element or None in case an error occured.
|
|
332
|
+
|
|
229
333
|
"""
|
|
230
334
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
find_method = By.CLASS_NAME
|
|
239
|
-
elif find_method == "xpath":
|
|
240
|
-
find_method = By.XPATH
|
|
241
|
-
else:
|
|
242
|
-
logger.error("Unsupported find method!")
|
|
243
|
-
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
|
+
)
|
|
244
342
|
|
|
245
343
|
try:
|
|
246
|
-
|
|
247
|
-
|
|
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:
|
|
248
384
|
if show_error:
|
|
249
|
-
logger.error(
|
|
250
|
-
"Cannot find page element -> %s by -> %s; error -> %s",
|
|
251
|
-
find_elem,
|
|
252
|
-
find_method,
|
|
253
|
-
exception,
|
|
254
|
-
)
|
|
255
|
-
return None
|
|
385
|
+
self.logger.error("%s; error -> %s", failure_message, str(e))
|
|
256
386
|
else:
|
|
257
|
-
logger.warning(
|
|
258
|
-
"Cannot find page element -> %s by -> %s",
|
|
259
|
-
find_elem,
|
|
260
|
-
find_method,
|
|
261
|
-
)
|
|
262
|
-
return None
|
|
263
|
-
except TimeoutException as exception:
|
|
264
|
-
logger.error(
|
|
265
|
-
"Timed out waiting for the element to be present or visible; error -> %s",
|
|
266
|
-
exception,
|
|
267
|
-
)
|
|
268
|
-
return None
|
|
269
|
-
except ElementNotInteractableException as exception:
|
|
270
|
-
logger.error("Element is not interactable!; error -> %s", exception)
|
|
271
|
-
return None
|
|
272
|
-
except MoveTargetOutOfBoundsException:
|
|
273
|
-
logger.error("Element is out of bounds!")
|
|
387
|
+
self.logger.warning("%s; error -> %s", failure_message, str(e))
|
|
274
388
|
return None
|
|
275
|
-
except WebDriverException as e:
|
|
276
|
-
logger.error("WebDriverException occurred -> %s", str(e))
|
|
277
|
-
return None
|
|
278
|
-
|
|
279
|
-
logger.debug("Found page element -> %s by -> %s", find_elem, find_method)
|
|
280
389
|
|
|
281
390
|
return elem
|
|
282
391
|
|
|
@@ -284,33 +393,68 @@ class BrowserAutomation:
|
|
|
284
393
|
|
|
285
394
|
def find_elem_and_click(
|
|
286
395
|
self,
|
|
287
|
-
|
|
288
|
-
|
|
396
|
+
selector: str,
|
|
397
|
+
selector_type: str = "id",
|
|
398
|
+
role_type: str | None = None,
|
|
289
399
|
scroll_to_element: bool = True,
|
|
400
|
+
desired_checkbox_state: bool | None = None,
|
|
401
|
+
is_navigation_trigger: bool = False,
|
|
402
|
+
wait_until: str | None = None,
|
|
290
403
|
show_error: bool = True,
|
|
291
404
|
) -> bool:
|
|
292
|
-
"""Find
|
|
405
|
+
"""Find a page element and click it.
|
|
293
406
|
|
|
294
407
|
Args:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
416
|
+
scroll_to_element (bool, optional):
|
|
417
|
+
Scroll the element into view.
|
|
418
|
+
desired_checkbox_state (bool | None, optional):
|
|
419
|
+
If True/False, ensures checkbox matches state.
|
|
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).
|
|
432
|
+
show_error (bool, optional):
|
|
433
|
+
Show an error if the element is not found or not clickable.
|
|
434
|
+
|
|
299
435
|
Returns:
|
|
300
|
-
bool:
|
|
436
|
+
bool:
|
|
437
|
+
True if click is successful (or checkbox already in desired state),
|
|
438
|
+
False otherwise.
|
|
439
|
+
|
|
301
440
|
"""
|
|
302
441
|
|
|
303
|
-
|
|
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!"
|
|
304
449
|
if show_error:
|
|
305
|
-
logger.error(
|
|
450
|
+
self.logger.error(failure_message)
|
|
306
451
|
else:
|
|
307
|
-
logger.warning(
|
|
452
|
+
self.logger.warning(failure_message)
|
|
308
453
|
return False
|
|
309
454
|
|
|
310
455
|
elem = self.find_elem(
|
|
311
|
-
|
|
456
|
+
selector=selector, selector_type=selector_type, role_type=role_type, show_error=show_error
|
|
312
457
|
)
|
|
313
|
-
|
|
314
458
|
if not elem:
|
|
315
459
|
return not show_error
|
|
316
460
|
|
|
@@ -318,24 +462,72 @@ class BrowserAutomation:
|
|
|
318
462
|
if scroll_to_element:
|
|
319
463
|
self.scroll_to_element(elem)
|
|
320
464
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
465
|
+
# Handle checkboxes
|
|
466
|
+
is_checkbox = elem.get_attribute("type") == "checkbox"
|
|
467
|
+
checkbox_state = None
|
|
468
|
+
|
|
469
|
+
if is_checkbox and desired_checkbox_state is not None:
|
|
470
|
+
checkbox_state = elem.is_checked()
|
|
471
|
+
if checkbox_state == desired_checkbox_state:
|
|
472
|
+
self.logger.debug(
|
|
473
|
+
"Checkbox -> '%s' is already in desired state -> %s", selector, desired_checkbox_state
|
|
474
|
+
)
|
|
475
|
+
return True # No need to click
|
|
476
|
+
else:
|
|
477
|
+
self.logger.debug("Checkbox -> '%s' has state mismatch. Clicking to change state.", selector)
|
|
478
|
+
|
|
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
|
|
331
491
|
else:
|
|
332
|
-
logger.
|
|
333
|
-
|
|
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
|
|
499
|
+
|
|
500
|
+
if is_checkbox and desired_checkbox_state is not None:
|
|
501
|
+
elem = self.find_elem(selector=selector, selector_type=selector_type, show_error=show_error)
|
|
502
|
+
if elem:
|
|
503
|
+
checkbox_state = elem.is_checked()
|
|
504
|
+
|
|
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
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
self.logger.debug("Successfully clicked element -> '%s'", selector)
|
|
334
521
|
|
|
335
|
-
|
|
522
|
+
if self.take_screenshots:
|
|
523
|
+
self.take_screenshot()
|
|
336
524
|
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
339
531
|
|
|
340
532
|
return True
|
|
341
533
|
|
|
@@ -343,84 +535,283 @@ class BrowserAutomation:
|
|
|
343
535
|
|
|
344
536
|
def find_elem_and_set(
|
|
345
537
|
self,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
538
|
+
selector: str,
|
|
539
|
+
value: str | bool,
|
|
540
|
+
selector_type: str = "id",
|
|
541
|
+
role_type: str | None = None,
|
|
349
542
|
is_sensitive: bool = False,
|
|
543
|
+
show_error: bool = True,
|
|
350
544
|
) -> bool:
|
|
351
545
|
"""Find an page element and fill it with a new text.
|
|
352
546
|
|
|
353
547
|
Args:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
+
|
|
358
563
|
Returns:
|
|
359
|
-
bool:
|
|
360
|
-
|
|
564
|
+
bool:
|
|
565
|
+
True if successful, False otherwise
|
|
361
566
|
|
|
362
|
-
|
|
363
|
-
find_elem=find_elem, find_method=find_method, show_error=True
|
|
364
|
-
)
|
|
567
|
+
"""
|
|
365
568
|
|
|
569
|
+
elem = self.find_elem(selector=selector, selector_type=selector_type, role_type=role_type, show_error=True)
|
|
366
570
|
if not elem:
|
|
367
571
|
return False
|
|
368
572
|
|
|
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
|
+
|
|
583
|
+
return False
|
|
584
|
+
|
|
369
585
|
if not is_sensitive:
|
|
370
|
-
logger.debug("Set element -> %s to value -> %s...",
|
|
586
|
+
self.logger.debug("Set element -> %s to value -> '%s'...", selector, value)
|
|
371
587
|
else:
|
|
372
|
-
logger.debug("Set element -> %s to value -> <sensitive>...",
|
|
588
|
+
self.logger.debug("Set element -> %s to value -> <sensitive>...", selector)
|
|
373
589
|
|
|
374
590
|
try:
|
|
375
|
-
|
|
376
|
-
elem.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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!")
|
|
612
|
+
return False
|
|
613
|
+
is_checked = elem.is_checked()
|
|
614
|
+
if value != is_checked:
|
|
615
|
+
elem.check() if value else elem.uncheck()
|
|
616
|
+
else:
|
|
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)
|
|
383
621
|
)
|
|
622
|
+
if show_error:
|
|
623
|
+
self.logger.error(message)
|
|
624
|
+
else:
|
|
625
|
+
self.logger.warning(message)
|
|
384
626
|
return False
|
|
385
627
|
|
|
628
|
+
if self.take_screenshots:
|
|
629
|
+
self.take_screenshot()
|
|
630
|
+
|
|
386
631
|
return True
|
|
387
632
|
|
|
388
633
|
# end method definition
|
|
389
634
|
|
|
390
635
|
def find_element_and_download(
|
|
391
|
-
self,
|
|
636
|
+
self,
|
|
637
|
+
selector: str,
|
|
638
|
+
selector_type: str = "id",
|
|
639
|
+
role_type: str | None = None,
|
|
640
|
+
download_time: int = 30,
|
|
392
641
|
) -> str | None:
|
|
393
|
-
"""
|
|
642
|
+
"""Click a page element to initiate a download.
|
|
394
643
|
|
|
395
644
|
Args:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
645
|
+
selector (str):
|
|
646
|
+
The page element to click for download.
|
|
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.
|
|
653
|
+
download_time (int, optional):
|
|
654
|
+
Time in seconds to wait for the download to complete.
|
|
655
|
+
|
|
399
656
|
Returns:
|
|
400
|
-
str | None:
|
|
401
|
-
|
|
657
|
+
str | None:
|
|
658
|
+
The full file path of the downloaded file.
|
|
402
659
|
|
|
403
|
-
|
|
404
|
-
initial_files = set(os.listdir(self.download_directory))
|
|
660
|
+
"""
|
|
405
661
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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))
|
|
410
675
|
return None
|
|
411
676
|
|
|
412
|
-
|
|
413
|
-
|
|
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.
|
|
414
725
|
|
|
415
|
-
|
|
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.
|
|
416
732
|
|
|
417
|
-
|
|
418
|
-
current_files = set(os.listdir(self.download_directory))
|
|
733
|
+
"""
|
|
419
734
|
|
|
420
|
-
#
|
|
421
|
-
|
|
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)
|
|
422
740
|
|
|
423
|
-
|
|
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)
|
|
808
|
+
|
|
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)
|
|
813
|
+
|
|
814
|
+
return (True, matching_elements_count)
|
|
424
815
|
|
|
425
816
|
# end method definition
|
|
426
817
|
|
|
@@ -430,44 +821,78 @@ class BrowserAutomation:
|
|
|
430
821
|
password_field: str = "otds_password",
|
|
431
822
|
login_button: str = "loginbutton",
|
|
432
823
|
page: str = "",
|
|
824
|
+
wait_until: str | None = None,
|
|
825
|
+
selector_type: str = "id",
|
|
433
826
|
) -> bool:
|
|
434
|
-
"""Login to target system via the browser
|
|
827
|
+
"""Login to target system via the browser.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
user_field (str, optional):
|
|
831
|
+
The name of the web HTML field to enter the user name. Defaults to "otds_username".
|
|
832
|
+
password_field (str, optional):
|
|
833
|
+
The name of the HTML field to enter the password. Defaults to "otds_password".
|
|
834
|
+
login_button (str, optional):
|
|
835
|
+
The name of the HTML login button. Defaults to "loginbutton".
|
|
836
|
+
page (str, optional):
|
|
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".
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
bool: True = success, False = error.
|
|
854
|
+
|
|
855
|
+
"""
|
|
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
|
|
435
861
|
|
|
436
862
|
self.logged_in = False
|
|
437
863
|
|
|
438
864
|
if (
|
|
439
|
-
not self.get_page(
|
|
440
|
-
|
|
441
|
-
) # 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)
|
|
442
867
|
or not self.find_elem_and_set(
|
|
443
|
-
|
|
868
|
+
selector=password_field, selector_type=selector_type, value=self.user_password, is_sensitive=True
|
|
444
869
|
)
|
|
445
|
-
or not self.
|
|
446
|
-
|
|
447
|
-
elem_value=self.user_password,
|
|
448
|
-
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
|
|
449
872
|
)
|
|
450
|
-
or not self.find_elem_and_click(find_elem=login_button)
|
|
451
873
|
):
|
|
452
|
-
logger.error(
|
|
453
|
-
"Cannot log into target system using URL -> %s and user -> %s",
|
|
874
|
+
self.logger.error(
|
|
875
|
+
"Cannot log into target system using URL -> %s and user -> '%s'!",
|
|
454
876
|
self.base_url,
|
|
455
877
|
self.user_name,
|
|
456
878
|
)
|
|
457
879
|
return False
|
|
458
880
|
|
|
459
|
-
|
|
881
|
+
self.page.wait_for_load_state(wait_until)
|
|
460
882
|
|
|
461
|
-
|
|
462
|
-
if
|
|
463
|
-
logger.error(
|
|
464
|
-
"
|
|
883
|
+
title = self.get_title()
|
|
884
|
+
if not title:
|
|
885
|
+
self.logger.error(
|
|
886
|
+
"Cannot read page title after login - you may have the wrong 'wait until' strategy configured!",
|
|
465
887
|
)
|
|
466
888
|
return False
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
)
|
|
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!")
|
|
471
896
|
return False
|
|
472
897
|
|
|
473
898
|
self.logged_in = True
|
|
@@ -476,22 +901,32 @@ class BrowserAutomation:
|
|
|
476
901
|
|
|
477
902
|
# end method definition
|
|
478
903
|
|
|
479
|
-
def
|
|
480
|
-
"""
|
|
481
|
-
|
|
482
|
-
|
|
904
|
+
def set_timeout(self, wait_time: float) -> None:
|
|
905
|
+
"""Wait for the browser to finish tasks (e.g. fully loading a page).
|
|
906
|
+
|
|
907
|
+
This setting is valid for the whole browser session and not just
|
|
908
|
+
for a single command.
|
|
483
909
|
|
|
484
910
|
Args:
|
|
485
|
-
wait_time (float):
|
|
911
|
+
wait_time (float):
|
|
912
|
+
The time in seconds to wait.
|
|
913
|
+
|
|
486
914
|
"""
|
|
487
915
|
|
|
488
|
-
logger.debug("
|
|
489
|
-
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)
|
|
920
|
+
|
|
921
|
+
# end method definition
|
|
490
922
|
|
|
491
|
-
def end_session(self):
|
|
492
|
-
"""End the browser session"""
|
|
923
|
+
def end_session(self) -> None:
|
|
924
|
+
"""End the browser session and close the browser."""
|
|
493
925
|
|
|
926
|
+
self.logger.debug("Ending browser automation session...")
|
|
927
|
+
self.context.close()
|
|
494
928
|
self.browser.close()
|
|
495
929
|
self.logged_in = False
|
|
930
|
+
self.playwright.stop()
|
|
496
931
|
|
|
497
932
|
# end method definition
|