pyxecm 2.0.1__py3-none-any.whl → 2.0.3__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.

@@ -5,6 +5,30 @@ These are typically used as fallback options if no REST API or LLConfig can be u
5
5
  This module uses playwright: https://playwright.dev for broweser-based automation
6
6
  and testing.
7
7
 
8
+ Core Playwright data types and their relationships:
9
+
10
+ Playwright
11
+ └── BrowserType (chromium / firefox / webkit)
12
+ └── Browser
13
+ └── BrowserContext
14
+ └── Page
15
+ ├── Frame
16
+ ├── Locator (preferred for element interactions)
17
+ ├── ElementHandle (lower-level DOM reference)
18
+ └── JSHandle (handle to any JS object)
19
+
20
+ | Type | Description |
21
+ | ------------------ | -------------------------------------------------------------------------------------------------------------- |
22
+ | **Playwright** | Entry point via `sync_playwright()` or `async_playwright()`. Gives access to browser types. |
23
+ | **BrowserType** | Represents Chromium, Firefox, or WebKit. Used to launch a `Browser`. |
24
+ | **Browser** | A running browser instance. Use `.new_context()` to create sessions. |
25
+ | **BrowserContext** | Isolated incognito-like browser profile. Contains pages. |
26
+ | **Page** | A single browser tab. Main interface for navigation and interaction. |
27
+ | **Frame** | Represents a `<frame>` or `<iframe>`. Like a mini `Page`. |
28
+ | **Locator** | Lazily-evaluated, auto-waiting reference to one or more elements. **Preferred** for interacting with elements. |
29
+ | **ElementHandle** | Static reference to a single DOM element. Useful for special interactions or JS execution. |
30
+ | **JSHandle** | Handle to any JavaScript object, not just DOM nodes. Returned by `evaluate_handle()`. |
31
+
8
32
  Here are few few examples of the most typical page matches with the different selector types:
9
33
 
10
34
  | **Element to Match** | **CSS** | **XPath** | **Playwright `get_by_*` Method** |
@@ -35,9 +59,12 @@ __email__ = "mdiefenb@opentext.com"
35
59
 
36
60
  import logging
37
61
  import os
62
+ import subprocess
38
63
  import tempfile
39
64
  import time
65
+ import traceback
40
66
  from http import HTTPStatus
67
+ from types import TracebackType
41
68
 
42
69
  default_logger = logging.getLogger("pyxecm.customizer.browser_automation")
43
70
 
@@ -48,7 +75,7 @@ try:
48
75
  from playwright.sync_api import (
49
76
  Browser,
50
77
  BrowserContext,
51
- ElementHandle,
78
+ Locator,
52
79
  Page,
53
80
  sync_playwright,
54
81
  )
@@ -66,6 +93,7 @@ REQUEST_TIMEOUT = 30
66
93
  REQUEST_RETRY_DELAY = 2
67
94
  REQUEST_MAX_RETRIES = 3
68
95
 
96
+
69
97
  class BrowserAutomation:
70
98
  """Class to automate settings via a browser interface."""
71
99
 
@@ -82,6 +110,7 @@ class BrowserAutomation:
82
110
  headless: bool = True,
83
111
  logger: logging.Logger = default_logger,
84
112
  wait_until: str | None = None,
113
+ browser: str | None = None,
85
114
  ) -> None:
86
115
  """Initialize the object.
87
116
 
@@ -106,12 +135,15 @@ class BrowserAutomation:
106
135
  If True, the browser will be started in headless mode. Defaults to True.
107
136
  wait_until (str | None, optional):
108
137
  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,
138
+ * "commit" - does not wait at all - commit the request and continue
139
+ * "load" - waits for the load event (after all resources like images/scripts load)
140
+ * "networkidle" - waits until there are no network connections for at least 500 ms.
141
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
112
142
  but subresources may still load).
113
143
  logger (logging.Logger, optional):
114
144
  The logging object to use for all log messages. Defaults to default_logger.
145
+ browser (str | None, optional):
146
+ The browser to use. Defaults to None, which takes the global default or from the ENV "BROWSER".
115
147
 
116
148
  """
117
149
 
@@ -134,9 +166,12 @@ class BrowserAutomation:
134
166
  self.logged_in = False
135
167
  self.download_directory = download_directory
136
168
 
169
+ # Screenshot configurations:
137
170
  self.take_screenshots = take_screenshots
138
171
  self.screenshot_names = automation_name
139
172
  self.screenshot_counter = 1
173
+ self.screenshot_full_page = True
174
+
140
175
  self.wait_until = wait_until if wait_until else DEFAULT_WAIT_UNTIL_STRATEGY
141
176
 
142
177
  self.screenshot_directory = os.path.join(
@@ -145,15 +180,110 @@ class BrowserAutomation:
145
180
  automation_name,
146
181
  "screenshots",
147
182
  )
183
+ self.logger.debug("Creating Screenshot directory... -> %s", self.screenshot_directory)
148
184
  if self.take_screenshots and not os.path.exists(self.screenshot_directory):
149
185
  os.makedirs(self.screenshot_directory)
150
186
 
187
+ self.logger.debug("Creating Playwright instance...")
151
188
  self.playwright = sync_playwright().start()
