pyxecm 2.0.2__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
  )
@@ -83,6 +110,7 @@ class BrowserAutomation:
83
110
  headless: bool = True,
84
111
  logger: logging.Logger = default_logger,
85
112
  wait_until: str | None = None,
113
+ browser: str | None = None,
86
114
  ) -> None:
87
115
  """Initialize the object.
88
116
 
@@ -107,12 +135,15 @@ class BrowserAutomation:
107
135
  If True, the browser will be started in headless mode. Defaults to True.
108
136
  wait_until (str | None, optional):
109
137
  Wait until a certain condition. Options are:
110
- * "load" - Waits for the load event (after all resources like images/scripts load)
111
- * "networkidle" - Waits until there are no network connections for at least 500 ms.
112
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
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,
113
142
  but subresources may still load).
114
143
  logger (logging.Logger, optional):
115
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".
116
147
 
117
148
  """
118
149
 
@@ -135,9 +166,12 @@ class BrowserAutomation:
135
166
  self.logged_in = False
136
167
  self.download_directory = download_directory
137
168
 
169
+ # Screenshot configurations:
138
170
  self.take_screenshots = take_screenshots
139
171
  self.screenshot_names = automation_name
140
172
  self.screenshot_counter = 1
173
+ self.screenshot_full_page = True
174
+
141
175
  self.wait_until = wait_until if wait_until else DEFAULT_WAIT_UNTIL_STRATEGY
142
176
 
143
177
  self.screenshot_directory = os.path.join(
@@ -146,15 +180,110 @@ class BrowserAutomation:
146
180
  automation_name,
147
181
  "screenshots",
148
182
  )
183
+ self.logger.debug("Creating Screenshot directory... -> %s", self.screenshot_directory)
149
184
  if self.take_screenshots and not os.path.exists(self.screenshot_directory):
150
185
  os.makedirs(self.screenshot_directory)
151
186
 
187
+ self.logger.debug("Creating Playwright instance...")
152
188
  self.playwright = sync_playwright().start()
153
- 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...")
154
257
  self.context: BrowserContext = self.browser.new_context(
155
258
  accept_downloads=True,
156
259
  )
260
+
261
+ self.logger.info("Creating Page...")
157
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
158
287
 
159
288
  # end method definition
160
289
 
@@ -167,7 +296,7 @@ class BrowserAutomation:
167
296
 
168
297
  """
169
298
 
170
- screenshot_file = "{}/{}-{}.png".format(
299
+ screenshot_file = "{}/{}-{:02d}.png".format(
171
300
  self.screenshot_directory,
172
301
  self.screenshot_names,
173
302
  self.screenshot_counter,
@@ -175,7 +304,7 @@ class BrowserAutomation:
175
304
  self.logger.debug("Save browser screenshot to -> %s", screenshot_file)
176
305
 
177
306
  try:
178
- self.page.screenshot(path=screenshot_file)
307
+ self.page.screenshot(path=screenshot_file, full_page=self.screenshot_full_page)
179
308
  self.screenshot_counter += 1
180
309
  except Exception as e:
181
310
  self.logger.error("Failed to take screenshot; error -> %s", e)
@@ -193,12 +322,13 @@ class BrowserAutomation:
193
322
  URL to load. If empty just the base URL will be used.
194
323
  wait_until (str | None, optional):
195
324
  Wait until a certain condition. Options are:
196
- * "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)
197
327
  This is the safest strategy for pages that keep loading content in the background
198
328
  like Salesforce.
199
- * "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.
200
330
  This seems to be the safest one for OpenText Content Server.
201
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
331
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
202
332
  but subresources may still load).
203
333
 
204
334
  Returns:
@@ -215,7 +345,7 @@ class BrowserAutomation:
215
345
  page_url = self.base_url + url
216
346
 
217
347
  try:
218
- self.logger.debug("Load page -> %s", page_url)
348
+ self.logger.debug("Load page -> %s (wait until -> '%s')", page_url, wait_until)
219
349
 
220
350
  # The Playwright Response object is different from the requests.response object!
221
351
  response = self.page.goto(page_url, wait_until=wait_until)
@@ -259,39 +389,50 @@ class BrowserAutomation:
259
389
  Args:
260
390
  wait_until (str | None, optional):
261
391
  Wait until a certain condition. Options are:
262
- * "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)
263
394
  This is the safest strategy for pages that keep loading content in the background
264
395
  like Salesforce.
265
- * "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.
266
397
  This seems to be the safest one for OpenText Content Server.
267
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
398
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
268
399
  but subresources may still load).
269
400
 
270
401
  Returns:
271
402
  str:
272
- The title of the browser window.
403
+ The title of the browser page.
273
404
 
274
405
  """
