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

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

Potentially problematic release.


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

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