152
- self.browser: Browser = self.playwright.chromium.launch(headless=headless)
189
+
190
+ proxy = None
191
+ if os.getenv("HTTP_PROXY"):
192
+ proxy = {
193
+ "server": os.getenv("HTTP_PROXY"),
194
+ }
195
+ self.logger.info("Using HTTP proxy -> %s", os.getenv("HTTP_PROXY"))
196
+
197
+ browser = browser or os.getenv("BROWSER", "webkit")
198
+ self.logger.info("Using Browser -> '%s'...", browser)
199
+
200
+ match browser:
201
+ case "chromium":
202
+ try:
203
+ self.browser: Browser = self.playwright.chromium.launch(
204
+ headless=headless, slow_mo=100 if not headless else None, proxy=proxy
205
+ )
206
+ except Exception:
207
+ self.install_browser(browser=browser)
208
+ self.browser: Browser = self.playwright.chromium.launch(
209
+ headless=headless, slow_mo=100 if not headless else None, proxy=proxy
210
+ )
211
+
212
+ case "chrome":
213
+ try:
214
+ self.browser: Browser = self.playwright.chromium.launch(
215
+ channel="chrome", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
216
+ )
217
+ except Exception:
218
+ self.install_browser(browser=browser)
219
+ self.browser: Browser = self.playwright.chromium.launch(
220
+ channel="chrome", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
221
+ )
222
+
223
+ case "msedge":
224
+ try:
225
+ self.browser: Browser = self.playwright.chromium.launch(
226
+ channel="msedge", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
227
+ )
228
+ except Exception:
229
+ self.install_browser(browser=browser)
230
+ self.browser: Browser = self.playwright.chromium.launch(
231
+ channel="msedge", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
232
+ )
233
+
234
+ case "webkit":
235
+ try:
236
+ self.browser: Browser = self.playwright.webkit.launch(
237
+ headless=headless, slow_mo=100 if not headless else None, proxy=proxy
238
+ )
239
+ except Exception:
240
+ self.install_browser(browser=browser)
241
+ self.browser: Browser = self.playwright.webkit.launch(
242
+ headless=headless, slow_mo=100 if not headless else None, proxy=proxy
243
+ )
244
+
245
+ case "firefox":
246
+ try:
247
+ self.browser: Browser = self.playwright.firefox.launch(
248
+ headless=headless, slow_mo=100 if not headless else None, proxy=proxy
249
+ )
250
+ except Exception:
251
+ self.install_browser(browser=browser)
252
+ self.browser: Browser = self.playwright.firefox.launch(
253
+ headless=headless, slow_mo=100 if not headless else None, proxy=proxy
254
+ )
255
+
256
+ self.logger.info("Creating Browser Context...")
153
257
  self.context: BrowserContext = self.browser.new_context(
154
258
  accept_downloads=True,
155
259
  )
260
+
261
+ self.logger.info("Creating Page...")
156
262
  self.page: Page = self.context.new_page()
263
+ self.main_page = self.page
264
+ self.logger.info("Browser Automation initialized.")
265
+
266
+ # end method definition
267
+
268
+ def install_browser(self, browser: str) -> bool:
269
+ """Check if browser is already installed if not install it."""
270
+
271
+ self.logger.info("Installing Browser -> '%s'...", browser)
272
+ process = subprocess.Popen(
273
+ ["playwright", "install", browser], # noqa: S607
274
+ stdout=subprocess.PIPE,
275
+ stderr=subprocess.PIPE,
276
+ shell=False,
277
+ )
278
+ output, error = process.communicate()
279
+ if process.returncode == 0:
280
+ self.logger.info("Installation completed successfullly.")
281
+ self.logger.debug(output.decode())
282
+ else:
283
+ self.logger.error("Installation failed with -> %s", error.decode())
284
+ self.logger.error(output.decode())
285
+
286
+ return True
157
287
 
158
288
  # end method definition
159
289
 
@@ -166,7 +296,7 @@ class BrowserAutomation:
166
296
 
