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.

Files changed (78) hide show
  1. pyxecm/__init__.py +7 -4
  2. pyxecm/avts.py +727 -254
  3. pyxecm/coreshare.py +686 -467
  4. pyxecm/customizer/__init__.py +16 -4
  5. pyxecm/customizer/__main__.py +58 -0
  6. pyxecm/customizer/api/__init__.py +5 -0
  7. pyxecm/customizer/api/__main__.py +6 -0
  8. pyxecm/customizer/api/app.py +163 -0
  9. pyxecm/customizer/api/auth/__init__.py +1 -0
  10. pyxecm/customizer/api/auth/functions.py +92 -0
  11. pyxecm/customizer/api/auth/models.py +13 -0
  12. pyxecm/customizer/api/auth/router.py +78 -0
  13. pyxecm/customizer/api/common/__init__.py +1 -0
  14. pyxecm/customizer/api/common/functions.py +47 -0
  15. pyxecm/customizer/api/common/metrics.py +92 -0
  16. pyxecm/customizer/api/common/models.py +21 -0
  17. pyxecm/customizer/api/common/payload_list.py +870 -0
  18. pyxecm/customizer/api/common/router.py +72 -0
  19. pyxecm/customizer/api/settings.py +128 -0
  20. pyxecm/customizer/api/terminal/__init__.py +1 -0
  21. pyxecm/customizer/api/terminal/router.py +87 -0
  22. pyxecm/customizer/api/v1_csai/__init__.py +1 -0
  23. pyxecm/customizer/api/v1_csai/router.py +87 -0
  24. pyxecm/customizer/api/v1_maintenance/__init__.py +1 -0
  25. pyxecm/customizer/api/v1_maintenance/functions.py +100 -0
  26. pyxecm/customizer/api/v1_maintenance/models.py +12 -0
  27. pyxecm/customizer/api/v1_maintenance/router.py +76 -0
  28. pyxecm/customizer/api/v1_otcs/__init__.py +1 -0
  29. pyxecm/customizer/api/v1_otcs/functions.py +61 -0
  30. pyxecm/customizer/api/v1_otcs/router.py +179 -0
  31. pyxecm/customizer/api/v1_payload/__init__.py +1 -0
  32. pyxecm/customizer/api/v1_payload/functions.py +179 -0
  33. pyxecm/customizer/api/v1_payload/models.py +51 -0
  34. pyxecm/customizer/api/v1_payload/router.py +499 -0
  35. pyxecm/customizer/browser_automation.py +721 -286
  36. pyxecm/customizer/customizer.py +1076 -1425
  37. pyxecm/customizer/exceptions.py +35 -0
  38. pyxecm/customizer/guidewire.py +1186 -0
  39. pyxecm/customizer/k8s.py +901 -379
  40. pyxecm/customizer/log.py +107 -0
  41. pyxecm/customizer/m365.py +2967 -920
  42. pyxecm/customizer/nhc.py +1169 -0
  43. pyxecm/customizer/openapi.py +258 -0
  44. pyxecm/customizer/payload.py +18228 -7820
  45. pyxecm/customizer/pht.py +717 -286
  46. pyxecm/customizer/salesforce.py +516 -342
  47. pyxecm/customizer/sap.py +58 -41
  48. pyxecm/customizer/servicenow.py +611 -372
  49. pyxecm/customizer/settings.py +445 -0
  50. pyxecm/customizer/successfactors.py +408 -346
  51. pyxecm/customizer/translate.py +83 -48
  52. pyxecm/helper/__init__.py +5 -2
  53. pyxecm/helper/assoc.py +83 -43
  54. pyxecm/helper/data.py +2406 -870
  55. pyxecm/helper/logadapter.py +27 -0
  56. pyxecm/helper/web.py +229 -101
  57. pyxecm/helper/xml.py +596 -171
  58. pyxecm/maintenance_page/__init__.py +5 -0
  59. pyxecm/maintenance_page/__main__.py +6 -0
  60. pyxecm/maintenance_page/app.py +51 -0
  61. pyxecm/maintenance_page/settings.py +28 -0
  62. pyxecm/maintenance_page/static/favicon.avif +0 -0
  63. pyxecm/maintenance_page/templates/maintenance.html +165 -0
  64. pyxecm/otac.py +235 -141
  65. pyxecm/otawp.py +2668 -1220
  66. pyxecm/otca.py +569 -0
  67. pyxecm/otcs.py +7956 -3237
  68. pyxecm/otds.py +2178 -925
  69. pyxecm/otiv.py +36 -21
  70. pyxecm/otmm.py +1272 -325
  71. pyxecm/otpd.py +231 -127
  72. pyxecm-2.0.1.dist-info/METADATA +122 -0
  73. pyxecm-2.0.1.dist-info/RECORD +76 -0
  74. {pyxecm-1.6.dist-info → pyxecm-2.0.1.dist-info}/WHEEL +1 -1
  75. pyxecm-1.6.dist-info/METADATA +0 -53
  76. pyxecm-1.6.dist-info/RECORD +0 -32
  77. {pyxecm-1.6.dist-info → pyxecm-2.0.1.dist-info/licenses}/LICENSE +0 -0
  78. {pyxecm-1.6.dist-info → pyxecm-2.0.1.dist-info}/top_level.txt +0 -0
