[UPD] script selenium better tools to navigate into Odoo

- support external selenium service to support selenium from Odoo
instance
- move selenium installation to erplibre virtual environment
- support user test instead of admin
This commit is contained in:
Mathieu Benoit 2025-10-31 01:12:25 -04:00
parent cbc43fde3c
commit 3d6b340b6d
3 changed files with 691 additions and 129 deletions

View file

@ -3,19 +3,22 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import datetime import datetime
import getpass
import json
import logging
import os import os
import random import random
import re import re
import shutil
import subprocess
import sys import sys
import time
import json
import tempfile import tempfile
from subprocess import getoutput import time
import tkinter as tk import tkinter as tk
from subprocess import getoutput
from tkinter import filedialog from tkinter import filedialog
import getpass
from pykeepass import PyKeePass
from pykeepass import PyKeePass
from randomwordfr import RandomWordFr from randomwordfr import RandomWordFr
from selenium import webdriver from selenium import webdriver
from selenium.common.exceptions import ( from selenium.common.exceptions import (
@ -30,17 +33,54 @@ from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.service import Service
from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import Select, WebDriverWait
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__)
# TODO maybe use TODO lib # TODO maybe use TODO lib
CONFIG_FILE = "./script/todo/todo.json" CONFIG_FILE = "./script/todo/todo.json"
CONFIG_OVERRIDE_FILE = "./private/todo.json" CONFIG_OVERRIDE_FILE = "./private/todo/todo.json"
LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt"
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,
}
# TODO maybe use TODO lib
CONFIG_FILE = "./script/todo/todo.json"
CONFIG_OVERRIDE_FILE = "./private/todo/todo.json"
LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt" LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt"
class SeleniumLib(object): class SeleniumLib(object):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.wait_human_time = config.human_time_speed
self.config_file = config_file.ConfigFile()
self.kdbx = None self.kdbx = None
self.video_recorder = None self.video_recorder = None
self.default_download_dir_path = tempfile.mkdtemp() self.default_download_dir_path = tempfile.mkdtemp()
@ -60,8 +100,13 @@ class SeleniumLib(object):
) )
self.dirname_recording = dir_path_screencast self.dirname_recording = dir_path_screencast
self.driver = None self.driver = None
with open(".odoo-version") as txt: if os.path.isfile(".odoo-version"):
self.odoo_version = txt.read() 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): def do_screenshot(self):
if self.config.scenario_screenshot: if self.config.scenario_screenshot:
@ -113,25 +158,155 @@ class SeleniumLib(object):
# Créez une instance du navigateur Firefox avec les options de navigation privée # Créez une instance du navigateur Firefox avec les options de navigation privée
try: try:
self.driver = webdriver.Firefox( user = os.environ.get("USER", "Non défini")
options=firefox_options, service=firefox_services 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
) )
except Exception: print(f"INFO: Result 'whoami': {whoami_result.stdout.strip()}")
except Exception as e:
print(f"ERROR when check user: {e}")
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"
)
self.driver = webdriver.Remote(
options=chrome_options,
command_executor=self.config.use_network,
)
else:
from webdriver_manager.chrome import ChromeDriverManager
service = Service(
ChromeDriverManager().install(),
log_path="/tmp/chromedriver.log",
)
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:
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
)
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}"
)
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"
)
self.driver = webdriver.Remote(
options=firefox_options,
command_executor=self.config.use_network,
)
else:
self.driver = webdriver.Firefox(
options=firefox_options,
service=firefox_services,
keep_alive=True,
)
except Exception as e:
print( print(
"Cannot open Firefox profile, so will force firefox snap for" "Cannot open Firefox profile, so will force firefox snap for"
" Ubuntu users." " Ubuntu users."
) )
firefox_services = Service( # TODO au lieu, faire une recherche de geckodriver avant de démarrer
executable_path=getoutput( # firefox_services = Service(
"find /snap/firefox -name geckodriver" # executable_path=getoutput(
).split("\n")[-1] # "find /snap/firefox -name geckodriver"
) # ).split("\n")[-1], log_path='/tmp/geckodriver.log'
firefox_options.binary_location = getoutput( # )
"find /snap/firefox -name firefox" # firefox_options.binary_location = getoutput(
).split("\n")[-1] # "find /snap/firefox -name firefox"
self.driver = webdriver.Firefox( # ).split("\n")[-1]
options=firefox_options, service=firefox_services # self.driver = webdriver.Firefox(
) # options=firefox_options, service=firefox_services
# )
raise e
print("The driver is launched!")
# Ajout de l'enregistrement # Ajout de l'enregistrement
if self.config.record_mode: if self.config.record_mode:
@ -152,66 +327,100 @@ class SeleniumLib(object):
# Install DarkReader to help my eyes # Install DarkReader to help my eyes
# TODO do a script to check if it's the last version # TODO do a script to check if it's the last version
if not self.config.no_dark_mode: if self.config.use_firefox_driver and not self.config.use_network:
self.driver.install_addon( if not self.config.no_dark_mode:
"./script/selenium/darkreader-firefox.xpi", temporary=True self.driver.install_addon(
) "./script/selenium/darkreader-firefox.xpi", temporary=True
self.driver.install_addon(
"./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.get("about:addons")
# Enable Dark Reader into incognito
self.click("/html/body/div/div[1]/categories-box/button[2]")
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: self.driver.install_addon(
# For old version before Firefox 138 "./script/selenium/odoo_debug-4.0.xpi", temporary=True
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 if (
# Back not self.config.not_private_mode
self.click( and not self.config.no_dark_mode
"/html/body/div/div[2]/addon-page-header/div/div[2]/button" ):
) self.driver.get("about:addons")
self.click( # Enable Dark Reader into incognito
"/html/body/div/div[2]/div/addon-list/section[1]/addon-card[2]/div/div/div/div/button", self.click("/html/body/div/div[1]/categories-box/button[2]")
)
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( self.click(
"/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[6]/div/label[1]/input", "/html/body/div/div[2]/div/addon-list/section[1]/addon-card/div/div/div/div/button",
) )
except Exception:
# For old version before Firefox 138
self.click( self.click(
"/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[5]/div/label[1]/input", "/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 # Ouvrez la page web
if not ignore_open_web: if not ignore_open_web:
self.driver.get(f"{self.config.url}/web") self.driver.get(f"{self.config.url}/web")
# Close tab opening by DarkReader # Close tab opening by DarkReader
if self.config.not_private_mode and not self.config.no_dark_mode: if self.config.use_firefox_driver and not self.config.use_network:
self.driver.switch_to.window(self.driver.window_handles[-1]) if self.config.not_private_mode and not self.config.no_dark_mode:
self.driver.close() self.driver.switch_to.window(self.driver.window_handles[-1])
self.driver.switch_to.window(self.driver.window_handles[0]) 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(["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(["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
def get_kdbx(self): def get_kdbx(self):
if self.kdbx: if self.kdbx:
@ -283,17 +492,46 @@ class SeleniumLib(object):
button.click() button.click()
return button return button
def get_element(self, by: str = By.ID, value: str = None, timeout=5): def get_element(
self,
by: str = By.ID,
value: str = None,
timeout=5,
wait_clickable=False,
is_visible=True,
):
only_one = False
wait = WebDriverWait(self.driver, timeout) wait = WebDriverWait(self.driver, timeout)
ele = wait.until( if wait_clickable:
EC.visibility_of_any_elements_located( ele = wait.until(
( EC.element_to_be_clickable(
by, (
value, by,
value,
)
) )
) )
) elif is_visible:
ele = wait.until(
EC.visibility_of_any_elements_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] return ele[0]
def get_element_not_visible( def get_element_not_visible(
@ -431,13 +669,14 @@ class SeleniumLib(object):
viewport_ele_value: str = None, viewport_ele_value: str = None,
element: WebElement = None, element: WebElement = None,
with_index: bool = False, with_index: bool = False,
is_visible=True,
index_of_list: int = 0, index_of_list: int = 0,
): ):
# ele = self.driver.find_element(by, value) # ele = self.driver.find_element(by, value)
if element: if element:
ele = element ele = element
else: else:
ele = self.get_element(by, value, timeout) ele = self.get_element(by, value, timeout, is_visible=is_visible)
if not no_scroll: if not no_scroll:
viewport_ele = None viewport_ele = None
if viewport_ele_value: if viewport_ele_value:
@ -742,40 +981,40 @@ class SeleniumLib(object):
# Website # Website
def odoo_show_robot_message( def odoo_show_robot_message(
self, msg="Ho no!", hide_message_after_x_seconds=0 self, msg="Ho no!", hide_message_after_x_seconds=0, robot_base64=None
): ):
# Injecter l'animation SVG dans la page # Injecter l'animation SVG dans la page
script_text = "" script_text = ""
if msg: if msg:
msg_to_print = msg.replace("'", "\\'") msg_to_print = msg.replace("'", "\\'")
script_text = f""" lst_mst_to_print = msg_to_print.split("\n")
// Ajouter le texte script_text = ""
const textElement = document.createElement('div'); for index, line in enumerate(lst_mst_to_print):
textElement.innerText = '{msg_to_print}'; script_text += f"""
textElement.style.color = '#FFFFFF'; // Ajouter le texte
textElement.style.fontSize = '24px'; const textElement_{index} = document.createElement('div');
textElement.style.marginBottom = '20px'; textElement_{index}.textContent = '{line}';
textElement.style.fontWeight = 'bold'; textElement_{index}.style.color = '#FFFFFF';
textElement_{index}.style.fontSize = '24px';
textElement_{index}.style.marginBottom = '20px';
textElement_{index}.style.fontWeight = 'bold';
overlay.appendChild(textElement); overlay.appendChild(textElement_{index});
""" """
script = ( if robot_base64:
""" script_robot = ""
(function() { script_add_robot = f"""
// Créer un div pour l'overlay const img = new Image();
const overlay = document.createElement('div'); img.alt = 'Robot';
overlay.id = 'uniqueOverlay'; // Ajout de l'ID img.className = 'cute-robot';
overlay.style.position = 'fixed'; img.style.width = '150px';
overlay.style.top = '0'; img.style.cursor = 'pointer';
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';
img.src = '{robot_base64}';
overlay.appendChild(img);
"""
else:
script_robot = """
// Contenu SVG du robot // Contenu SVG du robot
const svgContent = ` const svgContent = `
<svg class="cute-robot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150" style="width: 150px; cursor: pointer;"> <svg class="cute-robot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150" style="width: 150px; cursor: pointer;">
@ -801,11 +1040,32 @@ class SeleniumLib(object):
<rect x="50" y="120" width="15" height="30" rx="5" ry="5" fill="#388E3C"/> <rect x="50" y="120" width="15" height="30" rx="5" ry="5" fill="#388E3C"/>
</svg> </svg>
`; `;
"""
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_text
+ script_add_robot
+ """ + """
// Ajouter le contenu SVG au div
overlay.innerHTML += svgContent;
// Ajouter un gestionnaire de clic pour supprimer l'overlay // Ajouter un gestionnaire de clic pour supprimer l'overlay
overlay.onclick = function() { overlay.onclick = function() {
@ -856,7 +1116,7 @@ class SeleniumLib(object):
def odoo_web_click_principal_menu(self): def odoo_web_click_principal_menu(self):
# Click menu button # Click menu button
if self.odoo_version == "16.0": if self.odoo_version in ["16.0", "17.0", "18.0"]:
button = self.click_with_mouse_move( button = self.click_with_mouse_move(
By.XPATH, By.XPATH,
"//button[contains(@class, 'dropdown-toggle') and" "//button[contains(@class, 'dropdown-toggle') and"
@ -869,6 +1129,119 @@ class SeleniumLib(object):
) )
return button 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}')]",
timeout=10,
)
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')]",
timeout=10,
)
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')]",
timeout=10,
)
if self.wait_human_time:
time.sleep(1)
select_elem = self.get_element(
By.XPATH,
value="//select[contains(@class, 'o_add_custom_group_menu')]",
timeout=10,
wait_clickable=False,
)
select_obj = Select(select_elem)
# (ex: <option value="customer">Client</option>)
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}')]",
timeout=10,
)
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}')]",
timeout=10,
)
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')]",
timeout=10,
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')]",
timeout=10,
is_visible=False,
)
select_obj = Select(select_elem)
# (ex: <option value="customer">Client</option>)
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')]",
timeout=10,
)
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')]",
timeout=10,
)
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=""): def odoo_web_principal_menu_click_root_menu(self, from_text=""):
if not from_text: if not from_text:
raise Exception( raise Exception(
@ -952,6 +1325,23 @@ class SeleniumLib(object):
"o_form_button_save" "o_form_button_save"
) )
def odoo_web_change_view(
self, view_name="list", raise_error=False, timeout=5
):
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): def odoo_web_form_click_web_publish_action(self):
return self.odoo_web_form_click_button_action("website_publish_button") return self.odoo_web_form_click_button_action("website_publish_button")
@ -1059,14 +1449,20 @@ class SeleniumLib(object):
checkbox_div.click() checkbox_div.click()
# TODO improve speed by observation of comportement # TODO improve speed by observation of comportement
time.sleep(0.5) time.sleep(0.5)
checkbox_date_div = self.driver.find_element(
By.CSS_SELECTOR, ".bootstrap-datetimepicker-widget"
)
# time.sleep(0.5)
str_date_today = date_to_select.strftime("%Y-%m-%d") str_date_today = date_to_select.strftime("%Y-%m-%d")
new_value = checkbox_date_div.find_element( if self.odoo_version in ["17.0", "18.0"]:
By.XPATH, f"//td[@data-day='{str_date_today}']" 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() new_value.click()
return new_value return new_value
@ -1108,6 +1504,9 @@ class SeleniumLib(object):
time.sleep(var_wait_time) time.sleep(var_wait_time)
self.video_recorder.start() self.video_recorder.start()
def quit(self):
self.driver.quit()
def stop_record(self): def stop_record(self):
time.sleep(self.config.selenium_default_delay_presentation) time.sleep(self.config.selenium_default_delay_presentation)
@ -1122,10 +1521,13 @@ class SeleniumLib(object):
".webm", ".mp4" ".webm", ".mp4"
) )
if self.config.selenium_video_auto_convert_mp4: if self.config.selenium_video_auto_convert_mp4:
os.popen( with_auto_fix = True
"ffmpeg -i" if with_auto_fix:
f" {self.filename_recording} {filename_recording_mp4}" 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: if self.config.selenium_video_auto_play_video:
print(f"Play video {self.filename_recording}") print(f"Play video {self.filename_recording}")
@ -1193,6 +1595,94 @@ class SeleniumLib(object):
return date_naissance.strftime("%Y-%m-%d") 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 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 <button> avec un <span> = numéro)
day = str(int(date_to_select.day)) # sans zéro en tête
day_xpath = f".//button[contains(@class,'o_date_item_cell') and .//span[normalize-space(text())='{day}']]"
day_btn = WebDriverWait(picker, 10).until(
EC.element_to_be_clickable((By.XPATH, day_xpath))
)
return day_btn
def get_args(parser):
args = parser.parse_args()
if args.use_chrome_driver:
args.use_firefox_driver = False
return args
def fill_parser(parser): def fill_parser(parser):
parser.add_argument( parser.add_argument(
@ -1209,12 +1699,37 @@ def fill_parser(parser):
help="Pause at the end of an action.", help="Pause at the end of an action.",
) )
group_animation.add_argument(
"--human_time_speed",
action="store_true",
help="Will wait after each action to help human to follow",
)
group_browser = parser.add_argument_group(title="Browser") group_browser = parser.add_argument_group(title="Browser")
group_browser.add_argument( group_browser.add_argument(
"--not_private_mode", "--not_private_mode",
action="store_true", action="store_true",
help="Default is private mode.", help="Default is private mode.",
) )
group_browser.add_argument(
"--use_chrome_driver",
action="store_true",
help="Switch to chrome instead of great Firefox.",
)
group_browser.add_argument(
"--use_firefox_driver",
action="store_true",
default=True,
help="The default is firefox.",
)
group_browser.add_argument(
"--use_network",
help="Specify the adress, example: http://localhost:4444",
)
group_browser.add_argument(
"--window_size",
help="Example 1920,1080",
)
group_browser.add_argument( group_browser.add_argument(
"--headless", "--headless",
action="store_true", action="store_true",

View file

@ -2,10 +2,15 @@
import threading import threading
import gi try:
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst
except ImportError:
print("Please read installation script/selenium/install.md")
raise
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None) Gst.init(None)

View file

@ -7,9 +7,15 @@ import os
import sys import sys
import time import time
import selenium_lib new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from script.selenium import selenium_lib
def fill_parser(parser): def fill_parser(parser):
group_login = parser.add_argument_group(title="Login") group_login = parser.add_argument_group(title="Login")
@ -30,13 +36,24 @@ def fill_parser(parser):
) )
def run(config, selenium_tool): def run(
config,
selenium_tool,
default_email_auth=None,
default_password_auth=None,
no_try_test=False,
):
# Trouvez les éléments du formulaire # Trouvez les éléments du formulaire
courriel_input = selenium_tool.get_element(by=By.NAME, value="login") courriel_input = selenium_tool.get_element(by=By.NAME, value="login")
mot_de_passe_input = selenium_tool.get_element(by=By.NAME, value="password") mot_de_passe_input = selenium_tool.get_element(
by=By.NAME, value="password"
)
# div_connexion_button = selenium_tool.get_element(by=By.CLASS_NAME, value="oe_login_buttons") # div_connexion_button = selenium_tool.get_element(by=By.CLASS_NAME, value="oe_login_buttons")
connexion_button = selenium_tool.get_element(by=By.CSS_SELECTOR, value="[type='submit']") connexion_button = selenium_tool.get_element(
by=By.CSS_SELECTOR, value="[type='submit']"
)
actual_url = selenium_tool.driver.current_url
# try: # try:
# connexion_button = selenium_tool.driver.find_element( # connexion_button = selenium_tool.driver.find_element(
# By.XPATH, # By.XPATH,
@ -51,8 +68,18 @@ def run(config, selenium_tool):
# ) # )
# Remplissez le courriel et le mot de passe # Remplissez le courriel et le mot de passe
courriel_input.send_keys(config.default_email_auth) email_auth = (
mot_de_passe_input.send_keys(config.default_password_auth) default_email_auth if default_email_auth else config.default_email_auth
)
pass_auth = (
default_password_auth
if default_password_auth
else config.default_password_auth
)
courriel_input.clear()
mot_de_passe_input.clear()
courriel_input.send_keys(email_auth)
mot_de_passe_input.send_keys(pass_auth)
# Cliquez sur le bouton "Connexion" # Cliquez sur le bouton "Connexion"
try: try:
@ -100,6 +127,21 @@ def run(config, selenium_tool):
# ) # )
# ) # )
if not no_try_test:
time.sleep(1)
if actual_url in [
selenium_tool.driver.current_url,
f"{selenium_tool.driver.current_url}?redirect=%2Fweb%3F",
]:
run(
config,
selenium_tool,
default_email_auth="test",
default_password_auth="test",
no_try_test=True,
)
return
# Remove chatbot if open, because will crash wizard div[4] (to div[5]) # Remove chatbot if open, because will crash wizard div[4] (to div[5])
selenium_tool.check_bot_chat_and_close() selenium_tool.check_bot_chat_and_close()