167
297
  """
168
298
 
169
- screenshot_file = "{}/{}-{}.png".format(
299
+ screenshot_file = "{}/{}-{:02d}.png".format(
170
300
  self.screenshot_directory,
171
301
  self.screenshot_names,
172
302
  self.screenshot_counter,
@@ -174,7 +304,7 @@ class BrowserAutomation:
174
304
  self.logger.debug("Save browser screenshot to -> %s", screenshot_file)
175
305
 
176
306
  try:
177
- self.page.screenshot(path=screenshot_file)
307
+ self.page.screenshot(path=screenshot_file, full_page=self.screenshot_full_page)
178
308
  self.screenshot_counter += 1
179
309
  except Exception as e:
180
310
  self.logger.error("Failed to take screenshot; error -> %s", e)
@@ -192,12 +322,13 @@ class BrowserAutomation:
192
322
  URL to load. If empty just the base URL will be used.
193
323
  wait_until (str | None, optional):
194
324
  Wait until a certain condition. Options are:
195
- * "load" - Waits for the load event (after all resources like images/scripts load)
325
+ * "commit" - does not wait at all - commit the request and continue
326
+ * "load" - waits for the load event (after all resources like images/scripts load)
196
327
  This is the safest strategy for pages that keep loading content in the background
197
328
  like Salesforce.
198
- * "networkidle" - Waits until there are no network connections for at least 500 ms.
329
+ * "networkidle" - waits until there are no network connections for at least 500 ms.
199
330
  This seems to be the safest one for OpenText Content Server.
200
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
331
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
201
332
  but subresources may still load).
202
333
 
203
334
  Returns:
@@ -214,7 +345,7 @@ class BrowserAutomation:
214
345
  page_url = self.base_url + url
215
346
 
216
347
  try:
217
- self.logger.debug("Load page -> %s", page_url)
348
+ self.logger.debug("Load page -> %s (wait until -> '%s')", page_url, wait_until)
218
349
 
219
350
  # The Playwright Response object is different from the requests.response object!
220
351
  response = self.page.goto(page_url, wait_until=wait_until)
@@ -258,38 +389,50 @@ class BrowserAutomation:
258
389
  Args:
259
390
  wait_until (str | None, optional):
260
391
  Wait until a certain condition. Options are:
261
- * "load" - Waits for the load event (after all resources like images/scripts load)
392
+ * "commit" - does not wait at all - commit the request and continue
393
+ * "load" - waits for the load event (after all resources like images/scripts load)
262
394
  This is the safest strategy for pages that keep loading content in the background
263
395
  like Salesforce.
264
- * "networkidle" - Waits until there are no network connections for at least 500 ms.
396
+ * "networkidle" - waits until there are no network connections for at least 500 ms.
265
397
  This seems to be the safest one for OpenText Content Server.
266
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
398
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
267
399
  but subresources may still load).
268
400
 
269
401
  Returns:
270
402
  str:
271
- The title of the browser window.
403
+ The title of the browser page.
272
404
 
273
405
  """
274
406
 
275
- for _ in range(REQUEST_MAX_RETRIES):
407
+ for attempt in range(REQUEST_MAX_RETRIES):
276
408
  try:
277
- return self.page.title()
409
+ if wait_until:
410
+ self.page.wait_for_load_state(state=wait_until, timeout=REQUEST_TIMEOUT)
411
+ title = self.page.title()
412
+ if title:
413
+ return title
414
+ time.sleep(REQUEST_RETRY_DELAY)
415
+ self.logger.info("Retry attempt %d/%d", attempt + 1, REQUEST_MAX_RETRIES)
278
416
  except Exception as e:
279
417
  if "Execution context was destroyed" in str(e):
418
+ self.logger.info(
419
+ "Execution context was destroyed, retrying after %s seconds...", REQUEST_RETRY_DELAY
420
+ )
280
421
  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)
422
+ self.logger.info("Retry attempt %d/%d", attempt + 1, REQUEST_MAX_RETRIES)
423
+ continue
424
+ self.logger.error("Could not get page title; error -> %s", str(e))
425
+ break
284
426
 
285
427
  return None
428
+
286
429
  # end method definition
287
430
 
288
- def scroll_to_element(self, element: ElementHandle) -> None:
431
+ def scroll_to_element(self, element: Locator) -> None:
289
432
  """Scroll an element into view to make it clickable.
290
433
 
291
434
  Args:
292
- element (ElementHandle):
435
+ element (Locator):
293
436
  Web element that has been identified before.
294
437
 
295
438
  """
@@ -305,41 +448,28 @@ class BrowserAutomation:
305
448
 
306
449
  # end method definition
307
450
 
308
- def find_elem(
309
- self,
310
- selector: str,
311
- selector_type: str = "id",
312
- role_type: str | None = None,
313
- show_error: bool = True,
314
- ) -> ElementHandle | None:
315
- """Find a page element.
451
+ def get_locator(self, selector: str, selector_type: str, role_type: str | None = None) -> Locator | None:
452
+ """Determine the locator for the given selector type and (optional) role type.
316
453
 
317
454
  Args:
318
455
  selector (str):
319
- The name of the page element or accessible name (for role).
320
- selector_type (str, optional):
456
+ The selector to find the element on the page.
457
+ selector_type (str):
321
458
  One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
322
459
  "label", "placeholder", "alt".
460
+ When using css, the selector becomes a raw CSS selector, and you can skip attribute
461
+ and value filtering entirely if your selector already narrows it down.
462
+ Examples for CSS:
463
+ * selector="img" - find all img tags (images)
464
+ * selector="img[title]" - find all img tags (images) that have a title attribute - independent of its value
465
+ * selector="img[title*='Microsoft Teams']" - find all images with a title that contains "Microsoft Teams"
466
+ * selector=".toolbar button" - find all buttons inside a .toolbar class
323
467
  role_type (str | None, optional):
324
468
  ARIA role when using selector_type="role", e.g., "button", "textbox".
325
469
  If irrelevant then None should be passed for role_type.
326
- show_error (bool, optional):
327
- Show an error if not found or not visible.
328
-
329
- Returns:
330
- ElementHandle:
331
- The web element or None in case an error occured.
332
470
 