275
406
 
276
- for _ in range(REQUEST_MAX_RETRIES):
407
+ for attempt in range(REQUEST_MAX_RETRIES):
277
408
  try:
278
- 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)
279
416
  except Exception as e:
280
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
+ )
281
421
  time.sleep(REQUEST_RETRY_DELAY)
282
- self.page.wait_for_load_state(state=wait_until, timeout=REQUEST_TIMEOUT)
283
- else:
284
- self.logger.error("Could not get page title; error -> %s", e)
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
285
426
 
286
427
  return None
287
428
 
288
429
  # end method definition
289
430
 
290
- def scroll_to_element(self, element: ElementHandle) -> None:
431
+ def scroll_to_element(self, element: Locator) -> None:
291
432
  """Scroll an element into view to make it clickable.
292
433
 
293
434
  Args:
294
- element (ElementHandle):
435
+ element (Locator):
295
436
  Web element that has been identified before.
296
437
 
297
438
  """
@@ -307,41 +448,28 @@ class BrowserAutomation:
307
448
 
308
449
  # end method definition
309
450
 
310
- def find_elem(
311
- self,
312
- selector: str,
313
- selector_type: str = "id",
314
- role_type: str | None = None,
315
- show_error: bool = True,
316
- ) -> ElementHandle | None:
317
- """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.
318
453
 
319
454
  Args:
320
455
  selector (str):
321
- The name of the page element or accessible name (for role).
322
- selector_type (str, optional):
456
+ The selector to find the element on the page.
457
+ selector_type (str):
323
458
  One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
324
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
325
467
  role_type (str | None, optional):
326
468
  ARIA role when using selector_type="role", e.g., "button", "textbox".
327
469
  If irrelevant then None should be passed for role_type.
328
- show_error (bool, optional):
329
- Show an error if not found or not visible.
330
-
331
- Returns:
332
- ElementHandle:
333
- The web element or None in case an error occured.
334
470
 
335
471
  """
336
472
 
337
- locator = None
338
- failure_message = "Cannot find page element with selector -> '{}' ({}){}".format(
339
- selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
340
- )
341
- success_message = "Found page element with selector -> '{}' ('{}'){}".format(
342
- selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
343
- )
344
-
345
473
  try:
346
474
  match selector_type:
347
475
  case "id":
@@ -355,7 +483,8 @@ class BrowserAutomation:
355
483
  case "css":
356
484
  locator = self.page.locator(selector)
357
485
  case "text":
358
- locator = self.page.get_by_text(selector)
486
+ # locator = self.page.get_by_text(selector)
487
+ locator = self.page.locator("text={}".format(selector))
359
488
  case "title":
360
489
  locator = self.page.get_by_title(selector)
361
490
  case "label":
@@ -373,23 +502,83 @@ class BrowserAutomation:
373
502
  self.logger.error("Unsupported selector type -> '%s'", selector_type)
374
503
  return None
375
504
 
376
- elem = locator.element_handle() if locator is not None else None
377
- if elem is None:
378
- if show_error:
379
- self.logger.error(failure_message)
380
- else:
381
- self.logger.warning(failure_message)
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)
382
556
  else:
383
- self.logger.debug(success_message)
557
+ self.logger.warning(failure_message)
558
+ return None
384
559
 
385
- 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:
386
573
  if show_error:
387
- self.logger.error("%s; error -> %s", failure_message, str(e))
574
+ self.logger.error("%s (timeout)", failure_message)
388
575
  else:
389
- self.logger.warning("%s; error -> %s", failure_message, str(e))
576
+ self.logger.warning("%s (timeout)", failure_message)
390
577
  return None
578
+ else:
579
+ self.logger.debug(success_message)
391
580
 
392
- return elem
581
+ return locator
393
582
 
394
583
  # end method definition
395
584
 
@@ -401,7 +590,10 @@ class BrowserAutomation:
401
590
  scroll_to_element: bool = True,
402
591
  desired_checkbox_state: bool | None = None,
403
592
  is_navigation_trigger: bool = False,
593
+ is_popup_trigger: bool = False,
594
+ is_page_close_trigger: bool = False,
404
595
  wait_until: str | None = None,
596
+ wait_time: float = 0.0,
405
597
  show_error: bool = True,
