Skip to content

Commit fb834b0

Browse files
authored
Merge pull request #1760 from kroky/bugfix/selenium
fix(unit): selenium tests hardening
2 parents 95e6b12 + 10e1f0c commit fb834b0

File tree

12 files changed

+137
-146
lines changed

12 files changed

+137
-146
lines changed

.github/tests/selenium/creds.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@
55
from selenium.webdriver.chrome.service import Service
66

77
chrome_options = Options()
8-
chrome_options.add_argument('--headless')
98
chrome_options.add_argument('--disable-gpu')
109
chrome_options.BinaryLocation = "/usr/bin/google-chrome"
1110

12-
chrome_options.headless = False
13-
chrome_options.add_argument("start-maximized")
14-
# options.add_experimental_option("detach", True)
11+
chrome_options.add_argument("--headless=new") # or "--headless" depending on Chrome version
12+
chrome_options.add_argument("--window-size=1920,1080")
13+
chrome_options.add_argument("--force-device-scale-factor=1")
1514
chrome_options.add_argument("--no-sandbox")
16-
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
17-
chrome_options.add_experimental_option('excludeSwitches', ['enable-logging'])
15+
chrome_options.add_argument("--disable-dev-shm-usage")
16+
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"])
1817
chrome_options.add_experimental_option('useAutomationExtension', False)
1918
chrome_options.add_argument('--disable-blink-features=AutomationControlled')
2019

20+
# Enable browser console logs
21+
chrome_options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
22+
2123
2224
IMAP_ID='0'
2325
DRIVER_CMD =Service('/usr/bin/chromedriver')

.github/workflows/Test-Build.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,11 @@ jobs:
140140

141141
- name: "Script: test.sh"
142142
run: bash .github/tests/test.sh
143+
144+
- name: Upload Selenium debug artifacts
145+
if: failure()
146+
uses: actions/upload-artifact@v4
147+
with:
148+
name: selenium-debug
149+
path: |
150+
tests/selenium/artifacts/**/*