333
471
  """
334
472
 
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
- )
342
-
343
473
  try:
344
474
  match selector_type:
345
475
  case "id":
@@ -353,7 +483,8 @@ class BrowserAutomation:
353
483
  case "css":
354
484
  locator = self.page.locator(selector)
355
485
  case "text":
356
- locator = self.page.get_by_text(selector)
486
+ # locator = self.page.get_by_text(selector)
487
+ locator = self.page.locator("text={}".format(selector))
357
488
  case "title":
358
489
  locator = self.page.get_by_title(selector)
359
490
  case "label":
@@ -371,23 +502,83 @@ class BrowserAutomation:
371
502
  self.logger.error("Unsupported selector type -> '%s'", selector_type)
372
503
  return None
373
504
 
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)
505
+ except PlaywrightError as e:
506
+ self.logger.error("Failure to determine page locator; error -> %s", str(e))
507
+ return None
508
+
509
+ return locator
510
+
511
+ # end method definition
512
+
513
+ def find_elem(
514
+ self,
515
+ selector: str,
516
+ selector_type: str = "id",
517
+ role_type: str | None = None,
518
+ wait_state: str = "visible",
519
+ show_error: bool = True,
520
+ ) -> Locator | None:
521
+ """Find a page element.
522
+
523
+ Args:
524
+ selector (str):
525
+ The name of the page element or accessible name (for role).
526
+ selector_type (str, optional):
527
+ One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
528
+ "label", "placeholder", "alt".
529
+ role_type (str | None, optional):
530
+ ARIA role when using selector_type="role", e.g., "button", "textbox".
531
+ If irrelevant then None should be passed for role_type.
532
+ wait_state (str, optional):
533
+ Defines if we wait for attached (element is part of DOM) or
534
+ if we wait for elem to be visible (attached, displayed, and has non-zero size).
535
+ show_error (bool, optional):
536
+ Show an error if not found or not visible.
537
+
538
+ Returns:
539
+ Locator:
540
+ The web element or None in case an error occured.
541
+
542
+ """
543
+
544
+ failure_message = "Cannot find page element with selector -> '{}' ({}){}".format(
545
+ selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
546
+ )
547
+ success_message = "Found page element with selector -> '{}' ('{}'){}".format(
548
+ selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
549
+ )
550
+
551
+ # Determine the locator for the element:
552
+ locator = self.get_locator(selector=selector, selector_type=selector_type, role_type=role_type)
553
+ if not locator:
554
+ if show_error:
555
+ self.logger.error(failure_message)
380
556
  else:
381
- self.logger.debug(success_message)
557
+ self.logger.warning(failure_message)
558
+ return None
382
559
 
383
- except PlaywrightError as e:
560
+ # Wait for the element to be visible - don't use logic like
561
+ # locator.count() as this does not wait but fail immideately if elements
562
+ # are not yet loaded:
563
+ try:
564
+ self.logger.info(
565
+ "Wait for locator to find element with selector -> '%s' (%s%s) and state -> '%s'...",
566
+ selector,
567
+ "selector type -> '{}'".format(selector_type),
568
+ ", role type -> '{}'".format(role_type) if role_type else "",
569
+ wait_state,
570
+ )
571
+ locator.wait_for(state=wait_state)
572
+ except PlaywrightError:
384
573
  if show_error:
385
- self.logger.error("%s; error -> %s", failure_message, str(e))
574
+ self.logger.error("%s (timeout)", failure_message)
386
575
  else:
387
- self.logger.warning("%s; error -> %s", failure_message, str(e))
576
+ self.logger.warning("%s (timeout)", failure_message)
388
577
  return None
578
+ else:
579
+ self.logger.debug(success_message)
389
580
 
390
- return elem
581
+ return locator
391
582
 
392
583
  # end method definition
393
584
 
@@ -399,7 +590,10 @@ class BrowserAutomation:
399
590
  scroll_to_element: bool = True,
400
591
  desired_checkbox_state: bool | None = None,
401
592
  is_navigation_trigger: bool = False,
593
+ is_popup_trigger: bool = False,
594
+ is_page_close_trigger: bool = False,
402
595
  wait_until: str | None = None,
596
+ wait_time: float = 0.0,
403
597
  show_error: bool = True,
404
598
  ) -> bool:
405
599
  """Find a page element and click it.
@@ -420,15 +614,22 @@ class BrowserAutomation:
420
614
  If None then click it in any case.
421
615
  is_navigation_trigger (bool, optional):
422
616
  Is the click causing a navigation. Default is False.
617
+ is_popup_trigger (bool, optional):
618
+ Is the click causing a new browser window to open?
619
+ is_page_close_trigger (bool, optional):
620
+ Is the click causing the page to close?
423
621
  wait_until (str | None, optional):
424
622
  Wait until a certain condition. Options are:
425
- * "load" - Waits for the load event (after all resources like images/scripts load)
623
+ * "commit" - does not wait at all - commit the request and continue
624
+ * "load" - waits for the load event (after all resources like images/scripts load)
426
625
  This is the safest strategy for pages that keep loading content in the background
427
626
  like Salesforce.
428
- * "networkidle" - Waits until there are no network connections for at least 500 ms.
627
+ * "networkidle" - waits until there are no network connections for at least 500 ms.
429
628
  This seems to be the safest one for OpenText Content Server.
430
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
629
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
431
630
  but subresources may still load).
631
+ wait_time (float):
632
+ Time in seconds to wait for elements to appear.
432
633
  show_error (bool, optional):
433
634
  Show an error if the element is not found or not clickable.
434
635
 
@@ -439,11 +640,19 @@ class BrowserAutomation:
439
640
 