406
598
  ) -> bool:
407
599
  """Find a page element and click it.
@@ -422,15 +614,22 @@ class BrowserAutomation:
422
614
  If None then click it in any case.
423
615
  is_navigation_trigger (bool, optional):
424
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?
425
621
  wait_until (str | None, optional):
426
622
  Wait until a certain condition. Options are:
427
- * "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)
428
625
  This is the safest strategy for pages that keep loading content in the background
429
626
  like Salesforce.
430
- * "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.
431
628
  This seems to be the safest one for OpenText Content Server.
432
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
629
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
433
630
  but subresources may still load).
631
+ wait_time (float):
632
+ Time in seconds to wait for elements to appear.
434
633
  show_error (bool, optional):
435
634
  Show an error if the element is not found or not clickable.
436
635
 
@@ -441,11 +640,19 @@ class BrowserAutomation:
441
640
 
442
641
  """
443
642
 
643
+ success = True # Final return value
644
+
444
645
  # If no specific wait until strategy is provided in the
445
646
  # parameter, we take the one from the browser automation class:
446
647
  if wait_until is None:
447
648
  wait_until = self.wait_until
448
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
+
449
656
  if not selector:
450
657
  failure_message = "Missing element selector! Cannot find page element!"
451
658
  if show_error:
@@ -464,74 +671,74 @@ class BrowserAutomation:
464
671
  if scroll_to_element:
465
672
  self.scroll_to_element(elem)
466
673
 
467
- # Handle checkboxes
468
- is_checkbox = elem.get_attribute("type") == "checkbox"
469
- checkbox_state = None
470
-
471
- if is_checkbox and desired_checkbox_state is not None:
472
- checkbox_state = elem.is_checked()
473
- if checkbox_state == desired_checkbox_state:
474
- self.logger.debug(
475
- "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,
476
689
  )
477
- return True # No need to click
478
- else:
479
- self.logger.debug("Checkbox -> '%s' has state mismatch. Clicking to change state.", selector)
480
-
481
- if is_navigation_trigger:
482
- self.logger.info("Clicking on navigation-triggering element -> '%s'", selector)
483
- try:
484
690
  with self.page.expect_navigation(wait_until=wait_until):
485
691
  elem.click()
486
- except PlaywrightError as e:
487
- self.logger.error(
488
- "Navigation after clicking on element -> '%s' did not happen or failed; likely wrong parameter passed; error -> %s",
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)...",
489
705
  selector,
490
- str(e),
706
+ "selector type -> '{}'".format(selector_type),
707
+ ", role type -> '{}'".format(role_type) if role_type else "",
491
708
  )
492
- return False
493
- else:
494
- self.logger.info("Clicking on non-navigating element -> '%s'", selector)
495
- try:
496
709
  elem.click()
497
710
  time.sleep(1)
498
- except PlaywrightError as e:
499
- self.logger.error("Click failed -> %s", str(e))
500
- return False
501
-
502
- if is_checkbox and desired_checkbox_state is not None:
503
- elem = self.find_elem(selector=selector, selector_type=selector_type, show_error=show_error)
504
- if elem:
505
- checkbox_state = elem.is_checked()
506
-
507
- if checkbox_state is not None:
508
- if checkbox_state == desired_checkbox_state:
711
+ if success:
509
712
  self.logger.debug(
510
- "Successfully clicked checkbox element -> '%s'. It's state is now -> %s",
713
+ "Successfully clicked element -> '%s' (%s%s)",
511
714
  selector,
512
- checkbox_state,
715
+ "selector type -> '{}'".format(selector_type),
716
+ ", role type -> '{}'".format(role_type) if role_type else "",
513
717
  )
514
- else:
515
- self.logger.error(
516
- "Failed to flip checkbox element -> '%s' to desired state. It's state is still -> %s and not -> %s",
517
- selector,
518
- checkbox_state,
519
- desired_checkbox_state,
520
- )
521
- else:
522
- self.logger.debug("Successfully clicked element -> '%s'", selector)
523
-
524
- if self.take_screenshots:
525
- self.take_screenshot()
526
718
 
527
719
  except PlaywrightError as e:
528
720
  if show_error:
529
- 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
+ )
530
724
  else:
531
- self.logger.warning("Cannot click page element -> '%s'; warning -> %s", selector, str(e))
532
- 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
533
729
 
534
- 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
535
742
 
536
743
  # end method definition
537
744
 