@@ -1,81 +1,133 @@
1
- """
2
- browser_automation Module to implement a class to automate configuration
3
- via a browser interface. These are typically used as fallback options if
4
- no REST API or LLConfig can be used.
5
-
6
- Class: BrowserAutomation
7
- Methods:
8
-
9
- __init__ : class initializer. Start the browser session.
10
- set_chrome_options: Sets chrome options for Selenium. Chrome options for headless browser is enabled
11
- get_page: Load a page into the browser based on a given URL.
12
- find_elem: Find an page element
13
- find_elem_and_click: Find an page element and click it
14
- find_elem_and_set: Find an page element and fill it with a new text.
15
- find_element_and_download: Clicks a page element to initiate a download.
16
- run_login: Login to target system via the browser
17
- implicit_wait: Waits for the browser to finish tasks (e.g. fully loading a page).
18
- This setting is valid for the whole browser session.
19
- See https://www.selenium.dev/documentation/webdriver/waits/
20
- end_session: End the browser session
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
- import os
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
- logger = logging.getLogger("pyxecm.customizer.browser_automation")
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 selenium and chromedriver_autoinstaller
30
- # modules have not been installed in the customizer container:
45
+ # cases where the playwright modules have not been installed
46
+ # in the customizer container:
31
47
  try:
32
- from selenium.webdriver.chrome.options import Options
33
- from selenium import webdriver
34
- from selenium.webdriver.common.by import By
35
- from selenium.webdriver.common.action_chains import ActionChains
36
- from selenium.webdriver.remote.webelement import WebElement
37
- from selenium.common.exceptions import (
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
- except ModuleNotFoundError as module_exception:
47
- logger.warning("Module selenium is not installed")
48
-
49
- class Options:
50
- """Dummy class to avoid errors if selenium module cannot be imported"""
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 = "/tmp",
79
+ download_directory: str | None = None,
76
80
  take_screenshots: bool = False,
77
- automation_name: str = "screen",
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.screen_counter = 1
88
-
89
- self.screenshot_directory = "/tmp/browser_automations/{}".format(
90
- automation_name
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
- Returns:
111
- Options: Options to call the browser with
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: True if successful, False otherwise
164
+ bool:
165
+ True if successful, False otherwise
166
+
135
167
  """
136
168
 
137
169
  screenshot_file = "{}/{}-{}.png".format(
138
- self.screenshot_directory, self.screenshot_names, self.screen_counter
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
- result = self.browser.get_screenshot_as_file(screenshot_file)
142
- self.screen_counter += 1
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 result
183
+ return True
145
184
 
146
- def get_page(self, url: str = "") -> bool:
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): URL to load. If empty just the base URL will be used
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: True if successful, False otherwise
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
- self.browser.get(page_url)
160
- except WebDriverException as exception:
161
- logger.error("Cannot load page -> %s; error -> %s", page_url, exception)
162
- return False
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
- logger.debug("Page title after get page -> %s", self.browser.title)
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(self) -> str:
174
- """Get the browser title. This is handy to validate a certain page is loaded after get_page()
248
+ def get_title(
249
+ self,
250
+ wait_until: str | None = None,
251
+ ) -> str | None:
252
+ """Get the browser title.
175
253
 
176
- Returns:
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 not self.browser:
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
- return self.browser.title
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: WebElement):
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 (WebElement): Web element that has been identified before
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
- actions = ActionChains(self.browser)
201
- actions.move_to_element(element).perform()
202
- except NoSuchElementException:
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
- find_elem: str,
218
- find_method: str = By.ID,
310
+ selector: str,
311
+ selector_type: str = "id",
312
+ role_type: str | None = None,
219
313
  show_error: bool = True,
220
- ) -> WebElement:
221
- """Find an page element.
314
+ ) -> ElementHandle | None:
315
+ """Find a page element.
222
316
 
223
317
  Args:
224
- find_elem (str): name of the page element
225
- find_method (str, optional): either By.ID, By.NAME, By.CLASS_NAME, BY.XPATH
226
- show_error (bool, optional): show an error if the element is not found or not clickable
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
- WebElement: web element or None in case an error occured.
330
+ ElementHandle:
331
+ The web element or None in case an error occured.
332
+
229
333
  """
230
334
 
231
- # We don't want to expose class "By" outside this module,
232
- # so we map the string values to the By class values:
233
- if find_method == "id":
234
- find_method = By.ID
235
- elif find_method == "name":
236
- find_method = By.NAME
237
- elif find_method == "class_name":
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
- elem = self.browser.find_element(by=find_method, value=find_elem)
247
- except NoSuchElementException as exception:
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
- find_elem: str,
288
- find_method: str = By.ID,
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 an page element and click it.
405
+ """Find a page element and click it.
293
406
 