440
641
  """
441
642
 
643
+ success = True # Final return value
644
+
442
645
  # If no specific wait until strategy is provided in the
443
646
  # parameter, we take the one from the browser automation class:
444
647
  if wait_until is None:
445
648
  wait_until = self.wait_until
446
649
 
650
+ # Some operations that are done server-side and dynamically update
651
+ # the page may require a waiting time:
652
+ if wait_time > 0.0:
653
+ self.logger.info("Wait for %d milliseconds before clicking...", wait_time * 1000)
654
+ self.page.wait_for_timeout(wait_time * 1000)
655
+
447
656
  if not selector:
448
657
  failure_message = "Missing element selector! Cannot find page element!"
449
658
  if show_error:
@@ -462,74 +671,74 @@ class BrowserAutomation:
462
671
  if scroll_to_element:
463
672
  self.scroll_to_element(elem)
464
673
 
465
- # Handle checkboxes
466
- is_checkbox = elem.get_attribute("type") == "checkbox"
467
- checkbox_state = None
468
-
469
- if is_checkbox and desired_checkbox_state is not None:
470
- checkbox_state = elem.is_checked()
471
- if checkbox_state == desired_checkbox_state:
472
- self.logger.debug(
473
- "Checkbox -> '%s' is already in desired state -> %s", selector, desired_checkbox_state
674
+ # Handle checkboxes if requested:
675
+ if desired_checkbox_state is not None and elem.get_attribute("type") == "checkbox":
676
+ # Let Playwright handle checkbox state:
677
+ elem.set_checked(desired_checkbox_state)
678
+ self.logger.debug("Set checkbox -> '%s' to value -> %s.", selector, desired_checkbox_state)
679
+ # Handle non-checkboxes:
680
+ else:
681
+ # Will this click trigger a naviagation?
682
+ if is_navigation_trigger:
683
+ self.logger.info(
684
+ "Clicking on navigation-triggering element -> '%s' (%s%s) and wait until -> '%s'...",
685
+ selector,
686
+ "selector type -> '{}'".format(selector_type),
687
+ ", role type -> '{}'".format(role_type) if role_type else "",
688
+ wait_until,
474
689
  )
475
- return True # No need to click
476
- else:
477
- self.logger.debug("Checkbox -> '%s' has state mismatch. Clicking to change state.", selector)
478
-
479
- if is_navigation_trigger:
480
- self.logger.info("Clicking on navigation-triggering element -> '%s'", selector)
481
- try:
482
690
  with self.page.expect_navigation(wait_until=wait_until):
483
691
  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",
692
+ # Will this click trigger a a new popup window?
693
+ elif is_popup_trigger:
694
+ with self.page.expect_popup() as popup_info:
695
+ elem.click()
696
+ if not popup_info or not popup_info.value:
697
+ self.logger.info("Popup window did not open as expected!")
698
+ success = False
699
+ else:
700
+ self.page = popup_info.value
701
+ self.logger.info("Move browser automation to popup window -> %s...", self.page.url)
702
+ else:
703
+ self.logger.info(
704
+ "Clicking on non-navigating element -> '%s' (%s%s)...",
487
705
  selector,
488
- str(e),
706
+ "selector type -> '{}'".format(selector_type),
707
+ ", role type -> '{}'".format(role_type) if role_type else "",
489
708
  )
490
- return False
491
- else:
492
- self.logger.info("Clicking on non-navigating element -> '%s'", selector)
493
- try:
494
709
  elem.click()
495
710
  time.sleep(1)
496
- except PlaywrightError as e:
497
- self.logger.error("Click failed -> %s", str(e))
498
- return False
499
-
500
- if is_checkbox and desired_checkbox_state is not None:
501
- elem = self.find_elem(selector=selector, selector_type=selector_type, show_error=show_error)
502
- if elem:
503
- checkbox_state = elem.is_checked()
504
-
505
- if checkbox_state is not None:
506
- if checkbox_state == desired_checkbox_state:
711
+ if success:
507
712
  self.logger.debug(
508
- "Successfully clicked checkbox element -> '%s'. It's state is now -> %s",
713
+ "Successfully clicked element -> '%s' (%s%s)",
509
714
  selector,
510
- checkbox_state,
715
+ "selector type -> '{}'".format(selector_type),
716
+ ", role type -> '{}'".format(role_type) if role_type else "",
511
717
  )
512
- else:
513
- self.logger.error(
514
- "Failed to flip checkbox element -> '%s' to desired state. It's state is still -> %s and not -> %s",
515
- selector,
516
- checkbox_state,
517
- desired_checkbox_state,
518
- )
519
- else:
520
- self.logger.debug("Successfully clicked element -> '%s'", selector)
521
-
522
- if self.take_screenshots:
523
- self.take_screenshot()
524
718
 
525
719
  except PlaywrightError as e:
526
720
  if show_error:
527
- self.logger.error("Cannot click page element -> '%s'; error -> %s", selector, str(e))
721
+ self.logger.error(
722
+ "Cannot click page element -> '%s' (%s); error -> %s", selector, selector_type, str(e)
723
+ )
528
724
  else:
529
- self.logger.warning("Cannot click page element -> '%s'; warning -> %s", selector, str(e))
530
- return not show_error
725
+ self.logger.warning(
726
+ "Cannot click page element -> '%s' (%s); warning -> %s", selector, selector_type, str(e)
727
+ )
728
+ success = not show_error
531
729
 
532
- return True
730
+ if is_page_close_trigger:
731
+ if self.page == self.main_page:
732
+ self.logger.error("Unexpected try to close main page! Popup page not active! This is not supported!")
733
+ success = False
734
+ else:
735
+ self.page = self.main_page
736
+ self.logger.info("Move browser automation back to main window -> %s...", self.page.url)
737
+
738
+ if self.take_screenshots:
739
+ self.take_screenshot()
740
+
741
+ return success
533
742
 
534
743
  # end method definition
535
744
 
@@ -566,6 +775,8 @@ class BrowserAutomation:
566
775
 
567
776
  """