@@ -568,6 +775,8 @@ class BrowserAutomation:
568
775
 
569
776
  """
570
777
 
778
+ success = False # Final return value
779
+
571
780
  elem = self.find_elem(selector=selector, selector_type=selector_type, role_type=role_type, show_error=True)
572
781
  if not elem:
573
782
  return False
@@ -582,12 +791,14 @@ class BrowserAutomation:
582
791
  else:
583
792
  self.logger.warning(message)
584
793
 
794
+ if self.take_screenshots:
795
+ self.take_screenshot()
796
+
585
797
  return False
586
798
 
587
- if not is_sensitive:
588
- self.logger.debug("Set element -> %s to value -> '%s'...", selector, value)
589
- else:
590
- 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
+ )
591
802
 
592
803
  try:
593
804
  # HTML '<select>' can only be identified based on its tag name:
@@ -596,27 +807,38 @@ class BrowserAutomation:
596
807
  input_type = elem.get_attribute("type")
597
808
 
598
809
  if tag_name == "select":
599
- options = elem.query_selector_all("option")
600
- 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
+
601
814
  if value not in option_values:
602
815
  self.logger.warning(
603
- "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!",
604
817
  value,
605
818
  option_values,
606
819
  )
607
- return False
608
- # We set the value over the (visible) label:
609
- 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
610
824
  elif tag_name == "input" and input_type == "checkbox":
611
825
  # Handle checkbox
612
826
  if not isinstance(value, bool):
613
827
  self.logger.error("Checkbox value must be a boolean!")
614
- return False
615
- is_checked = elem.is_checked()
616
- if value != is_checked:
617
- 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
618
839
  else:
619
840
  elem.fill(value)
841
+ success = True
620
842
  except PlaywrightError as e:
621
843
  message = "Cannot set page element selected by -> '{}' ({}) to value -> '{}'; error -> {}".format(
622
844
  selector, selector_type, value, str(e)
@@ -625,12 +847,12 @@ class BrowserAutomation:
625
847
  self.logger.error(message)
626
848
  else:
627
849
  self.logger.warning(message)
628
- return False
850
+ success = not show_error
629
851
 
630
852
  if self.take_screenshots:
631
853
  self.take_screenshot()
632
854
 
633
- return True
855
+ return success
634
856
 
635
857
  # end method definition
636
858
 
@@ -692,13 +914,14 @@ class BrowserAutomation:
692
914
  substring: bool = True,
693
915
  min_count: int = 1,
694
916
  wait_time: float = 0.0,
917
+ wait_state: str = "visible",
695
918
  show_error: bool = True,
696
919
  ) -> tuple[bool | None, int]:
697
920
  """Check if (multiple) elements with defined attributes exist on page and return the number.
698
921
 
699
922
  Args:
700
923
  selector (str):
701
- Base selector.
924
+ The selector to find the element on the page.
702
925
  selector_type (str):
703
926
  One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
704
927
  "label", "placeholder", "alt".
@@ -722,6 +945,9 @@ class BrowserAutomation:
722
945
  Minimum number of required matches (# elements on page).
723
946
  wait_time (float):
724
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).
725
951
  show_error (bool):
726
952
  Whether to log warnings/errors.
727
953
 
@@ -734,86 +960,126 @@ class BrowserAutomation:
734
960
 
