Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
f074d2a
QA-8162 testcases added for login tabs
kbo001 Oct 29, 2025
439ab2d
QA-8162 corrected yml for extended tests
kbo001 Oct 29, 2025
acbe46d
QA-8162 update missing secret
kbo001 Oct 29, 2025
8a219b8
QA-8162 corrected staff creation variables and added timeout
kbo001 Oct 30, 2025
21cad86
QA-8162 updated yml and commented incorrect steps for staff creation
kbo001 Oct 30, 2025
2bfe41f
QA-8162 added testcase 12
kbo001 Nov 6, 2025
08fd6cd
QA-8162 updated the testcases steps
kbo001 Nov 7, 2025
11475e1
QA-8162 updated the presetup testcase mark
kbo001 Nov 7, 2025
1ab67a9
QA-8162 removed repeatative step
kbo001 Nov 7, 2025
438a5a4
QA-8162 fix for testcase 10
kbo001 Nov 7, 2025
672401e
QA-8162 removed commented steps
kbo001 Nov 7, 2025
68b1d0c
Merge branch 'main' of https://github.com/dimagi/dimagi-qa-sureadhere…
kbo001 Nov 13, 2025
67bedf1
QA-8162 some testcases for staff tab
kbo001 Nov 17, 2025
34208ad
QA-8162 renamed params
kbo001 Nov 17, 2025
5930562
QA-8162 little fixes
kbo001 Nov 25, 2025
6e99b81
QA-8162 added depends and fixed params
kbo001 Nov 26, 2025
bb8edc2
QA-8162 changed the logic for add staff
kbo001 Nov 27, 2025
a0c0a4a
QA-8162 updated testcases for staff and the parallel execution value
kbo001 Nov 27, 2025
fa16457
QA-8162 added rerun handlings
kbo001 Nov 27, 2025
c1be8fb
QA-8162 reduced length of staff name
kbo001 Nov 28, 2025
3fbc7b3
QA-8162 little changes to staff tests
kbo001 Nov 28, 2025
7a97e23
QA-8162 modified login exception
kbo001 Nov 28, 2025
78d23f5
QA-8162 added missing declaration
kbo001 Dec 1, 2025
d8b6dca
QA-8162 added new utility to wait_for_text
kbo001 Dec 1, 2025
a3de01e
QA-8162 updated locators of all tabs
kbo001 Dec 1, 2025
c87498f
Merge branch 'main' of https://github.com/dimagi/dimagi-qa-sureadhere…
kbo001 Dec 1, 2025
28b2f56
QA-8162 reverted the base page, added new fixture
kbo001 Dec 1, 2025
416e121
QA-8162 separated test classes for the idle TCs
kbo001 Dec 1, 2025
e315f05
QA-8162 increase parallel threads for extended tests
kbo001 Dec 1, 2025
d9a617e
QA-8162 replaced idle_time with time.sleep
kbo001 Dec 2, 2025
17e47ec
QA-8162 added 3 more staff TCs
kbo001 Dec 8, 2025
7b0d064
QA-8162 resolved conflicts
kbo001 Dec 10, 2025
979c133
QA-8162 added new testcases and new test module
kbo001 Dec 11, 2025
d5b981e
QA-8162 added login step
kbo001 Dec 11, 2025
c2e0aa3
QA-8162 added 4 new staff role TCs
kbo001 Dec 12, 2025
573c0c4
QA-8162 added 1 more TC
kbo001 Dec 12, 2025
da3a225
QA-8162 increased wait time
kbo001 Dec 12, 2025
08c10cf
QA-8162 updated patient names
kbo001 Dec 12, 2025
bf0fe03
QA-8162 updated fill patient form parameters
kbo001 Dec 12, 2025
d28e262
QA-8162 fixes for module 7
kbo001 Dec 12, 2025
58b9075
QA-8162 login page handled
kbo001 Dec 12, 2025
1d30080
QA-8162 fixes for search staff
kbo001 Dec 12, 2025
ca481c6
QA-8162 adjusting parameters
kbo001 Dec 13, 2025
6eb4239
QA-8162 fix for staff failures
kbo001 Dec 13, 2025
417b535
QA-8162 more fixes for staff failures
kbo001 Dec 13, 2025
67d5e87
QA-8162 some more fixes for staff failures
kbo001 Dec 13, 2025
ae85fdc
QA-8162 another fix for staff failures
kbo001 Dec 13, 2025
48972f6
QA-8162 updated steps for staff testcases
kbo001 Dec 13, 2025
e903f0e
QA-8162 updated login function
kbo001 Dec 13, 2025
45f8ce7
QA-8162 updated staff functions
kbo001 Dec 13, 2025
b288693
QA-8162 added the remaining TC
kbo001 Dec 13, 2025
a6d8893
QA-8162 exception handled for TC 1
kbo001 Dec 13, 2025
eab97d1
QA-8162 fix for staff tcs
kbo001 Dec 13, 2025
bc8c515
QA-8162 more fixes for staff tcs
kbo001 Dec 13, 2025
7afc6b4
QA-8162 exception for edit access
kbo001 Dec 13, 2025
7f0b139
QA-8162 more fixes
kbo001 Dec 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/sa-workflows-extended.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
--dashboard --html=presetup_extendedtests-report-${{ matrix.environment }}.html --maxfail=1
pytest -v -m extendedtests \
--browser=chrome --headless --headless2 \
--reruns 1 -n 3 --dist=loadscope\
--reruns 1 -n 4 --dist=loadscope\
--dashboard --html=extendedtests-report-${{ matrix.environment }}.html