568
777
 
778
+ success = False # Final return value
779
+
569
780
  elem = self.find_elem(selector=selector, selector_type=selector_type, role_type=role_type, show_error=True)
570
781
  if not elem:
571
782
  return False
@@ -580,12 +791,14 @@ class BrowserAutomation:
580
791
  else:
581
792
  self.logger.warning(message)
582
793
 
794
+ if self.take_screenshots:
795
+ self.take_screenshot()
796
+
583
797
  return False
584
798
 
585
- if not is_sensitive:
586
- self.logger.debug("Set element -> %s to value -> '%s'...", selector, value)
587
- else:
588
- self.logger.debug("Set element -> %s to value -> <sensitive>...", selector)
799
+ self.logger.info(
800
+ "Set element -> '%s' to value -> '%s'...", selector, value if not is_sensitive else "<sensitive>"
801
+ )
589
802
 
590
803
  try:
591
804
  # HTML '<select>' can only be identified based on its tag name:
@@ -594,27 +807,38 @@ class BrowserAutomation:
594
807
  input_type = elem.get_attribute("type")
595
808
 
596
809
  if tag_name == "select":
597
- options = elem.query_selector_all("option")
598
- option_values = [opt.inner_text().strip().replace("\n", "") for opt in options]
810
+ options = elem.locator("option")
811
+ options_count = options.count()
812
+ option_values = [options.nth(i).inner_text().strip().replace("\n", "") for i in range(options_count)]
813
+
599
814
  if value not in option_values:
600
815
  self.logger.warning(
601
- "Provided value -> '%s' not in available drop-down options -> %s. Cannot set it!",
816
+ "Provided value -> '%s' is not in available drop-down options -> %s. Cannot set it!",
602
817
  value,
603
818
  option_values,
604
819
  )
605
- return False
606
- # We set the value over the (visible) label:
607
- elem.select_option(label=value)
820
+ else:
821
+ # We set the value over the (visible) label:
822
+ elem.select_option(label=value)
823
+ success = True
608
824
  elif tag_name == "input" and input_type == "checkbox":
609
825
  # Handle checkbox
610
826
  if not isinstance(value, bool):
611
827
  self.logger.error("Checkbox value must be a boolean!")
612
- return False
613
- is_checked = elem.is_checked()
614
- if value != is_checked:
615
- elem.check() if value else elem.uncheck()
828
+ else:
829
+ retry = 0
830
+ while elem.is_checked() != value and retry < 5:
831
+ try:
832
+ elem.set_checked(checked=value)
833
+ except Exception:
834
+ self.logger.warning("Cannot set checkbox to value -> '%s'. (retry %s).", value, retry)
835
+ finally:
836
+ retry += 1
837
+
838
+ success = retry < 5 # True is less than 5 retries were needed
616
839
  else:
617
840
  elem.fill(value)
841
+ success = True
618
842
  except PlaywrightError as e:
619
843
  message = "Cannot set page element selected by -> '{}' ({}) to value -> '{}'; error -> {}".format(
620
844
  selector, selector_type, value, str(e)
@@ -623,12 +847,12 @@ class BrowserAutomation:
623
847
  self.logger.error(message)
624
848
  else:
625
849
  self.logger.warning(message)
626
- return False
850
+ success = not show_error
627
851
 
628
852
  if self.take_screenshots:
629
853
  self.take_screenshot()
630
854
 
631
- return True
855
+ return success
632
856
 
633
857
  # end method definition
634
858
 
@@ -690,13 +914,14 @@ class BrowserAutomation:
690
914
  substring: bool = True,
691
915
  min_count: int = 1,
692
916
  wait_time: float = 0.0,
917
+ wait_state: str = "visible",
693
918
  show_error: bool = True,
694
919
  ) -> tuple[bool | None, int]:
695
920
  """Check if (multiple) elements with defined attributes exist on page and return the number.
696
921
 
697
922
  Args:
698
923
  selector (str):
699
- Base selector.
924
+ The selector to find the element on the page.
700
925
  selector_type (str):
701
926
  One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
702
927
  "label", "placeholder", "alt".
@@ -720,6 +945,9 @@ class BrowserAutomation:
720
945
  Minimum number of required matches (# elements on page).
721
946
  wait_time (float):
722
947
  Time in seconds to wait for elements to appear.
948
+ wait_state (str, optional):
949
+ Defines if we wait for attached (element is part of DOM) or
950
+ if we wait for elem to be visible (attached, displayed, and has non-zero size).
723
951
  show_error (bool):
724
952
  Whether to log warnings/errors.
725
953
 
@@ -732,86 +960,126 @@ class BrowserAutomation:
732
960
 
733
961
  """