modules/core/js_modules/utils/loaders.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ function showLoaderToast(text = 'Loading...') {
1313
</div>
1414
`
1515

16+
if (document.getElementById('loading_indicator')) {
17+
document.getElementById('loading_indicator').remove();
18+
}
19+
1620
document.body.insertAdjacentHTML('beforeend', toastHTML)
1721

1822
const instance = bootstrap.Toast.getOrCreateInstance(document.getElementById(uniqueId));
19-
instance.show();
23+
instance.show();
2024

2125
return instance;
2226
}

tests/selenium/base.py

Lines changed: 76 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import glob
1818
import subprocess
1919
import json
20+
import time
21+
import os
2022

2123
class WebTest:
2224

@@ -25,13 +27,23 @@ class WebTest:
2527
def __init__(self, cap=None):
2628
self.read_ini()
2729
self.driver = get_driver(cap)
28-
# Change the window size to make sure all elements are visible
29-
current_size = self.driver.get_window_size()
30-
new_height = 5000
31-
self.driver.set_window_size(current_size['width'], new_height)
3230
self.browser = False
3331
if 'browserName' in self.driver.capabilities:
3432
self.browser = self.driver.capabilities['browserName'].lower()
33+
34+
# Ensure a consistent, generous viewport in CI to avoid responsive layout issues
35+
try:
36+
if os.getenv("GITHUB_ACTIONS") == "true":
37+
self.driver.set_window_rect(x=0, y=0, width=1920, height=1080)
38+
actual_size = self.driver.get_window_size()
39+
if actual_size['width'] < 1000: # If still too small, try alternative method
40+
self.driver.execute_script("window.resizeTo(1920, 1080);")
41+
else:
42+
current_size = self.driver.get_window_size()
43+
self.driver.set_window_size(current_size['width'], 5000)
44+
except Exception as e:
45+
print(f" - Warning: Could not set window size: {e}")
46+
3547
self.load()
3648

3749
def read_ini(self):
@@ -48,27 +60,44 @@ def read_ini(self):
4860
def load(self):
4961
print(" - loading site")
5062
self.go(SITE_URL)
63+
# In headless mode, maximize_window() doesn't work and can cause issues
64+
# Set window size explicitly instead
5165
try:
52-
self.driver.maximize_window()
66+
if os.getenv("GITHUB_ACTIONS") == "true":
67+
self.driver.set_window_rect(x=0, y=0, width=1920, height=1080)
68+
self.driver.execute_script("window.resizeTo(1920, 1080);")
69+
else:
70+
self.driver.maximize_window()
5371
except Exception:
54-
print(" - Could not maximize browser :(")
72+
print(" - Could not set window size :(")
5573
if self.browser == 'safari':
5674
try:
5775
self.driver.set_window_size(1920,1080)
5876
except Exception:
5977
print(" - Could not maximize Safari")
6078

79+
def save_debug_artifacts(self, name):
80+
if os.getenv("GITHUB_ACTIONS") != "true":
81+
return
82+
os.makedirs(f"artifacts/{name}", exist_ok=True)
83+
with open(f"artifacts/{name}/page_dump.html", "w", encoding="utf-8") as f:
84+
f.write(self.driver.page_source)
85+
self.driver.save_screenshot(f"artifacts/{name}/page_screenshot.png")
86+
logs = self.driver.get_log("browser")
87+
with open(f"artifacts/{name}/console_logs.json", "w", encoding="utf-8") as f:
88+
json.dump(logs, f, indent=2)
89+
6190
def mod_active(self, name):
6291
# debug self.modules
6392
echo = " - modules enabled: "
6493
for mod in self.modules:
6594
echo += mod + " "
66-
print(echo)
95+
print(echo)
6796
if name in self.modules:
6897
return True
6998
print(" - module not enabled: %s" % name)
7099
return False
71-
100+
72101
def single_server(self):
73102
if self.servers <= 1:
74103
return True
@@ -77,6 +106,7 @@ def single_server(self):
77106

78107
def go(self, url):
79108
self.driver.get(url)
109+
self.wait_for_page_ready()
80110

81111
def login(self, user, password):
82112
print(" - logging in")
@@ -95,7 +125,7 @@ def confirm_alert(self):
95125
WebDriverWait(self.driver, 3).until(exp_cond.alert_is_present(), 'timed out')
96126
alert = self.driver.switch_to.alert
97127
alert.accept()
98-
128+
99129
def logout_no_save(self):
100130
print(" - logging out")
101131
self.driver.find_element(By.CLASS_NAME, 'logout_link').click()
@@ -128,7 +158,7 @@ def by_class(self, class_name):
128158
print(" - finding element by class {0}".format(class_name))
129159
return self.driver.find_element(By.CLASS_NAME, class_name)
130160

131-
def wait_for_element_by_class(self, class_name, timeout=10):
161+
def wait_for_element_by_class(self, class_name, timeout=60):
132162
"""Wait for an element to be present and visible by class name"""
133163
print(" - waiting for element by class {0}".format(class_name))
134164
WebDriverWait(self.driver, timeout).until(
@@ -143,7 +173,7 @@ def wait_for_element_by_class(self, class_name, timeout=10):
143173
def by_xpath(self, element_xpath):
144174
print(" - finding element by xpath {0}".format(element_xpath))
145175
return self.driver.find_element(By.XPATH, element_xpath)
146-
176+
147177
def element_exists(self, class_name):
148178
print(" - checking if element exists by class {0}".format(class_name))
149179
try:
@@ -157,63 +187,49 @@ def wait(self, el_type=By.TAG_NAME, el_value="body", timeout=60):
157187
element = WebDriverWait(self.driver, timeout).until(
158188
exp_cond.presence_of_element_located((el_type, el_value)))
159189

160-
def wait_on_class(self, class_name, timeout=30):
190+
def wait_on_class(self, class_name, timeout=60):
161191
self.wait(By.CLASS_NAME, class_name)
162192

163193
def wait_with_folder_list(self):
164194
self.wait(By.CLASS_NAME, "main")
165195

166-
def wait_on_sys_message(self, timeout=30):
196+
def wait_on_sys_message(self, timeout=60):
167197
wait = WebDriverWait(self.driver, timeout)
168198
element = wait.until(wait_for_non_empty_text((By.CLASS_NAME, "sys_messages"))
169199
)
170-
171-
def wait_for_navigation_to_complete(self, timeout=30):
200+
201+
def wait_for_navigation_to_complete(self, timeout=60):
172202
print(" - waiting for the navigation to complete...")
173203
# Wait for the main content to be updated and any loading indicators to disappear
174204
try:
175-
# Wait for any loading indicators to disappear
176-
WebDriverWait(self.driver, 5).until_not(
177-
lambda driver: len(driver.find_elements(By.ID, "loading_indicator")) > 0
205+
WebDriverWait(self.driver, timeout).until(
206+
lambda driver: driver.execute_script("return window.routingToast === null;")
207+
)
208+
WebDriverWait(self.driver, timeout).until(
209+
lambda driver: driver.execute_script("return document.getElementById('nprogress') === null;")
178210
)
179211
except:
180-
# Loading icon might not be present, continue
212+
print(" - routing toast or nprogress check failed, continuing...")
181213
pass
182-
183-
# Wait for the main content area to be stable
214+
215+
def wait_for_page_ready(self, timeout=60):
216+
"""Wait for document readiness and idle network to reduce flakiness after navigation."""
184217
try:
218+
# Wait for DOM ready
185219
WebDriverWait(self.driver, timeout).until(
186-
lambda driver: driver.execute_script("""
187-
return new Promise((resolve) => {
188-
let lastContent = '';
189-
let stableCount = 0;
190-
const checkStability = () => {
191-
const mainContent = document.querySelector('main')?.innerHTML || '';
192-
if (mainContent === lastContent) {
193-
stableCount++;
194-
if (stableCount >= 3) {
195-
resolve(true);
196-
return;
197-
}
198-
} else {
199-
stableCount = 0;
200-
lastContent = mainContent;
201-
}
202-
setTimeout(checkStability, 100);
203-
};
204-
checkStability();
205-
});
206-
""")
220+
lambda d: d.execute_script("return document.readyState") == "complete"
207221
)
208-
except:
209-
# Fallback: just wait for the main element to be present
210-
print(" - fallback: waiting for main element")
211-
WebDriverWait(self.driver, timeout).until(
212-
exp_cond.presence_of_element_located((By.TAG_NAME, "main"))
222+
except Exception:
223+
print(" - document.readyState wait failed, continuing...")
224+
# Best-effort small delay to allow post-load JS to attach handlers in CI
225+
try:
226+
WebDriverWait(self.driver, 5).until(
227+
lambda d: d.execute_script(
228+
"return !!(window.requestIdleCallback || window.setTimeout)"
229+
)
213230
)
214-
# Additional wait for any dynamic content
215-
import time
216-
time.sleep(1)
231+
except Exception:
232+
pass
217233

218234
def wait_for_settings_to_expand(self):
219235
print(" - waiting for the settings section to expand...")
@@ -229,13 +245,13 @@ def wait_for_settings_to_expand(self):
229245
return
230246
except:
231247
pass
232-
248+
233249
# Click to expand
234250
settings_button.click()
235-
251+
236252
# Wait for the settings to be displayed
237253
try:
238-
WebDriverWait(self.driver, 10).until(lambda x: self.by_class('settings').is_displayed())
254+
WebDriverWait(self.driver, 60).until(lambda x: self.by_class('settings').is_displayed())
239255
print(" - settings expanded successfully")
240256
except:
241257
print(" - settings expansion timeout, continuing anyway")
@@ -249,13 +265,13 @@ def click_when_clickable(self, el):
249265
print(" - waiting for element to be clickable")
250266
try:
251267
# Scroll element into view
252-
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", el)
253-
268+
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center', behavior: 'instant'});", el)
269+
254270
# Wait for element to be clickable
255-
WebDriverWait(self.driver, 10).until(
271+
WebDriverWait(self.driver, 60).until(
256272
exp_cond.element_to_be_clickable(el)
257273
)
258-
274+
259275
# Try regular click first
260276
try:
261277
el.click()
@@ -265,7 +281,7 @@ def click_when_clickable(self, el):
265281
print(" - trying JavaScript click as fallback")
266282
# Use JavaScript click as fallback
267283
self.driver.execute_script("arguments[0].click();", el)
268-
284+
269285
except Exception as e:
270286
print(f" - click_when_clickable failed: {e}")
271287
# Final fallback: try JavaScript click without waiting
@@ -282,11 +298,8 @@ def safe_click(self, element):
282298
for attempt in range(max_attempts):
283299
try:
284300
# Scroll element into view
285-
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
286-
# Wait a moment for any animations
287-
import time
288-
time.sleep(0.5)
289-
# Try to click
301+
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center', behavior: 'instant'});", element)
302+
WebDriverWait(self.driver, 60).until(exp_cond.element_to_be_clickable(element))
290303
element.click()
291304
return
292305
except Exception as e:

tests/selenium/folder_list.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def reload_folder_list(self):
3535
# If spinner doesn't appear, just wait a bit for the AJAX to complete
3636
import time
3737
time.sleep(2)
38-
38+
3939
# Verify the main menu is still displayed after the reload
4040
main_menu = self.by_class('main')
4141
assert main_menu.is_displayed()
@@ -52,7 +52,7 @@ def expand_section(self):
5252
const item = arguments[1];
5353
container.scrollTop = item.offsetTop - container.offsetTop;
5454
""", folder_list, list_item)
55-
WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable(link))
55+
WebDriverWait(self.driver, 60).until(EC.element_to_be_clickable(link))
5656
link.click()
5757
self.wait_with_folder_list()
5858
self.wait_for_navigation_to_complete()
@@ -68,7 +68,7 @@ def collapse_section(self):
6868
assert 'show' not in collapsed_class
6969

7070
def hide_folders(self):
71-
self.driver.execute_script("window.scrollBy(0, 1000);")
71+
self.driver.execute_script("window.scrollBy({left:0, top:1000, behavior: 'instant'});")
7272
self.wait(By.CLASS_NAME, 'menu-toggle')
7373
# Use JavaScript to click the element
7474
hide_button = self.by_class('menu-toggle')

0 commit comments

Comments
 (0)