735
961
  """
736
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
+
737
1018
  # Some operations that are done server-side and dynamically update
738
- # the page may require a waiting time:
1019
+ # the page with additional matching elements that may require a waiting time:
739
1020
  if wait_time > 0.0:
740
- 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)
741
1022
  self.page.wait_for_timeout(wait_time * 1000)
742
1023
 
743
- try:
744
- match selector_type:
745
- case "id":
746
- locator = self.page.locator("#{}".format(selector))
747
- case "name":
748
- locator = self.page.locator("[name='{}']".format(selector))
749
- case "class_name":
750
- locator = self.page.locator(".{}".format(selector))
751
- case "xpath":
752
- locator = self.page.locator("xpath={}".format(selector))
753
- case "css":
754
- locator = self.page.locator(selector)
755
- case "text":
756
- locator = self.page.get_by_text(selector)
757
- case "title":
758
- locator = self.page.get_by_title(selector)
759
- case "label":
760
- locator = self.page.get_by_label(selector)
761
- case "placeholder":
762
- locator = self.page.get_by_placeholder(selector)
763
- case "alt":
764
- locator = self.page.get_by_alt_text(selector)
765
- case "role":
766
- if not role_type:
767
- self.logger.error("Role type must be specified when using find method 'role'!")
768
- return (None, 0)
769
- locator = self.page.get_by_role(role=role_type, name=selector)
770
- case _:
771
- self.logger.error("Unsupported selector type -> '%s'", selector_type)
772
- return (None, 0)
1024
+ matching_elems = []
773
1025
 
774
- 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)
775
1031
 
776
- count = locator.count() if locator is not None else 0
777
- if count == 0:
778
- if show_error:
779
- self.logger.error("No elements found using selector -> '%s' ('%s')", selector, selector_type)
780
- return (None, 0)
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
+ )
781
1039
 
782
- for i in range(count):
783
- elem_handle = locator.nth(i).element_handle()
784
- if not elem_handle:
785
- 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
+ )
786
1047
 
787
- if value is None:
788
- # No filtering, accept all elements
789
- matching_elems.append(elem_handle)
790
- continue
1048
+ for i in range(count):
1049
+ elem = locator.nth(i)
1050
+ if not elem:
1051
+ continue
791
1052
 
792
- # Get attribute or text content
793
- 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
794
1057
 
795
- if not attr_value:
796
- continue
1058
+ # Get attribute or text content
1059
+ attr_value = elem.get_attribute(attribute) if attribute else elem.text_content()
797
1060
 
798
- if (substring and value in attr_value) or (not substring and value == attr_value):
799
- matching_elems.append(elem_handle)
1061
+ if not attr_value:
1062
+ continue
800
1063
 
801
- 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)
802
1066
 
803
- if matching_elements_count < min_count and show_error:
804
- self.logger.warning(
805
- "%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",
806
1076
  "Only {}".format(matching_elements_count) if matching_elems else "No",
1077
+ "s" if matching_elements_count > 1 else "",
807
1078
  min_count,
808
1079
  )
809
- return (False, matching_elements_count)
1080
+ self.logger.info("Found %d matching elements.", matching_elements_count)
810
1081
 
811
- except PlaywrightError as e:
812
- if show_error:
813
- self.logger.error("Failed to check if elements -> '%s' exist; errors -> %s", selector, str(e))
814
- return (None, 0)
815
-
816
- return (True, matching_elements_count)
1082
+ return (success, matching_elements_count)
817
1083
 
818
1084
  # end method definition
819
1085
 
@@ -839,12 +1105,13 @@ class BrowserAutomation:
839
1105
  The URL to the login page. Defaults to "".
840
1106
  wait_until (str | None, optional):
841
1107
  Wait until a certain condition. Options are:
842
- * "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)
843
1110
  This is the safest strategy for pages that keep loading content in the background
844
1111
  like Salesforce.
845
- * "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.
846
1113
  This seems to be the safest one for OpenText Content Server.
847
- * "domcontentloaded" - Waits for the DOMContentLoaded event (HTML is parsed,
1114
+ * "domcontentloaded" - waits for the DOMContentLoaded event (HTML is parsed,
848
1115
  but subresources may still load).
849
1116
  selector_type (str, optional):
850
1117
  One of "id", "name", "class_name", "xpath", "css", "role", "text", "title",
@@ -880,12 +1147,14 @@ class BrowserAutomation:
880
1147
  )
881
1148
  return False
882
1149
 
1150
+ self.logger.info("Wait for -> '%s' to assure login is completed and target page is loaded.", wait_until)
883
1151
  self.page.wait_for_load_state(wait_until)
884
1152
 
885
1153
  title = self.get_title()
886
1154
  if not title:
887
1155
  self.logger.error(
888
- "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,
889
1158
  )
890
1159
  return False
891
1160
 
@@ -896,6 +1165,7 @@ class BrowserAutomation:
896
1165
  self.logger.error("Authentication failed. You may have given the wrong password!")
897
1166
  return False
898
1167
 
1168
+ self.logger.info("Login completed successfully! Page title -> '%s'", title)
899
1169
  self.logged_in = True
900
1170
 
901
1171
  return True
@@ -924,10 +1194,47 @@ class BrowserAutomation:
924
1194
  def end_session(self) -> None:
925
1195
  """End the browser session and close the browser."""
926
1196
 
927
- 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...")
928
1200
  self.context.close()
1201
+ self.logger.info("Close Browser...")
929
1202
  self.browser.close()
930
1203
  self.logged_in = False
1204
+ self.logger.info("Stop Playwright instance...")
931
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()
932
1239
 
933
1240
  # end method definition