734
962
 
963
+ failure_message = "No matching page element found with selector -> '{}' ({}){}".format(
964
+ selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
965
+ )
966
+
967
+ # Determine the locator for the elements:
968
+ locator = self.get_locator(selector=selector, selector_type=selector_type, role_type=role_type)
969
+ if not locator:
970
+ if show_error:
971
+ self.logger.error(
972
+ "Failed to check if elements -> '%s' (%s) exist! Locator is undefined.", selector, selector_type
973
+ )
974
+ return (None, 0)
975
+
976
+ self.logger.info(
977
+ "Check if at least %d element%s found by selector -> %s (%s%s)%s%s...",
978
+ min_count,
979
+ "s are" if min_count > 1 else " is",
980
+ selector,
981
+ "selector type -> '{}'".format(selector_type),
982
+ ", role type -> {}".format(role_type) if role_type else "",
983
+ " with value -> '{}'".format(value) if value else "",
984
+ " in attribute -> '{}'".format(attribute) if attribute and value else "",
985
+ )
986
+
987
+ # Wait for the element to be visible - don't immediately use logic like
988
+ # locator.count() as this does not wait but then fail immideately
989
+ try:
990
+ self.logger.info(
991
+ "Wait for locator to find first matching element with selector -> '%s' (%s%s) and state -> '%s'...",
992
+ selector,
993
+ "selector type -> '{}'".format(selector_type),
994
+ ", role type -> {}".format(role_type) if role_type else "",
995
+ wait_state,
996
+ )
997
+ self.logger.info("Locator count before waiting: %d", locator.count())
998
+
999
+ # for i in range(locator.count()):
1000
+ # elem = locator.nth(i)
1001
+ # self.logger.info("Element #%d visible: %s", i, elem.is_visible())
1002
+
1003
+ # IMPORTANT: We wait for the FIRST element. otherwise we get errors like
1004
+ # 'Locator.wait_for: Error: strict mode violation'.
1005
+ # IMPORTANT: if the first match does not comply to the
1006
+ # wait_state this will block and then timeout. Check your
1007
+ # selector to make sure it delivers a visible first element!
1008
+ locator.first.wait_for(state=wait_state)
1009
+ except PlaywrightError as e:
1010
+ # This is typically a timeout error indicating the element does not exist
1011
+ # in the defined timeout period.
1012
+ if show_error:
1013
+ self.logger.error("%s (timeout); error -> %s", failure_message, str(e))
1014
+ else:
1015
+ self.logger.warning("%s (timeout)", failure_message)
1016
+ return (None, 0)
1017
+
735
1018
  # Some operations that are done server-side and dynamically update
736
- # the page may require a waiting time:
1019
+ # the page with additional matching elements that may require a waiting time:
737
1020
  if wait_time > 0.0:
738
- self.logger.info("Wait for %d milliseconds before checking...", wait_time * 1000)
1021
+ self.logger.info("Wait additional %d milliseconds before checking...", wait_time * 1000)
739
1022
  self.page.wait_for_timeout(wait_time * 1000)
740
1023
 
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)
1024
+ matching_elems = []
771
1025
 
772
- matching_elems = []
1026
+ count = locator.count()
1027
+ if count == 0:
1028
+ if show_error:
1029
+ self.logger.error("No elements found using selector -> '%s' ('%s')", selector, selector_type)
1030
+ return (None, 0)
773
1031
 
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)
1032
+ self.logger.info(
1033
+ "Found %s elements matching selector -> '%s' (%s%s).",
1034
+ count,
1035
+ selector,
1036
+ "selector type -> '{}'".format(selector_type),
1037
+ ", role type -> '{}'".format(role_type) if role_type else "",
1038
+ )
779
1039
 
780
- for i in range(count):
781
- elem_handle = locator.nth(i).element_handle()
782
- if not elem_handle:
783
- continue
1040
+ if value:
1041
+ self.logger.info(
1042
+ "Checking if their %s %s -> '%s'...",
1043
+ "attribute -> '{}'".format(attribute) if attribute else "content",
1044
+ "has value" if not substring else "contains",
1045
+ value,
1046
+ )
784
1047
 
785
- if value is None:
786
- # No filtering, accept all elements
787
- matching_elems.append(elem_handle)
788
- continue
1048
+ for i in range(count):
1049
+ elem = locator.nth(i)
1050
+ if not elem:
1051
+ continue
789
1052
 
790
- # Get attribute or text content
791
- attr_value = elem_handle.get_attribute(attribute) if attribute else elem_handle.text_content()
1053
+ if value is None:
1054
+ # No filtering, accept all elements
1055
+ matching_elems.append(elem)
1056
+ continue
792
1057
 
793
- if not attr_value:
794
- continue
1058
+ # Get attribute or text content
1059
+ attr_value = elem.get_attribute(attribute) if attribute else elem.text_content()
795
1060
 
796
- if (substring and value in attr_value) or (not substring and value == attr_value):
797
- matching_elems.append(elem_handle)
1061
+ if not attr_value:
1062
+ continue
798
1063
 