- name: Parse test counts
Expand Down Expand Up @@ -293,7 +293,7 @@ jobs:
script: |
const { promises: fs } = require('fs')
const {JOB_STATUS, NOW, CC_ENV, GITHUB_HEAD_REF} = process.env
const prefix = `[${CC_ENV}] SureAdhere Tests - ${JOB_STATUS.toUpperCase()} - Run #${context.runNumber}`
const prefix = `[${CC_ENV}] SureAdhere Extended Tests - ${JOB_STATUS.toUpperCase()} - Run #${context.runNumber}`
const suffix = `at ${NOW}`
let subject = `${prefix} on branch "${GITHUB_HEAD_REF}" ${suffix}`

Expand All @@ -303,7 +303,7 @@ jobs:
}

let actionRunLink = context.payload.repository.html_url + `/actions/runs/${context.runId}`
let testSuite = 'SureAdhere Tests'
let testSuite = 'SureAdhere Extended Tests'
let bodyContent = await fs.readFile(bodyFile, 'utf8')
bodyContent = bodyContent.replace(/{{actionRunLink}}/g, actionRunLink)
.replace(/{{runNumber}}/g, context.runNumber)
Expand Down Expand Up @@ -335,4 +335,3 @@ jobs:
convert_markdown: true
attachments: sbase-extendedtests-artifacts-${{ matrix.environment }}.zip
in_reply_to: ${{ fromJSON(steps.configure_email.outputs.result).reference }}

1 change: 1 addition & 0 deletions .github/workflows/sa-workflows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ jobs:
DIMAGIQA_ADMIN_PASSWORD: ${{ secrets.DIMAGIQA_ADMIN_PASSWORD }}
DIMAGIQA_BS_USER: ${{ secrets.DIMAGIQA_BS_USER }}
DIMAGIQA_BS_KEY: ${{ secrets.DIMAGIQA_BS_KEY }}
DIMAGIQA_SA_IMAP_PASSWORD: ${{ secrets.DIMAGIQA_SA_IMAP_PASSWORD }}

run: |
echo "client_payload: ${{ toJson(github.event.client_payload) }}"
Expand Down
291 changes: 286 additions & 5 deletions common_utilities/base_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import Dict, Any, Iterable, List, Tuple, Optional
import platform
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, NoSuchFrameException
from selenium.common.exceptions import ElementClickInterceptedException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
Expand All @@ -24,7 +24,7 @@
# ---- Tunables ---------------------------------------------------------------

PRIMARY_TIMEOUT = 6 # seconds for fast checks
CLICK_TIMEOUT = 15 # seconds for user actions
CLICK_TIMEOUT = 30 # seconds for user actions
VISIBLE_REQUIRED = True # only accept visible elements
SIM_THRESHOLD = 0.62 # fuzzy match threshold for text-ish attrs
MIN_STABLE_PREFIX = 3 # for dynamic-id starts-with heuristics
Expand Down Expand Up @@ -312,7 +312,7 @@ def _candidate_matches_entry(self, el, entry: dict) -> bool:
if col is not None:
if (el.get_attribute("aria-colindex") or "").strip() != str(col):
return False
# class tokens guard (token-based, not substring)
# class tokens guard – ONLY for table-ish cells where we really care
cls = entry.get("class") or ""
if cls and not self._class_has_tokens(el, cls):
return False
Expand Down Expand Up @@ -372,7 +372,7 @@ def _candidates_for(self, entry: Dict[str, Any]) -> List[str]:
# 4) Attribute combos (equals)
# inside your candidate builder, add aria-colindex to equality attributes
combo_attrs = [
"id", "name", "type", "placeholder", "aria-label", "role",
"id", "name", "type", "placeholder", "aria-label", "title", "role",
"data-testid", "data-id", "data-value", "aria-colindex" # <--- add this
]

