#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import datetime import getpass import json import logging import os import random import re import subprocess import sys import tempfile import time import tkinter as tk from pathlib import Path from tkinter import filedialog from pykeepass import PyKeePass from randomwordfr import RandomWordFr from selenium import webdriver from selenium.common.exceptions import ( ElementClickInterceptedException, TimeoutException, ) from selenium.webdriver import ActionChains from selenium.webdriver.common.actions.wheel_input import ScrollOrigin from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.firefox.options import Options from selenium.webdriver.firefox.service import Service from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select, WebDriverWait new_path = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "..") ) sys.path.append(new_path) from script.config import config_file logging.basicConfig( format=( "%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d]" " %(message)s" ), datefmt="%Y-%m-%d:%H:%M:%S", level=logging.INFO, ) _logger = logging.getLogger(__name__) MONTHS_FR = { "janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12, } class SeleniumLib(object): def __init__(self, config): self.config = config self.wait_human_time = config.human_time_speed self.config_file = config_file.ConfigFile() self.kdbx = None self.video_recorder = None self.default_copy_download_dir_path = None self.defaut_timeout = 60 date_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") tmp_dir = tempfile.mkdtemp( prefix=f"selenium_download_{date_str}_", # dir="/tmp/test/", ) self.default_download_dir_path = tmp_dir if self.config.video_suffix: self.filename_recording = ( f"video_{self.config.video_suffix}_" + time.strftime("%Y_%m_%d-%H_%M_%S") + ".webm" ) else: self.filename_recording = ( "video_" + time.strftime("%Y_%m_%d-%H_%M_%S") + ".webm" ) dir_path_screencast = os.path.join(".", "screencasts") self.filename_recording = os.path.join( dir_path_screencast, self.filename_recording ) self.dirname_recording = dir_path_screencast self.driver = None if os.path.isfile(".odoo-version"): with open(".odoo-version") as txt: self.odoo_version = txt.read() else: # Default version # TODO instead, need to detect the version from client and detection self.odoo_version = "18.0" def do_screenshot(self): if self.config.scenario_screenshot: dir_path = os.path.join(".", "screenshots") if not os.path.isdir(dir_path): os.mkdir(dir_path) file_path = os.path.join( dir_path, f"{self.config.scenario}_{str(int(time.time() * 10000))}.png", ) self.driver.save_screenshot(file_path) def configure(self, ignore_open_web=False): # Configuration pour lancer Firefox en mode de navigation privée firefox_options = webdriver.FirefoxOptions() if not self.config.not_private_mode: firefox_options.add_argument("--private") if self.config.headless: firefox_options.add_argument("--headless") # firefox_options.set_preference("browser.link.open_newwindow", 3) # firefox_options.set_preference("browser.link.open_newwindow.restriction", 2) # Définissez la préférence pour les notifications de bureau # 1 signifie "autoriser", 2 signifie "bloquer" firefox_options.set_preference( "permissions.default.desktop-notification", 1 ) firefox_options.set_preference("browser.download.folderList", 2) firefox_options.set_preference("browser.download.useDownloadDir", True) firefox_options.set_preference( "browser.download.manager.showWhenStarting", False ) if self.default_download_dir_path: firefox_options.set_preference( "browser.download.dir", self.default_download_dir_path ) firefox_options.set_preference( "browser.helperApps.neverAsk.saveToDisk", "application/pdf," "application/x-pdf," "application/vnd.ms-excel," "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet," "text/csv," "application/octet-stream", ) # disable pdf viewer from firefox firefox_options.set_preference("pdfjs.disabled", True) firefox_services = None if self.config.firefox_binary_path: firefox_services = Service( executable_path=self.config.firefox_binary_path ) if self.config.gecko_binary_path: firefox_options.binary_location = self.config.gecko_binary_path # Créez une instance du navigateur Firefox avec les options de navigation privée try: user = os.environ.get("USER", "Non défini") uid = os.getuid() gid = os.getgid() print(f"INFO: Execute by user: {user}, UID: {uid}, GID: {gid}") # Exécuter la commande 'whoami' pour confirmation whoami_result = subprocess.run( ["whoami"], capture_output=True, text=True ) print(f"INFO: Result 'whoami': {whoami_result.stdout.strip()}") except Exception as e: print(f"ERROR when check user: {e}") driver_url = f"{self.config.use_network}/wd/hub" try: if self.config.use_chrome_driver: from selenium.webdriver.chrome.options import ( Options as ChromeOptions, ) from selenium.webdriver.chrome.service import Service chrome_options = ChromeOptions() if self.config.window_size: # chrome_options.add_argument("--window-size=1920,1080") chrome_options.add_argument( f"--window-size={self.config.window_size}" ) if self.config.use_network: chrome_options.set_capability( "se:name", "ERPLibre selenium" ) chrome_options.enable_downloads = True self.driver = webdriver.Remote( options=chrome_options, command_executor=driver_url, ) else: from webdriver_manager.chrome import ChromeDriverManager service = Service( ChromeDriverManager().install(), log_path="/tmp/chromedriver.log", ) chrome_options.enable_downloads = True self.driver = webdriver.Chrome( options=chrome_options, # options=firefox_options, service=service, keep_alive=True, ) elif self.config.use_firefox_driver: from selenium.webdriver.firefox.options import Options from selenium.webdriver.firefox.service import Service firefox_services = None # shutil.which("geckodriver") if self.config.gecko_binary_path: firefox_services = Service( executable_path=self.config.gecko_binary_path, log_output=self.config.debug, ) firefox_options = webdriver.FirefoxOptions() if not self.config.not_private_mode: firefox_options.add_argument("--private") if self.config.firefox_binary_path: firefox_options.binary_location = ( self.config.firefox_binary_path ) elif not self.config.use_network: # test snap firefox for Ubuntu first snap_firefox_path = ( "/snap/firefox/current/usr/lib/firefox/firefox" ) firefox_path_exist = Path(snap_firefox_path).exists() if firefox_path_exist: firefox_options.binary_location = snap_firefox_path else: status_location = subprocess.check_output( ["which", "firefox"], text=True ).strip() firefox_options.binary_location = status_location firefox_profile = webdriver.FirefoxProfile() firefox_profile.set_preference( "browser.cache.disk.enable", False ) firefox_profile.set_preference( "browser.cache.memory.enable", False ) firefox_profile.set_preference( "browser.cache.offline.enable", False ) firefox_profile.set_preference("network.http.use-cache", False) firefox_options.set_preference( "permissions.default.desktop-notification", 1 ) firefox_options.set_preference( "browser.download.folderList", 2 ) firefox_options.set_preference( "browser.download.manager.showWhenStarting", False ) if self.default_download_dir_path: firefox_options.set_preference( "browser.download.dir", self.default_download_dir_path ) firefox_options.set_preference( "browser.helperApps.neverAsk.saveToDisk", "application/octet-stream,application/pdf,application/x-pdf", ) firefox_options.set_preference("pdfjs.disabled", True) if self.config.window_size: # chrome_options.add_argument("--window-size=1920,1080") # firefox_options.add_argument( # f"--window-size={self.config.window_size}" # ) width, height = self.config.window_size.split(",") firefox_options.add_argument("--width=" + str(width)) firefox_options.add_argument("--height=" + str(height)) if self.config.headless: firefox_options.add_argument("--headless") # firefox_options.add_argument("--disable-gpu") firefox_options.add_argument("--no-sandbox") firefox_options.add_argument("--disable-dev-shm-usage") firefox_options.add_argument("--disable-dbus") if self.config.use_network: firefox_options.set_capability( "se:name", "ERPLibre selenium" ) firefox_options.enable_downloads = True self.driver = webdriver.Remote( options=firefox_options, command_executor=driver_url, ) else: firefox_options.add_argument("-remote-allow-system-access") self.driver = webdriver.Firefox( options=firefox_options, service=firefox_services, keep_alive=True, ) except Exception as e: print( "Cannot open Firefox profile, so will force firefox snap for" " Ubuntu users." ) # TODO au lieu, faire une recherche de geckodriver avant de démarrer # firefox_services = Service( # executable_path=getoutput( # "find /snap/firefox -name geckodriver" # ).split("\n")[-1], log_path='/tmp/geckodriver.log' # ) # firefox_options.binary_location = getoutput( # "find /snap/firefox -name firefox" # ).split("\n")[-1] # self.driver = webdriver.Firefox( # options=firefox_options, service=firefox_services # ) raise e print("The driver is launched!") # Ajout de l'enregistrement if self.config.record_mode: # Create recording from script.selenium.selenium_video import VideoRecorder if not os.path.isdir(self.dirname_recording): os.mkdir(self.dirname_recording) self.video_recorder = VideoRecorder( self.driver, filename=self.filename_recording ) # import vlc # Install DarkReader to help my eyes # TODO do a script to check if it's the last version if self.config.use_firefox_driver and not self.config.use_network: if not self.config.no_dark_mode: self.driver.install_addon( os.path.join( new_path, "script/selenium/darkreader-firefox.xpi" ), temporary=True, ) self.driver.install_addon( os.path.join( new_path, "script/selenium/odoo_debug-4.0.xpi" ), temporary=True, ) if ( not self.config.not_private_mode and not self.config.no_dark_mode ): # self.driver.set_context("chrome") self.driver.get("about:addons") # Enable Dark Reader into incognito # Click Extensions self.click('//button[@viewid="addons://list/extension"]') self.click( "/html/body/div/div[2]/div/addon-list/section[1]/addon-card/div/div/div/div/button", ) self.click( "/html/body/div/div[2]/div/addon-list/section[1]/addon-card/div/addon-options/panel-list/panel-item[5]", ) # Enable Run in Private Windows try: self.click( "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[6]/div/label[1]/input", ) except Exception: # For old version before Firefox 138 self.click( "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[5]/div/label[1]/input", ) # Enable Odoo_debug into incognito # Back self.click( "/html/body/div/div[2]/addon-page-header/div/div[2]/button" ) self.click( "/html/body/div/div[2]/div/addon-list/section[1]/addon-card[2]/div/div/div/div/button", ) self.click( "/html/body/div/div[2]/div/addon-list/section[1]/addon-card[2]/div/addon-options/panel-list/panel-item[5]", ) # Enable Run in Private Windows try: self.click( "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[6]/div/label[1]/input", ) except Exception: # For old version before Firefox 138 self.click( "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[5]/div/label[1]/input", ) # Ouvrez la page web if not ignore_open_web: self.driver.get(f"{self.config.url}/web") # Close tab opening by DarkReader if self.config.use_firefox_driver and not self.config.use_network: if self.config.not_private_mode and not self.config.no_dark_mode: self.driver.switch_to.window(self.driver.window_handles[-1]) self.driver.close() self.driver.switch_to.window(self.driver.window_handles[0]) def get_kdbx(self): if self.kdbx: return self.kdbx print("Open KDBX") # Open file chemin_fichier_kdbx = self.config_file.get_config_value( ["kdbx", "path"] ) if not chemin_fichier_kdbx: root = tk.Tk() root.withdraw() # Hide the main window chemin_fichier_kdbx = filedialog.askopenfilename( title="Select a File", filetypes=(("KeepassX files", "*.kdbx"),), ) if not chemin_fichier_kdbx: # _logger.error(f"KDBX is not configured, please fill {CONFIG_FILE}") return mot_de_passe_kdbx = self.config_file.get_config_value( ["kdbx", "password"] ) if not mot_de_passe_kdbx: mot_de_passe_kdbx = getpass.getpass( prompt="Entrez votre mot de passe : " ) kp = PyKeePass(chemin_fichier_kdbx, password=mot_de_passe_kdbx) if kp: self.kdbx = kp return kp @staticmethod def get_french_word_no_space_no_accent(): word = "" rw = RandomWordFr() while not word or " " in word: word = rw.get().get("word") if not re.match("^[a-zA-Z_]+$", word): word = "" return word def click(self, xpath, timeout=None): if timeout is None: timeout = self.defaut_timeout wait = WebDriverWait(self.driver, timeout) button = wait.until( EC.element_to_be_clickable( ( By.XPATH, xpath, ) ) ) button.click() return button def get_elements(self, by: str = By.ID, value: str = None, timeout=None): if timeout is None: timeout = self.defaut_timeout wait = WebDriverWait(self.driver, timeout) elements = wait.until( EC.visibility_of_any_elements_located( ( by, value, ) ) ) return elements def get_element( self, by: str = By.ID, value: str = None, timeout=None, wait_clickable=False, wait_is_invisible=False, is_visible=True, ): if timeout is None: timeout = self.defaut_timeout only_one = False wait = WebDriverWait(self.driver, timeout) if wait_clickable: ele = wait.until( EC.element_to_be_clickable( ( by, value, ) ) ) only_one = True elif is_visible: ele = wait.until( EC.visibility_of_any_elements_located( ( by, value, ) ) ) elif wait_is_invisible: ele = wait.until( EC.invisibility_of_element_located( ( by, value, ) ) ) else: only_one = True ele = wait.until( EC.presence_of_element_located( ( by, value, ) ) ) if only_one: return ele return ele[0] def get_element_not_visible( self, by: str = By.ID, value: str = None, timeout=None ): if timeout is None: timeout = self.defaut_timeout wait = WebDriverWait(self.driver, timeout) ele = wait.until( EC.presence_of_element_located( ( by, value, ) ) ) return ele def get_elements_not_visible( self, by: str = By.ID, value: str = None, timeout=None ): if timeout is None: timeout = self.defaut_timeout wait = WebDriverWait(self.driver, timeout) ele = wait.until( EC.presence_of_all_elements_located( ( by, value, ) ) ) return ele def get_text_from_element( self, by: str = By.ID, value: str = None, timeout: int = None ): if timeout is None: timeout = self.defaut_timeout ele = self.get_element(by, value, timeout=timeout) return ele.text def get_all_element( self, by: str = By.ID, value: str = None, timeout=None ): if timeout is None: timeout = self.defaut_timeout wait = WebDriverWait(self.driver, timeout) ele = wait.until( EC.visibility_of_all_elements_located( ( by, value, ) ) ) return ele def wait_new_element_and_click( self, by: str = By.ID, value: str = None, delay_wait=1, timeout=None, inject_cursor=False, ): if timeout is None: timeout = self.defaut_timeout new_element = self.wait_new_element( by=by, value=value, delay_wait=delay_wait, timeout=timeout ) if new_element: if inject_cursor: self.inject_cursor() self.click_with_mouse_move(element=new_element, no_scroll=True) return new_element def wait_new_element( self, by: str = By.ID, value: str = None, delay_wait=1, timeout=None, timeout_wait=None, ): if timeout is None: timeout = self.defaut_timeout element = None # TODO support timeout_wait while element is None: time.sleep(delay_wait) try: element = self.get_element(by, value, timeout) except TimeoutException as e: pass print(f"Waiting {delay_wait} seconds after value '{value}'") return element def wait_add_new_element_and_click( self, by: str = By.ID, value: str = None, delay_wait=1, timeout=None, ): if timeout is None: timeout = self.defaut_timeout new_element = self.wait_add_new_element( by=by, value=value, delay_wait=delay_wait, timeout=timeout ) self.click_with_mouse_move(element=new_element, no_scroll=True) return new_element def wait_add_new_element( self, by: str = By.ID, value: str = None, delay_wait=1, timeout=None, ): if timeout is None: timeout = self.defaut_timeout all_element_init = self.get_all_element(by=by, value=value) len_all_element_init = len(all_element_init) len_all_element = len_all_element_init all_element = [] while len_all_element_init == len_all_element: time.sleep(delay_wait) all_element = self.get_all_element(by=by, value=value) len_all_element = len(all_element) print(f"Waiting {delay_wait} seconds after value '{value}'") new_element = all_element[-1] return new_element def wait_increment_number_text_and_click( self, by: str = By.ID, value: str = None, delay_wait=1, timeout=None, ): if timeout is None: timeout = self.defaut_timeout element_init = self.get_element(by=by, value=value, timeout=timeout) actual_number = int(element_init.text) number_goal = actual_number + 1 while actual_number < number_goal: time.sleep(delay_wait) element_goal = self.get_element( by=by, value=value, timeout=timeout ) actual_number = int(element_goal.text) print(f"Waiting {delay_wait} seconds after value '{value}'") self.click_with_mouse_move(element=element_init, no_scroll=True) return element_init def click_with_mouse_move( self, by: str = By.ID, value: str = None, timeout: int = None, no_scroll: bool = False, viewport_ele_by: str = By.ID, viewport_ele_value: str = None, element: WebElement = None, with_index: bool = False, is_visible=True, index_of_list: int = 0, support_multiple_click: bool = False, ): if timeout is None: timeout = self.defaut_timeout # Exemple Detect button by text value # Replace CLASS_NAME # By.CSS_SELECTOR, "input.CLASS_NAME[value='Fermer']" # ele = self.driver.find_element(by, value) if element: ele = element else: if support_multiple_click: ele = self.get_elements(by, value, timeout) else: ele = self.get_element( by, value, timeout, is_visible=is_visible ) if not no_scroll: viewport_ele = None if viewport_ele_value: viewport_ele = self.get_element( viewport_ele_by, viewport_ele_value, timeout ) if support_multiple_click: for one_ele in ele: self.scrollto_element(one_ele, viewport_ele=viewport_ele) # ActionChains(self.driver).move_to_element(one_ele).click().perform() ActionChains(self.driver).move_to_element( one_ele ).perform() else: self.scrollto_element(ele, viewport_ele=viewport_ele) # ActionChains(self.driver).move_to_element(ele).click().perform() ActionChains(self.driver).move_to_element(ele).perform() time.sleep(self.config.selenium_default_delay) wait = WebDriverWait(self.driver, timeout) if element: button = wait.until(EC.element_to_be_clickable(element)) else: if with_index: buttons = wait.until( EC.presence_of_all_elements_located( ( by, value, ) ) ) button = buttons[index_of_list] else: if support_multiple_click: button = wait.until( # EC.visibility_of_all_elements_located( EC.presence_of_all_elements_located( ( by, value, ) ) ) else: button = wait.until( EC.element_to_be_clickable( ( by, value, ) ) ) try: if support_multiple_click: for btn in button: try: btn.click() # This button work! return btn except Exception as e: pass else: button.click() except ElementClickInterceptedException as e: try: print(e) if support_multiple_click: for btn in button: self.driver.execute_script( "arguments[0].scrollIntoView(true);", btn ) btn.click() else: self.driver.execute_script( "arguments[0].scrollIntoView(true);", button ) button.click() except ElementClickInterceptedException as e: print(e) if support_multiple_click: for btn in button: self.driver.execute_script( "arguments[0].click();", btn ) else: self.driver.execute_script("arguments[0].click();", button) return button def click_canvas_form( self, by: str = By.ID, value: str = None, timeout: int = None, form="carre", ): if timeout is None: timeout = self.defaut_timeout # ele = self.driver.find_element(by, value) ele = self.get_element(by, value, timeout) actions = ActionChains(self.driver) actions.move_to_element_with_offset(ele, -50, -50) actions.click_and_hold() if form == "carre": actions.move_by_offset(50, 0) actions.move_by_offset(0, 50) actions.move_by_offset(-50, 0) actions.move_by_offset(0, -50) actions.release() actions.perform() def input_text_with_mouse_move( self, by: str = By.ID, value: str = None, text_value: str = "", delay_after: int = -1, timeout: int = None, no_scroll: bool = False, viewport_ele_by: str = By.ID, viewport_ele_value: str = None, clear_before: bool = False, click_before: bool = False, ): if timeout is None: timeout = self.defaut_timeout # ele = self.driver.find_element(by, value) ele = self.get_element(by, value) if not no_scroll: viewport_ele = None if viewport_ele_value: viewport_ele = self.get_element( viewport_ele_by, viewport_ele_value, timeout ) self.scrollto_element(ele, viewport_ele=viewport_ele) ActionChains(self.driver).move_to_element(ele).perform() # Write content if click_before: # Need this to update a datetimepicker ele.click() time.sleep(0.3) if clear_before: ele.clear() if click_before: # Need this to update a datetimepicker ele.click() time.sleep(0.3) ele.send_keys(text_value) if delay_after > 0: time.sleep(delay_after) elif self.config.selenium_default_delay > 0: time.sleep(self.config.selenium_default_delay) def scrollto_element1(self, element_name): # ele = self.driver.find_element(By.TAG_NAME, element_name) ele = self.get_element(By.TAG_NAME, element_name) # delta_y = ele.rect["y"] ActionChains(self.driver).scroll_to_element(ele).perform() # ele = self.get_element(By.TAG_NAME, element_name) # delta_y = ele.rect["y"] # ActionChains(self.driver).scroll_by_amount(0, delta_y).perform() time.sleep(0.5) def scrollto_xpath5(self, xpath): footer = self.driver.find_element(By.XPATH, xpath) # footer = self.get_element(By.XPATH, xpath) delta_y = footer.rect["y"] ActionChains(self.driver).scroll_by_amount(0, int(delta_y)).perform() time.sleep(0.5) def scrollto_xpath2(self, xpath): footer = self.driver.find_element(By.XPATH, xpath) # footer = self.get_element(By.XPATH, xpath) scroll_origin = ScrollOrigin.from_element(footer) ActionChains(self.driver).scroll_from_origin( scroll_origin, 0, 100 ).perform() time.sleep(0.5) def scrollto_xpath3(self, xpath): footer = self.driver.find_element(By.XPATH, xpath) # footer = self.get_element(By.XPATH, xpath) scroll_origin = ScrollOrigin.from_element(footer) ActionChains(self.driver).scroll_to_element(scroll_origin).perform() time.sleep(0.5) def scrollto_xpath( self, xpath, offset_middle=True, smooth_scroll=True, smooth_speed=50, smooth_fps=1 / 25, ): footer = self.driver.find_element(By.XPATH, xpath) # footer = self.get_element(By.XPATH, xpath) scroll_origin = ScrollOrigin.from_viewport(10, 10) delta_y = footer.rect["y"] if offset_middle: # TODO check size windows and divide by 2 delta_y -= 400 print(f"scroll to xpath at y:{delta_y} position") if smooth_scroll: actual_pos = 0 while delta_y > actual_pos: actual_pos += smooth_speed print(actual_pos) ActionChains(self.driver).scroll_from_origin( scroll_origin, 0, int(smooth_speed) ).perform() print(f"Wait {smooth_fps} seconds") time.sleep(smooth_fps) print("end") else: ActionChains(self.driver).scroll_from_origin( scroll_origin, 0, int(delta_y) ).perform() time.sleep(0.5) def scrollto_element( self, element, smooth_scroll=True, smooth_speed=50, smooth_fps=1 / 25, viewport_ele=None, ): window_y = self.driver.execute_script( "return window.scrollY || window.pageYOffset;" ) window_height = self.driver.get_window_size().get("height") element_height = element.rect["height"] viewport_height = self.driver.execute_script( "return window.innerHeight || document.documentElement.clientHeight;" ) if viewport_ele: scroll_origin = ScrollOrigin.from_element(viewport_ele, 10, 10) else: scroll_origin = ScrollOrigin.from_viewport(10, 10) delta_y = element.rect["y"] target_distance = ( delta_y - (viewport_height - element_height) / 2 - window_y ) if window_y < delta_y < window_y + viewport_height - 50: # No need to scroll return print(f"scroll to xpath at y:{delta_y} position") if smooth_scroll: actual_pos = 0 # Detect go top or bot if delta_y > window_y: step = abs(int(smooth_speed)) # to the top condition = lambda a, d: a < d go_down = True else: step = -abs(int(smooth_speed)) # to the bot condition = lambda a, d: a > d go_down = False while condition(actual_pos, target_distance): # TODO detect when cannot scroll, stop scrolling actual_pos += step if go_down and actual_pos > target_distance: step = actual_pos - target_distance elif not go_down and actual_pos < target_distance: step = actual_pos - target_distance ActionChains(self.driver).scroll_from_origin( scroll_origin, 0, int(step) ).perform() print(f"Wait {smooth_fps} seconds") time.sleep(smooth_fps) print("end") else: ActionChains(self.driver).scroll_from_origin( scroll_origin, 0, int(target_distance) ).perform() # action = ActionChains(self.driver) # action.click_and_hold(element).move_by_offset(0, 1).perform() # # Don't forget to release the click_and_hold, for example with: # action.reset_actions() time.sleep(0.5) def inject_cursor(self): cursor_script = """ var cursor = document.createElement('div'); cursor.style.position = 'absolute'; cursor.style.zIndex = '9999'; cursor.style.width = '30px'; cursor.style.height = '30px'; cursor.style.borderRadius = '50%'; cursor.style.backgroundColor = 'red'; cursor.style.pointerEvents = 'none'; document.body.appendChild(cursor); document.addEventListener('mousemove', function(e) { cursor.style.left = e.pageX - 5 + 'px'; cursor.style.top = e.pageY - 5 + 'px'; }); """ try: try: self.driver.execute_script(cursor_script) except Exception as e: time.sleep(1) print("Error execute script cursor, wait 1 second") self.driver.execute_script(cursor_script) except Exception as e: time.sleep(3) print("Error execute script cursor, wait 3 second") self.driver.execute_script(cursor_script) def scenario_create_new_account_with_random_user( self, show_cursor=False, def_action_before_submit=None ): # Click no account if show_cursor: self.inject_cursor() self.click_with_mouse_move( By.XPATH, "/html/body/div[1]/main/div/form/div[3]/div[1]/a[1]" ) # Trouvez les éléments du formulaire if show_cursor: self.inject_cursor() # Remplissez le courriel et le mot de passe first_name = self.get_french_word_no_space_no_accent() password = first_name.lower() second_name = self.get_french_word_no_space_no_accent() full_name = f"{first_name} {second_name}" domain = self.get_french_word_no_space_no_accent().lower() self.input_text_with_mouse_move( By.NAME, "login", f"{password}@{domain}.com" ) self.input_text_with_mouse_move(By.NAME, "name", full_name) self.input_text_with_mouse_move(By.NAME, "password", password) self.input_text_with_mouse_move(By.NAME, "confirm_password", password) if def_action_before_submit: def_action_before_submit() self.click_with_mouse_move( By.XPATH, "/html/body/div[1]/main/div/form/div[6]/button" ) def open_tab(self, url): # Open a new window self.driver.execute_script("window.open('');") # Switch to the new window and open new URL self.driver.switch_to.window(self.driver.window_handles[1]) self.driver.get(url) def switch_tab(self, index): # Switch to the new window and open new URL self.driver.switch_to.window(self.driver.window_handles[index]) def refresh(self, wait_ready=False): self.driver.refresh() if wait_ready: WebDriverWait(self.driver, 15).until( lambda d: d.execute_script("return document.readyState") == "complete" ) def check_bot_chat_and_close(self): try: xpath_button = "/html/body/div[3]/div[1]/span[2]/a[2]" button = self.driver.find_element(By.XPATH, xpath_button) # button = self.get_element(By.XPATH, xpath_button) self.click(self.driver, xpath_button) except Exception: print("Chatbot cannot be found, stop searching it.") # Website def odoo_show_robot_message( self, msg="Ho no!", hide_message_after_x_seconds=0, robot_base64=None ): # Injecter l'animation SVG dans la page script_text = "" if msg: msg_to_print = msg.replace("'", "\\'") lst_mst_to_print = msg_to_print.split("\n") script_text = "" for index, line in enumerate(lst_mst_to_print): script_text += f""" // Ajouter le texte const textElement_{index} = document.createElement('div'); textElement_{index}.textContent = '{line}'; textElement_{index}.style.color = '#FFFFFF'; textElement_{index}.style.fontSize = '24px'; textElement_{index}.style.marginBottom = '20px'; textElement_{index}.style.fontWeight = 'bold'; overlay.appendChild(textElement_{index}); """ if robot_base64: script_robot = "" script_add_robot = f""" const img = new Image(); img.alt = 'Robot'; img.className = 'cute-robot'; img.style.width = '150px'; img.style.cursor = 'pointer'; img.src = '{robot_base64}'; overlay.appendChild(img); """ else: script_robot = """ // Contenu SVG du robot const svgContent = ` `; """ script_add_robot = """ // Ajouter le contenu SVG au div overlay.innerHTML += svgContent; """ script = ( """ (function() { // Créer un div pour l'overlay const overlay = document.createElement('div'); overlay.id = 'uniqueOverlay'; // Ajout de l'ID overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; overlay.style.display = 'flex'; overlay.style.justifyContent = 'center'; overlay.style.alignItems = 'center'; overlay.style.zIndex = '9999'; """ + script_robot + script_text + script_add_robot + """ // Ajouter un gestionnaire de clic pour supprimer l'overlay overlay.onclick = function() { overlay.style.display = 'none'; }; // Ajouter l'overlay au corps du document document.body.appendChild(overlay); })(); """ ) # Exécuter le script pour injecter l'animation self.driver.execute_script(script) if hide_message_after_x_seconds: time.sleep(hide_message_after_x_seconds) delete_script = """(function() { // Sélectionner l'overlay par ses attributs de style uniques const overlay = document.getElementById('uniqueOverlay'); // Vérifier si l'overlay existe et le supprimer if (overlay) { overlay.remove(); } })();""" self.driver.execute_script(delete_script) print(f"Script inject robot svg with msg '{msg}'") def odoo_website_menu_click(self, from_text=""): if not from_text: raise Exception( "Cannot click, empty parameter from method" " odoo_website_menu_click" ) button = self.click_with_mouse_move( By.LINK_TEXT, from_text, timeout=self.defaut_timeout ) return button # Web def odoo_fill_widget_char(self, field_name, text): # Type input text_field = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.NAME, field_name)) ) text_field.send_keys(text) return text_field def odoo_web_click_principal_menu(self): # Click menu button if self.odoo_version in ["16.0", "17.0", "18.0"]: button = self.click_with_mouse_move( By.XPATH, "//button[contains(@class, 'dropdown-toggle') and" " .//i[contains(@class, 'oi-apps')]]", ) elif self.odoo_version == "14.0": button = self.click_with_mouse_move( By.CSS_SELECTOR, "a.full[data-toggle='dropdown']" ) return button def odoo_web_click_item_list(self, index=0, field_name="name"): # Click item list all_row = self.get_all_element( by=By.XPATH, value=f"//tr[contains(@class, 'o_data_row')]/td[contains(@name, '{field_name}')]", ) row = all_row[index] if index < len(all_row) else all_row[-1] button = self.click_with_mouse_move(element=row) return button def odoo_web_click_open_search(self): # Click search button button = self.click_with_mouse_move( By.XPATH, "//button[contains(@class, 'o_searchview_dropdown_toggler')]", ) return button def odoo_web_click_open_search_add_group_custom(self, group_name): # Add personalize group button = self.click_with_mouse_move( By.XPATH, value="//select[contains(@class, 'o_add_custom_group_menu')]", ) if self.wait_human_time: time.sleep(1) select_elem = self.get_element( By.XPATH, value="//select[contains(@class, 'o_add_custom_group_menu')]", wait_clickable=False, ) select_obj = Select(select_elem) # (ex: ) select_obj.select_by_value(group_name) return select_elem def odoo_web_click_open_search_filter( self, filter_label=None, filter_sub_label=None, lst_update_condition=None, ): if not lst_update_condition: lst_update_condition = [] if filter_label: button = self.click_with_mouse_move( By.XPATH, value=f"//button[contains(@class,'o_menu_item') and contains(normalize-space(.), '{filter_label}')]", ) if filter_sub_label: button = self.click_with_mouse_move( By.XPATH, value=f"//span[contains(@class,'o_item_option') and contains(normalize-space(.), '{filter_sub_label}')]", ) if lst_update_condition: filter_text = f"{filter_label}: {filter_sub_label}" button = self.click_with_mouse_move( By.XPATH, value=f"//small[contains(@class,'o_facet_value') and contains(normalize-space(.), '{filter_text}')]/../..//i[contains(@class,'fa-cog')]", is_visible=False, ) time.sleep(1) for item_update_condition in lst_update_condition: condition_value = item_update_condition.get("condition") if condition_value: select_elem = self.click_with_mouse_move( By.XPATH, value=f"//div[contains(@class,'modal-content')]//select[contains(@class,'pe-3')]", is_visible=False, ) select_obj = Select(select_elem) # (ex: ) select_obj.select_by_value(condition_value) else: _logger.error( f"Not supported item_update_condition '{item_update_condition}'" ) # Save button = self.click_with_mouse_move( By.XPATH, value=f"//footer[contains(@class,'modal-footer')]//button[contains(@class,'btn-primary') and contains(normalize-space(.), 'Confirmer')]", ) def odoo_fill_search_input(self, text, selection_research=None): input_elem = self.click_with_mouse_move( By.XPATH, value="//input[contains(@class, 'o_searchview_input')]", ) input_elem.send_keys(text) time.sleep(1) if not selection_research: input_elem.send_keys(Keys.ENTER) else: input_autocomplete_elem = self.click_with_mouse_move( By.XPATH, value="//ul[contains(@class,'o_searchview_autocomplete')]" f"//li[contains(@class,'o_menu_item') and .//b[normalize-space(.)='{selection_research}']]" "//a[not(contains(@class,'o_expand'))]", ) return input_elem def odoo_web_principal_menu_click_root_menu(self, from_text=""): if not from_text: raise Exception( "Cannot click, empty parameter from method" " odoo_web_principal_menu_click_root_menu" ) button = self.click_with_mouse_move(By.LINK_TEXT, from_text) # If not work, force it # selenium_tool.driver.execute_script("arguments[0].click();", button) return button def odoo_web_form_click_statusbar_button_status( self, status_label, timeout=None ): if timeout is None: timeout = self.defaut_timeout try: status_button = self.click_with_mouse_move( By.XPATH, "//button[contains(@class, 'o_arrow_button') and" f" contains(text(), '{status_label}')]", timeout=timeout, ) except Exception as e: status_button = self.click_with_mouse_move( By.XPATH, "//button[contains(@class, 'o_arrow_button') and" f" text()='{status_label}']", timeout=timeout, ) print(f"Bouton du statusbar avec le label '{status_label}' cliqué.") return status_button def odoo_profile_click(self, enable_dark_mode=False, enable_tour=False): def toggle_data_menu(key, enable): profile_button = self.click_with_mouse_move( By.XPATH, "//div[contains(@class, 'o_user_menu')]/button[contains(@class, 'py-1')]", ) element = self.get_element( By.XPATH, f"//div[contains(@class, 'o_popover')]//span[@data-menu=\"{key}\"]//input", ) if element.is_selected() != enable: self.click_with_mouse_move( By.XPATH, f"//div[contains(@class, 'o_popover')]//span[@data-menu=\"{key}\"]//input", ) else: # close it self.click_with_mouse_move( By.XPATH, "//div[contains(@class, 'o_user_menu')]/button[contains(@class, 'py-1')]", ) toggle_data_menu("web_tour.tour_enabled", enable_tour) toggle_data_menu("color_scheme.switch", enable_dark_mode) def odoo_web_form_click_statusbar_button_status_floating( self, status_label, timeout=None ): if timeout is None: timeout = self.defaut_timeout status_button = self.click_with_mouse_move( By.XPATH, "//span[contains(@class, 'o_arrow_button') and contains(text()," f" '{status_label}')]", timeout=timeout, ) print(f"Bouton du statusbar avec le label '{status_label}' cliqué.") return status_button def odoo_web_form_click_statusbar_button_status_plus( self, status_label, timeout=None ): if timeout is None: timeout = self.defaut_timeout status_button = self.click_with_mouse_move( By.XPATH, "//button[contains(@class, 'o_arrow_button') and" f" text()='{status_label}']", timeout=timeout, ) print(f"Bouton du statusbar avec le label '{status_label}' cliqué.") return status_button def odoo_web_form_click_button_statusbar( self, btn_label, btn_class="btn-primary" ): status_button = self.click_with_mouse_move( By.XPATH, f"//button[contains(@class, '{btn_class}') and contains(span," f" '{btn_label}')]", ) print(f"Bouton du statusbar avec le label '{btn_label}' cliqué.") return status_button def odoo_web_form_click_button_action(self, btn_action): status_button = self.click_with_mouse_move( By.NAME, btn_action, ) print(f"Bouton avec l'action '{btn_action}' cliqué.") return status_button def odoo_web_form_click_save_action(self): return self.odoo_web_form_click_by_classname_action( "o_form_button_save" ) def odoo_web_change_view(self, view_name="list", raise_error=False): if view_name not in ["list", "kanban", "timeline"]: msg = f"odoo_web_change_view view_name '{view_name}' invalid" if raise_error: raise Exception(msg) _logger.error(msg) return None status_button = self.click_with_mouse_move( by=By.XPATH, value=f"//nav[contains(@class, 'o_cp_switch_buttons')]/button[contains(@class, 'o_{view_name}')]", ) print(f"Change view '{view_name}' cliqué.") return status_button def odoo_web_form_click_web_publish_action(self): return self.odoo_web_form_click_button_action("website_publish_button") def odoo_web_form_click_by_classname_action(self, custom_class_name): status_button = self.click_with_mouse_move( By.CLASS_NAME, custom_class_name, ) print(f"Bouton avec l'action '{custom_class_name}' cliqué.") return status_button def odoo_web_kanban_card(self, card_label): # From web view kanban, list kanban_card = self.click_with_mouse_move( By.XPATH, "//div[contains(@class," f" 'o_kanban_record')]//span[contains(text(), '{card_label}')]", ) # kanban_card = self.driver.find_element(By.XPATH, # f"//div[contains(@class, 'o_kanban_record')]//span[contains(text(), '{card_label}')]") # kanban_card.click() print(f"Carte Kanban avec le label '{card_label}' cliquée.") return kanban_card def odoo_web_action_button_new(self): # From web view kanban, list return self.click_with_mouse_move(By.CLASS_NAME, "o-kanban-button-new") def odoo_web_kanban_click(self, button_text, btn_class="btn-primary"): # From web view kanban, list return self.click_with_mouse_move( By.XPATH, f"//button[contains(@class, 'btn {btn_class} o_kanban_edit') and" f" text()='{button_text}']", ) def odoo_web_form_notebook_tab_click(self, tab_text): return self.click_with_mouse_move( By.XPATH, f"//a[text()='{tab_text}']", ) # Web def odoo_fill_widget_char(self, field_name, text, is_label=False): # Type input if self.odoo_version == "14.0": text_field = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.NAME, field_name)) ) elif self.odoo_version == "16.0": text_field = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.ID, field_name)) ) text_field.send_keys(text) return text_field def odoo_fill_widget_selection(self, field_name, text): return self.odoo_fill_widget_many2one(field_name, text) def odoo_fill_widget_boolean(self, field_name, value): self.driver.implicitly_wait(10) checkbox_div = self.driver.find_element( By.XPATH, f"//div[@name='{field_name}']" ) checkbox = checkbox_div.find_element( By.CSS_SELECTOR, 'input[type="checkbox"]' ) is_selected = checkbox.get_attribute("selected") # Check if already set if is_selected == "true" and value: return checkbox elif is_selected is None and not value: return checkbox parent_element = checkbox.find_element(By.XPATH, "..") parent_element.click() return checkbox def odoo_fill_widget_many2one(self, field_name, text): # TODO read this file odoo/addons/web/static/src/js/fields/field_registry.js div_field = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.NAME, field_name)) ) input_field = div_field.find_element(By.XPATH, ".//input") input_field.send_keys(text) # TODO improve speed by observation of comportement time.sleep(1) input_field.send_keys(Keys.ENTER) return input_field def odoo_fill_widget_date(self, field_name, date_to_select): # Type date self.driver.implicitly_wait(10) checkbox_div = self.driver.find_element( By.XPATH, f"//div[@name='{field_name}']" ) checkbox_div.click() # TODO improve speed by observation of comportement time.sleep(0.5) str_date_today = date_to_select.strftime("%Y-%m-%d") if self.odoo_version in ["17.0", "18.0"]: checkbox_date_div = self.driver.find_element( By.CSS_SELECTOR, ".o_datetime_picker" ) new_value = self.select_date(self.driver, date_to_select) else: checkbox_date_div = self.driver.find_element( By.CSS_SELECTOR, ".bootstrap-datetimepicker-widget" ) # time.sleep(0.5) new_value = checkbox_date_div.find_element( By.XPATH, f"//td[@data-day='{str_date_today}']" ) new_value.click() return new_value def start_record(self): # Démarrer l'enregistrement if self.config.record_mode: if self.config.record_wait_before_start_time: time.sleep(int(self.config.record_wait_before_start_time)) # Sync before record has_sync_error = False if self.config.sync_file_record_read: if not self.config.sync_file_record_write: print( "Error, cannot sync, missing 'sync_file_record_write'" ) has_sync_error = True if self.config.sync_file_record_write: if not self.config.sync_file_record_read: print( "Error, cannot sync, missing 'sync_file_record_read'" ) has_sync_error = True if ( not has_sync_error and self.config.sync_file_record_read and self.config.sync_file_record_write ): # Do sync file_name_1 = f"/tmp/erplibre_sync_temp_file{self.config.sync_file_record_write}" file_name_2 = f"/tmp/erplibre_sync_temp_file{self.config.sync_file_record_read}" with open(file_name_1, "w") as file: file.write(f"Recording {self.filename_recording}\n") while not os.path.exists(file_name_2): var_wait_time = 0.001 print( f"File {self.filename_recording} wait" f" {var_wait_time} seconds" ) time.sleep(var_wait_time) self.video_recorder.start() def quit(self): self.driver.quit() def stop_record(self): time.sleep(self.config.selenium_default_delay_presentation) # Arrêter l'enregistrement if self.config.record_mode: time.sleep(self.config.selenium_video_time_waiting_end) self.video_recorder.stop() print("End of recording video") print(self.filename_recording) filename_recording_mp4 = self.filename_recording.replace( ".webm", ".mp4" ) if self.config.selenium_video_auto_convert_mp4: with_auto_fix = True if with_auto_fix: cmd = f'ffmpeg -i {self.filename_recording} -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" -c:v libx264 -pix_fmt yuv420p -crf 23 -preset veryfast -c:a aac -b:a 160k -movflags +faststart {filename_recording_mp4}' else: cmd = f"ffmpeg -i {self.filename_recording} {filename_recording_mp4}" print(cmd) os.popen(cmd) if self.config.selenium_video_auto_play_video: print(f"Play video {self.filename_recording}") # By python-vlc # media_player = vlc.MediaPlayer(filename) # media = vlc.Media(filename) # media_player.set_media(media) # media_player.play() # time.sleep(15) # By external process if self.config.selenium_video_auto_convert_mp4: os.popen(f"vlc {filename_recording_mp4}") else: os.popen(f"vlc {self.filename_recording}") def generer_code_postal_quebec(self): # Zip code from quebec canada premieres_lettres = ["G", "H", "J"] premiere_lettre = random.choice(premieres_lettres) deuxieme_lettre = chr(random.randint(65, 90)) chiffre = str(random.randint(0, 9)) troisieme_lettre = chr(random.randint(65, 90)) code_postal = f"{premiere_lettre}{chiffre}{deuxieme_lettre} {chiffre}{troisieme_lettre}{chiffre}" return code_postal def generer_numero_telephone_quebec(self): # Number from quebec canada indicateurs_regionaux = [ "418", "438", "450", "514", "579", "581", "819", "873", ] indicatif = random.choice(indicateurs_regionaux) numero = "".join(random.choices("0123456789", k=7)) numero_telephone = f"({indicatif}) {numero[:3]}-{numero[3:]}" return numero_telephone def generer_date_naissance(self, age_minimum=16): annee_actuelle = datetime.datetime.now().year annee_maximale = annee_actuelle - age_minimum annee_minimale = 1900 annee_naissance = random.randint(annee_minimale, annee_maximale) mois_naissance = random.randint(1, 12) jour_naissance = random.randint(1, 28) date_naissance = datetime.datetime( annee_naissance, mois_naissance, jour_naissance ) return date_naissance.strftime("%Y-%m-%d") def _get_visible_month_year(self, picker): header = picker.find_element( By.CSS_SELECTOR, ".o_datetime_picker_header .o_header_part" ) text = header.text.strip().lower() # ex: "octobre 2025" parts = text.split() # s'il y a des espaces insécables ou formats bizarres mois_txt = parts[0].replace("\xa0", " ") annee = int(parts[-1]) mois = MONTHS_FR[mois_txt] return ( mois, annee, header.text, ) # on retourne aussi le texte exact pour attendre le changement # def wait_is_invisible(self, by, value): # div_field = WebDriverWait(self.driver, 10).until( # EC.presence_of_element_located((By.NAME, field_name)) # ) # wait = WebDriverWait(driver, 10) # wait.until( # EC.invisibility_of_element_located((By.ID, "ui-datepicker-div")) # ) def select_date(self, driver, date_to_select): # 1) ouvrir/viser le datepicker (si besoin, clique l'input avant) picker = WebDriverWait(driver, 10).until( EC.visibility_of_element_located( (By.CSS_SELECTOR, ".o_datetime_picker") ) ) # 2) naviguer au bon mois max_hops = 24 # sécurité (2 ans) for _ in range(max_hops): mois_vis, annee_vis, header_txt = self._get_visible_month_year( picker ) if (annee_vis, mois_vis) == ( date_to_select.year, date_to_select.month, ): break # bouton suivant/précédent dans l'entête du picker courant if (annee_vis, mois_vis) < ( date_to_select.year, date_to_select.month, ): btn = picker.find_element( By.CSS_SELECTOR, ".o_datetime_picker_header .o_next" ) else: btn = picker.find_element( By.CSS_SELECTOR, ".o_datetime_picker_header .o_previous" ) btn.click() # attendre que l'en-tête change pour éviter les doubles clics trop rapides WebDriverWait(driver, 10).until( EC.text_to_be_present_in_element( ( By.CSS_SELECTOR, ".o_datetime_picker_header .o_header_part", ), "", ) ) WebDriverWait(driver, 10).until( lambda d: picker.find_element( By.CSS_SELECTOR, ".o_datetime_picker_header .o_header_part" ).text != header_txt ) else: raise RuntimeError( "Impossible d'atteindre le mois voulu dans le délai imparti." ) # 3) cliquer le jour (les jours cliquables sont des