799
- matching_elements_count = len(matching_elems)
1064
+ if (substring and value in attr_value) or (not substring and value == attr_value):
1065
+ matching_elems.append(elem)
800
1066
 
801
- if matching_elements_count < min_count and show_error:
802
- self.logger.warning(
803
- "%s matching elements found, expected at least %d",
1067
+ matching_elements_count = len(matching_elems)
1068
+
1069
+ success = True
1070
+
1071
+ if matching_elements_count < min_count:
1072
+ success = False
1073
+ if show_error:
1074
+ self.logger.error(
1075
+ "%s matching element%s found, expected at least %d",
804
1076
  "Only {}".format(matching_elements_count) if matching_elems else "No",
1077
+ "s" if matching_elements_count > 1 else "",
805
1078
  min_count,
806
1079
  )
807
- return (False, matching_elements_count)
1080
+ self.logger.info("Found %d matching elements.", matching_elements_count)
808
1081
 
809
- except PlaywrightError as e:
810
- if show_error:
811
- self.logger.error("Failed to check if elements -> '%s' exist; errors -> %s", selector, str(e))
812
- return (None, 0)
813
-
814
- return (True, matching_elements_count)
1082
+ return (success, matching_elements_count)
815
1083
 
816
1084
  # end method definition
817
1085
 
@@ -837,12 +1105,13 @@ class BrowserAutomation:
837
1105
  The URL to the login page. Defaults to "".
838
1106
  wait_until (str | None, optional):
839
1107
  Wait until a certain condition. Options are:
840
- * "load" - Waits for the load event (after all resources like images/scripts load)
1108
+ * "commit" - does not wait at all - commit the request and continue
1109
+ * "load" - waits for the load event (after all resources like images/scripts load)
841
1110
  This is the safest strategy for pages that keep loading content in the background
842
1111
  like Salesforce.
843
- * "networkidle" - Waits until there are no network connections for at least 500 ms.
1112
+ * "networkidle" - waits until there are no network connections for at least 500 ms.
844
1113
  This seems to be the safest one for OpenText Content Server.
845
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
1114
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
846
1115
  but subresources may still load).
847
1116
  selector_type (str, optional):
848
1117
  One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
@@ -878,16 +1147,17 @@ class BrowserAutomation:
878
1147
  )
879
1148
  return False
880
1149
 
1150
+ self.logger.info("Wait for -> '%s' to assure login is completed and target page is loaded.", wait_until)
881
1151
  self.page.wait_for_load_state(wait_until)
882
1152
 
883
1153
  title = self.get_title()
884
1154
  if not title:
885
1155
  self.logger.error(
886
- "Cannot read page title after login - you may have the wrong 'wait until' strategy configured!",
1156
+ "Cannot read page title after login - you may have the wrong 'wait until' strategy configured! Strategy user -> '%s'.",
1157
+ wait_until,
887
1158
  )
888
1159
  return False
889
1160
 
890
-
891
1161
  if "Verify" in title:
892
1162
  self.logger.error("Site is asking for a Verification Token. You may need to whitelist your IP!")
893
1163
  return False
@@ -895,6 +1165,7 @@ class BrowserAutomation:
895
1165
  self.logger.error("Authentication failed. You may have given the wrong password!")
896
1166
  return False
897
1167
 
1168
+ self.logger.info("Login completed successfully! Page title -> '%s'", title)
898
1169
  self.logged_in = True
899
1170
 
900
1171
  return True
@@ -923,10 +1194,47 @@ class BrowserAutomation:
923
1194
  def end_session(self) -> None:
924
1195
  """End the browser session and close the browser."""
925
1196
 
926
- self.logger.debug("Ending browser automation session...")
1197
+ self.logger.info("Close Browser Page...")
1198
+ self.page.close()
1199
+ self.logger.info("Close Browser Context...")
927
1200
  self.context.close()
1201
+ self.logger.info("Close Browser...")
928
1202
  self.browser.close()
929
1203
  self.logged_in = False
1204
+ self.logger.info("Stop Playwright instance...")
930
1205
  self.playwright.stop()
1206
+ self.logger.info("Browser automation has ended.")
1207
+
1208
+ # end method definition
1209
+
1210
+ def __enter__(self) -> object:
1211
+ """Enable use with 'with' statement."""
1212
+
1213
+ return self
1214
+
1215
+ # end method definition
1216
+
1217
+ def __exit__(
1218
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback_obj: TracebackType | None
1219
+ ) -> None:
1220
+ """Handle cleanup when exiting a context manager block.
1221
+
1222
+ Ensures all browser-related resources are released. If an unhandled exception
1223
+ occurs within the context block, it will be logged before cleanup.
1224
+
1225
+ Args:
1226
+ exc_type (type[BaseException] | None): The class of the raised exception, if any.
1227
+ exc_value (BaseException | None): The exception instance raised, if any.
1228
+ traceback_obj (TracebackType | None): The traceback object associated with the exception, if any.
1229
+
1230
+ """
1231
+
1232
+ if exc_type is not None:
1233
+ self.logger.error(
1234
+ "Unhandled exception in browser automation context -> %s",
1235
+ "".join(traceback.format_exception(exc_type, exc_value, traceback_obj)),
1236
+ )
1237
+
1238
+ self.end_session()
931
1239
 
932
1240
  # end method definition