pyxecm 2.0.0__py3-none-any.whl → 2.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

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