294
407
  Args:
295
- find_elem (str): name of the page element
296
- find_method (str, optional): either By.ID, By.NAME, By.CLASS_NAME, BY.XPATH
297
- scroll_to_element (bool, optional): scroll the element into view
298
- show_error (bool, optional): show an error if the element is not found or not clickable
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: True if successful, False otherwise
436
+ bool:
437
+ True if click is successful (or checkbox already in desired state),
438
+ False otherwise.
439
+
301
440
  """
302
441
 
303
- if not find_elem:
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("Missing element name! Cannot find HTML element!")
450
+ self.logger.error(failure_message)
306
451
  else:
307
- logger.warning("Missing element name! Cannot find HTML element!")
452
+ self.logger.warning(failure_message)
308
453
  return False
309
454
 
310
455
  elem = self.find_elem(
311
- find_elem=find_elem, find_method=find_method, show_error=show_error
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
- elem.click()
322
- except (
323
- ElementClickInterceptedException,
324
- ElementNotInteractableException,
325
- ) as exception:
326
- if show_error:
327
- logger.error(
328
- "Cannot click page element -> %s; error -> %s", find_elem, exception
329
- )
330
- return False
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.warning("Cannot click page element -> %s", find_elem)
333
- return True
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
- logger.debug("Successfully clicked element -> %s", find_elem)
522
+ if self.take_screenshots:
523
+ self.take_screenshot()
336
524
 
337
- if self.take_screenshots:
338
- self.take_screenshot()
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
- find_elem: str,
347
- elem_value: str,
348
- find_method: str = By.ID,
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
- find_elem (str): name of the page element
355
- elem_value (str): new text string for the page element
356
- find_method (str, optional): either By.ID, By.NAME, By.CLASS_NAME, or By.XPATH
357
- is_sensitive (bool, optional): True for suppressing sensitive information in logging
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: True if successful, False otherwise
360
- """
564
+ bool:
565
+ True if successful, False otherwise
361
566
 
362
- elem = self.find_elem(
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...", find_elem, elem_value)
586
+ self.logger.debug("Set element -> %s to value -> '%s'...", selector, value)
371
587
  else:
372
- logger.debug("Set element -> %s to value -> <sensitive>...", find_elem)
588
+ self.logger.debug("Set element -> %s to value -> <sensitive>...", selector)
373
589
 
374
590
  try:
375
- elem.clear() # clear existing text in the input field
376
- elem.send_keys(elem_value) # write new text into the field
377
- except ElementNotInteractableException as exception:
378
- logger.error(
379
- "Cannot set page element -> %s to value -> %s; error -> %s",
380
- find_elem,
381
- elem_value,
382
- exception,
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, find_elem: str, find_method: str = By.ID, download_time: int = 30
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
- """Clicks a page element to initiate a download
642
+ """Click a page element to initiate a download.
394
643
 
395
644
  Args:
396
- find_elem (str): page element to click for download
397
- find_method (str, optional): method to find the element. Defaults to By.ID.
398
- download_time (int, optional): time in seconds to wait for the download to complete
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: filename of the download
401
- """
657
+ str | None:
658
+ The full file path of the downloaded file.
402
659
 
403
- # Record the list of files in the download directory before the download
404
- initial_files = set(os.listdir(self.download_directory))
660
+ """
405
661
 
406
- if not self.find_elem_and_click(
407
- find_elem=find_elem,
408
- find_method=find_method,
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
- # Wait for the download to complete
413
- # time.sleep(download_time)
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
- self.browser.implicitly_wait(download_time)
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
- # Record the list of files in the download directory after the download
418
- current_files = set(os.listdir(self.download_directory))
733
+ """
419
734
 
420
- # Determine the name of the downloaded file
421
- new_file = (current_files - initial_files).pop()
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
- return new_file
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
- url=page
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
- find_elem=user_field, elem_value=self.user_name
868
+ selector=password_field, selector_type=selector_type, value=self.user_password, is_sensitive=True
444
869
  )
445
- or not self.find_elem_and_set(
446
- find_elem=password_field,
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
- logger.debug("Page title after login -> %s", self.browser.title)
881
+ self.page.wait_for_load_state(wait_until)
460
882
 
461
- # Some special handling for Salesforce login:
462
- if "Verify" in self.browser.title:
463
- logger.error(
464
- "Site is asking for a Verification Token. You may need to whitelist your IP!"
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
- if "Login" in self.browser.title:
468
- logger.error(
469
- "Authentication failed. You may have given the wrong password!"
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 implicit_wait(self, wait_time: float):
480
- """Waits for the browser to finish tasks (e.g. fully loading a page)
481
- This setting is valid for the whole browser session and not just
482
- for a single command.
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): time in seconds to wait
911
+ wait_time (float):
912
+ The time in seconds to wait.
913
+
486
914
  """
487
915
 
488
- logger.debug("Implicit wait for max -> %s seconds...", str(wait_time))
489
- self.browser.implicitly_wait(wait_time)
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