Expand Down Expand Up @@ -445,7 +445,7 @@ def _score_element(self, el, entry: Dict[str, Any]) -> float:
tag = (el.tag_name or "").lower()
if entry.get("tag") and tag == (entry["tag"] or "").lower():
score += 0.35
for a in ["type", "placeholder", "aria-label", "name", "id", "class"]:
for a in ["type", "placeholder", "aria-label", "name", "id", "class", "title", "role"]:
want = entry.get(a)
if not want:
continue
Expand Down Expand Up @@ -554,6 +554,13 @@ def wait_for_element(self, logical_name: str, timeout: int = CLICK_TIMEOUT, stri
sel = self.resolve_strict(logical_name) if strict else self.resolve(logical_name)
self.sb.wait_for_element(sel, timeout=timeout)

def wait_for_text(self, text: str, logical_name: str, timeout: int = CLICK_TIMEOUT, strict: bool = False):
sel = self.resolve_strict(logical_name) if strict else self.resolve(logical_name)
text = text.strip()
self.sb.wait_for_element_present(sel, timeout=timeout)
self.sb.wait_for_element_visible(sel, timeout=timeout)
self.sb.wait_for_text(text, sel, timeout=timeout)

def find_elements(self, logical_name: str, timeout: int = CLICK_TIMEOUT):
sel = self.resolve_strict(logical_name)
print(sel)
Expand All @@ -566,6 +573,30 @@ def find_elements(self, logical_name: str, timeout: int = CLICK_TIMEOUT):
# Return all currently matching elements
return self.sb.find_elements(sel)

def find_elements_raw(self, selector: str, by: str = "xpath", timeout: int = 10):
by = by.lower()
if by == "xpath":
by_type = By.XPATH
elif by == "css":
by_type = By.CSS_SELECTOR
elif by == "id":
by_type = By.ID
elif by == "name":
by_type = By.NAME
else:
raise ValueError(f"Unsupported selector type: {by}")

print(f"[RAW FIND] {by_type} -> {selector}")

try:
WebDriverWait(self.driver, timeout).until(
lambda d: d.find_elements(by_type, selector)
)
except TimeoutException:
return []

return self.driver.find_elements(by_type, selector)

def go_back(self):
"""Go back one page in browser history."""
self.sb.go_back()
Expand All @@ -590,6 +621,11 @@ def type(self, logical_name: str, value: str, timeout: int = CLICK_TIMEOUT):
# self.sb.highlight(sel)
self.sb.type(sel, value)

def idle_wait(self, idle_time=300):
print(f"⏳ Simulating {idle_time / 60} minutes of inactivity...")
for i in range(idle_time // 60):
time.sleep(60)
print(f" ... {i + 1} minute(s) passed")

def type_and_trigger(self, logical_name: str, text: str, *,
timeout: int = 15, blur: bool = True, clear_first: bool = True):
Expand Down Expand Up @@ -3413,4 +3449,249 @@ def round_to_nearest_minute(self, dt: datetime) -> datetime:
return (dt.replace(second=0, microsecond=0)
+ timedelta(minutes=1) if dt.second >= 30 else dt.replace(second=0, microsecond=0))

def get_current_url(self):
return self.sb.get_current_url()

def normalize_values(self, values):
"""Convert values into appropriate comparable formats.

- Numeric strings → floats
- Phone numbers (10 digits) → keep as strings
- Everything else stays as string (date detection happens later)
"""
cleaned = [v for v in values if v is not None]

if not cleaned:
return cleaned

# Phone number pattern: 10 digits
if all(v.isdigit() and len(v) == 10 for v in cleaned):
return cleaned # keep as strings

# Try converting to floats
numeric_converted = []
for v in cleaned:
s = str(v)
try:
numeric_converted.append(float(s.replace(",", "")))
except ValueError:
return cleaned # at least one is not numeric → fallback to strings

return numeric_converted

def try_parse_datetime(self, value):
"""Try parsing strings like 'Dec 10 19:53:15'. Return datetime or None."""
formats = [
"%b %d %H:%M:%S", # Dec 10 19:53:15
"%b %d %Y %H:%M:%S" # Dec 10 2023 19:53:15 (if year added later)
]

for fmt in formats:
try:
return datetime.strptime(value, fmt)
except ValueError:
pass

return None

def is_sorted(self, final_values, sorted_as):
"""Automatically detect type: datetime, number, or string."""

if not final_values:
return

reverse = (sorted_as == "descending")

# 1️⃣ Check if values are datetime strings
parsed_dates = []
date_mode = True

for v in final_values:
dt = self.try_parse_datetime(str(v))
if dt is None:
date_mode = False
break
parsed_dates.append(dt)

if date_mode:
expected = sorted(parsed_dates, reverse=reverse)
if parsed_dates != expected:
print("ACTUAL datetimes :", parsed_dates)
print("EXPECTED datetimes:", expected)
raise AssertionError(f"Date column NOT sorted {sorted_as}")
return

# 2️⃣ Numeric?
if isinstance(final_values[0], (int, float)):
expected = sorted(final_values, reverse=reverse)
else:
# 3️⃣ Default: case-insensitive string
expected = sorted(final_values, key=lambda s: str(s).casefold(), reverse=reverse)

if final_values != expected:
print("ACTUAL :", final_values)
print("EXPECTED:", expected)
raise AssertionError(f"Column NOT sorted {sorted_as}")

def _get_column_values(self, col_index: int):
rows = self.find_elements_raw("//*[contains(@class,'k-table-row')]", by="xpath")
print(f"DEBUG: found {len(rows)} rows for column {col_index}")

values = []
for i, row in enumerate(rows, start=1):
cells = row.find_elements(By.XPATH, ".//*[contains(@class,'k-table-td')]")
print(f" Row {i}: {len(cells)} cells")
if len(cells) < col_index:
continue
txt = (cells[col_index - 1].text or "").strip()
if txt:
values.append(txt)

return self.normalize_values(values) if values else []

def kendo_multiselect_clear_all(self, input_logical_name: str, timeout: int = 10) -> None:
"""
Clear all selected values from a Kendo MultiSelect.

Pass the SAME logical name you use for kendo_select's input, e.g.:
self.kendo_multiselect_clear_all("k-input_Patient_Manager")
"""

sel = self.resolve(input_logical_name)
inp = self._get_webelement(sel, timeout=timeout)

# Put focus inside the control so Kendo is "awake"
try:
inp.click()
except Exception:
pass

# Use your MultiSelect root helper
root = self._ms_root(inp)
wait = WebDriverWait(self.driver, timeout)

def chips():
# All selected tags inside this multiselect
return root.find_elements(
By.CSS_SELECTOR,
".k-chip-list .k-chip"
)

def remove_buttons():
# All possible "x" icons / remove actions inside chips
return root.find_elements(
By.CSS_SELECTOR,
(
".k-chip-remove-action, " # ✅ your class
".k-chip-remove, "
".k-chip .k-icon.k-i-close, "
".k-chip .k-button-icon, "
".k-chip .k-select"
)
)

# --- Try a global clear icon on the widget, if present ---
clear_btns = root.find_elements(
By.CSS_SELECTOR,
".k-clear-value, .k-multiselect-clearable .k-clear-value"
)
if clear_btns:
try:
clear_btns[0].click()
try:
wait.until(lambda d: len(chips()) == 0)
return
except TimeoutException:
# If still not empty, fall back to per-chip removal
pass
except Exception:
pass

# --- Fallback: click each chip's remove action one by one ---
end = time.time() + timeout
while time.time() < end:
btns = remove_buttons()
if not btns:
break

btn = btns[0]
try:
# optional: visual debug
try:
self.sb.highlight(btn)
except Exception:
pass

btn.click()
except StaleElementReferenceException:
# DOM changed between find & click; retry in next loop
continue
except Exception:
# JS click fallback
try:
self.driver.execute_script("arguments[0].click();", btn)
except Exception:
break

time.sleep(0.1) # let DOM update

# Final soft wait; don't hard-fail the test here
try:
wait.until(lambda d: len(chips()) == 0)
except TimeoutException:
print(
f"⚠ kendo_multiselect_clear_all('{input_logical_name}'): "
f"some chips may still remain ({len(chips())} left)"
)

def get_li_items(self, ul_logical_name: str, timeout: int = 10) -> list[str]:
"""
Given a locator for a <ul>, return text of all <li> elements under it.
"""

sel = self.resolve(ul_logical_name)
try:
ul = self._get_webelement(sel, timeout=timeout)
except TimeoutException:
return []

lis = ul.find_elements(By.TAG_NAME, "li")
values = [li.text.strip() for li in lis if li.text.strip()]

print(f"[UL → LI VALUES] {values}")
return values

def assert_list_contains_only(self, actual: list, expected: list):
"""
Assert that actual list contains ONLY expected values (order-independent).
"""
actual_set = set(actual)
expected_set = set(expected)

missing = expected_set - actual_set
extra = actual_set - expected_set

assert not missing and not extra, (
f"List contents mismatch.\n"
f"Missing: {sorted(missing)}\n"
f"Extra : {sorted(extra)}\n"
f"Actual : {actual}"
)

def get_elements_texts(self, logical_name: str, timeout: int = 10) -> list[str]:
"""
Given a logical locator that matches multiple elements,
return text of all matching elements.
"""

sel = self.resolve(logical_name)

try:
elements = self.find_elements(logical_name, timeout=timeout)
except TimeoutException:
return []

values = [el.text.strip() for el in elements if el.text and el.text.strip()]

print(f"[ELEMENT TEXTS → {logical_name}] {values}")
return values
Loading
Loading