Initial QSfera import
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import pyautogui
|
||||
import psutil
|
||||
import threading
|
||||
from appium.webdriver import Remote, WebElement
|
||||
from appium.options.common.base import AppiumOptions
|
||||
from appium.webdriver.common.appiumby import AppiumBy as By
|
||||
from selenium.common.exceptions import WebDriverException, NoSuchElementException
|
||||
|
||||
from helpers.ConfigHelper import get_config, get_app_env
|
||||
from helpers.ElementHelper import get_element_center_xy
|
||||
from helpers.keys.keys_map import get_key
|
||||
|
||||
|
||||
def native_click(self, **kwargs):
|
||||
x, y = get_element_center_xy(self)
|
||||
win_x, win_y = get_window_location()
|
||||
if x < win_x:
|
||||
x = x + win_x
|
||||
if y < win_y:
|
||||
y = y + win_y
|
||||
pyautogui.click(x, y, **kwargs)
|
||||
|
||||
|
||||
def native_double_click(self, **kwargs):
|
||||
x, y = get_element_center_xy(self)
|
||||
win_x, win_y = get_window_location()
|
||||
if x < win_x:
|
||||
x = x + win_x
|
||||
if y < win_y:
|
||||
y = y + win_y
|
||||
pyautogui.doubleClick(x, y, **kwargs)
|
||||
|
||||
|
||||
def native_send_keys(self, key):
|
||||
pyautogui.press(get_key(key))
|
||||
|
||||
|
||||
def find_element(self, by, selector):
|
||||
"""
|
||||
Returns a visible element.
|
||||
Throws if no elements are found or if multiple visible elements are found.
|
||||
"""
|
||||
elements = self.find_elements(by, selector)
|
||||
elements_count = len(elements)
|
||||
if elements_count > 1:
|
||||
visible_elements = [el for el in elements if el.is_displayed()]
|
||||
if len(visible_elements) == 1:
|
||||
return visible_elements.pop()
|
||||
raise WebDriverException(
|
||||
f'Found {elements_count} elements using "{by}={selector}"'
|
||||
)
|
||||
if elements_count == 0:
|
||||
raise NoSuchElementException(f'No element found for "{by}={selector}"')
|
||||
return elements[0]
|
||||
|
||||
|
||||
def pause(self):
|
||||
threading.Event().wait()
|
||||
|
||||
|
||||
# bind custom element methods
|
||||
Remote.find_element = find_element
|
||||
Remote.pause = pause
|
||||
WebElement.native_click = native_click
|
||||
WebElement.native_double_click = native_double_click
|
||||
WebElement.native_send_keys = native_send_keys
|
||||
WebElement.find_element = find_element
|
||||
|
||||
app_driver = None
|
||||
|
||||
|
||||
def app():
|
||||
return app_driver
|
||||
|
||||
|
||||
def create_app_session():
|
||||
global app_driver
|
||||
logfile = get_config("currentAppLogFile")
|
||||
command_args = f' --logfile {logfile}'
|
||||
|
||||
options = AppiumOptions()
|
||||
options.set_capability(
|
||||
'app',
|
||||
f'{get_config("app_path")} -s {command_args} --logdebug',
|
||||
)
|
||||
options.set_capability('appium:environ', get_app_env())
|
||||
app_driver = Remote(command_executor='http://localhost:4723', options=options)
|
||||
app_driver.implicitly_wait = 10
|
||||
|
||||
|
||||
def close_and_kill_app():
|
||||
"""
|
||||
Close Appium session and kill the desktop client process.
|
||||
Use this for both mid-scenario and end-of-scenario cleanup.
|
||||
"""
|
||||
global app_driver
|
||||
# Quit Appium session
|
||||
if app_driver is not None:
|
||||
app_driver.quit()
|
||||
|
||||
# Kill remaining process by exe path
|
||||
app_path = get_config("app_path")
|
||||
for process in psutil.process_iter(['pid', 'exe']):
|
||||
if process.info['exe'] == app_path:
|
||||
print("Closing desktop client...")
|
||||
psutil.Process(process.info['pid']).kill()
|
||||
break
|
||||
|
||||
# Reset driver for reuse
|
||||
app_driver = None
|
||||
|
||||
|
||||
def get_window_location():
|
||||
window = (
|
||||
app()
|
||||
.find_element(By.XPATH, "//*[contains(@name,'КуСфера')]")
|
||||
.location
|
||||
)
|
||||
return window['x'], window['y']
|
||||
@@ -0,0 +1,185 @@
|
||||
import os
|
||||
import platform
|
||||
import builtins
|
||||
import tempfile
|
||||
from tempfile import gettempdir
|
||||
from configparser import ConfigParser
|
||||
from pathlib import Path
|
||||
|
||||
CURRENT_DIR = Path(__file__).resolve().parent
|
||||
APP_CONFIG_FILE = "qsfera.cfg"
|
||||
CUMULATIVE_APP_LOG_FILE = "qsfera.log"
|
||||
CURRENT_APP_LOG_FILE = "app.log"
|
||||
|
||||
|
||||
def is_windows():
|
||||
return platform.system() == 'Windows'
|
||||
|
||||
|
||||
def is_linux():
|
||||
return platform.system() == 'Linux'
|
||||
|
||||
|
||||
def get_win_user_home():
|
||||
return os.environ.get('USERPROFILE', '')
|
||||
|
||||
|
||||
def get_client_root_path():
|
||||
if is_windows():
|
||||
return os.path.join(get_win_user_home(), 'qsferatest')
|
||||
return os.path.join(gettempdir(), 'qsferatest')
|
||||
|
||||
|
||||
def get_config_home_linux():
|
||||
return os.path.join(tempfile.gettempdir(), 'qsferatest', '.config')
|
||||
|
||||
|
||||
def get_config_home_win():
|
||||
return os.path.join(
|
||||
get_win_user_home(), 'AppData', 'Local', 'Temp', 'qsferatest', '.config'
|
||||
)
|
||||
|
||||
|
||||
def get_config_home():
|
||||
if is_windows():
|
||||
return get_config_home_win()
|
||||
return get_config_home_linux()
|
||||
|
||||
|
||||
def get_default_home_dir():
|
||||
if is_windows():
|
||||
return get_win_user_home()
|
||||
return os.environ.get('HOME')
|
||||
|
||||
|
||||
def get_app_env():
|
||||
return {
|
||||
'XDG_CONFIG_HOME': get_config_home(),
|
||||
'APPDATA': get_config_home(),
|
||||
}
|
||||
|
||||
|
||||
# map environment variables to config keys
|
||||
CONFIG_ENV_MAP = {
|
||||
'app_path': 'APP_PATH',
|
||||
'localBackendUrl': 'BACKEND_HOST',
|
||||
'maxSyncTimeout': 'MAX_SYNC_TIMEOUT',
|
||||
'minSyncTimeout': 'MIN_SYNC_TIMEOUT',
|
||||
'lowestSyncTimeout': 'LOWEST_SYNC_TIMEOUT',
|
||||
'clientRootSyncPath': 'CLIENT_ROOT_SYNC_PATH',
|
||||
'tempFolderPath': 'TEMP_FOLDER_PATH',
|
||||
'guiTestReportDir': 'GUI_TEST_REPORT_DIR',
|
||||
'record_video_on_failure': 'RECORD_VIDEO_ON_FAILURE',
|
||||
}
|
||||
|
||||
DEFAULT_PATH_CONFIG = {
|
||||
'custom_lib': os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), 'custom_lib')
|
||||
),
|
||||
'home_dir': get_default_home_dir(),
|
||||
# allow to record first 5 videos
|
||||
'video_record_limit': 5,
|
||||
'app_path': None,
|
||||
}
|
||||
|
||||
# default config values
|
||||
CONFIG = {
|
||||
'localBackendUrl': 'https://localhost:9200/',
|
||||
'maxSyncTimeout': 60,
|
||||
'minSyncTimeout': 5,
|
||||
'lowestSyncTimeout': 1,
|
||||
'clientRootSyncPath': get_client_root_path(),
|
||||
'clientConfigFile': os.path.join(get_config_home(), "QSfera", APP_CONFIG_FILE),
|
||||
'guiTestReportDir': os.path.join(CURRENT_DIR.parent, 'reports'),
|
||||
'tempFolderPath': os.path.join(get_client_root_path(), 'temp'),
|
||||
'record_video_on_failure': False,
|
||||
'files_for_upload': os.path.join(CURRENT_DIR.parent, 'files-for-upload'),
|
||||
'syncConnectionName': 'Personal',
|
||||
}
|
||||
|
||||
# Permission roles mapping
|
||||
PERMISSION_ROLES = {
|
||||
'Viewer': 'b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5',
|
||||
'Editor': 'fb6c3e19-e378-47e5-b277-9732f9de6e21',
|
||||
}
|
||||
|
||||
CONFIG.update(DEFAULT_PATH_CONFIG)
|
||||
|
||||
READONLY_CONFIG = list(CONFIG_ENV_MAP.keys()) + list(DEFAULT_PATH_CONFIG.keys())
|
||||
|
||||
|
||||
def read_cfg_file(cfg_path):
|
||||
cfg = ConfigParser()
|
||||
if cfg.read(cfg_path):
|
||||
for key, _ in CONFIG.items():
|
||||
if key in CONFIG_ENV_MAP:
|
||||
if value := cfg.get('DEFAULT', CONFIG_ENV_MAP[key]):
|
||||
if key == 'record_video_on_failure':
|
||||
CONFIG[key] = value == 'true'
|
||||
else:
|
||||
CONFIG[key] = value
|
||||
|
||||
|
||||
def init_config():
|
||||
# try reading configs from config.ini
|
||||
try:
|
||||
cfg_path = os.path.abspath(os.path.join(CURRENT_DIR.parent, 'config.ini'))
|
||||
read_cfg_file(cfg_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
# read and override configs from environment variables
|
||||
for key, value in CONFIG_ENV_MAP.items():
|
||||
if os.environ.get(value):
|
||||
if key == 'record_video_on_failure':
|
||||
CONFIG[key] = os.environ.get(value) == 'true'
|
||||
else:
|
||||
CONFIG[key] = os.environ.get(value)
|
||||
|
||||
# Set the default values if empty
|
||||
for key, value in CONFIG.items():
|
||||
if key in ('maxSyncTimeout', 'minSyncTimeout'):
|
||||
CONFIG[key] = builtins.int(value)
|
||||
elif key == 'localBackendUrl':
|
||||
# make sure there is always one trailing slash
|
||||
CONFIG[key] = value.rstrip('/') + '/'
|
||||
elif key in (
|
||||
'clientRootSyncPath',
|
||||
'tempFolderPath',
|
||||
'guiTestReportDir',
|
||||
):
|
||||
# make sure there is always one trailing slash
|
||||
if is_windows():
|
||||
value = value.replace('/', '\\')
|
||||
CONFIG[key] = value.rstrip('\\') + '\\'
|
||||
else:
|
||||
CONFIG[key] = value.rstrip('/') + '/'
|
||||
|
||||
if 'app_path' not in CONFIG or not CONFIG['app_path']:
|
||||
raise KeyError('APP_PATH must be set in config.ini or environment variables')
|
||||
if not os.path.exists(CONFIG['app_path']):
|
||||
raise KeyError(f'App not found: {CONFIG["app_path"]}')
|
||||
|
||||
### initialize dynamic config values
|
||||
# file to store app logs for the current scenario run
|
||||
CONFIG['currentAppLogFile'] = os.path.join(
|
||||
CONFIG["guiTestReportDir"], CURRENT_APP_LOG_FILE
|
||||
)
|
||||
# file to store cumulative app logs for the entire test run
|
||||
CONFIG['appLogFile'] = os.path.join(
|
||||
CONFIG["guiTestReportDir"], CUMULATIVE_APP_LOG_FILE
|
||||
)
|
||||
# create report dir if it not exist
|
||||
if not os.path.exists(CONFIG['guiTestReportDir']):
|
||||
os.makedirs(CONFIG['guiTestReportDir'])
|
||||
CONFIG['currentUserSyncPath'] = ''
|
||||
|
||||
|
||||
def get_config(key):
|
||||
return CONFIG[key]
|
||||
|
||||
|
||||
def set_config(key, value):
|
||||
if key in READONLY_CONFIG:
|
||||
raise KeyError(f'Cannot set read-only config: {key}')
|
||||
CONFIG[key] = value
|
||||
@@ -0,0 +1,5 @@
|
||||
def get_element_center_xy(element):
|
||||
rect = element.rect
|
||||
x = int(rect['x'] + (rect['width'] // 2))
|
||||
y = int(rect['y'] + (rect['height'] // 2))
|
||||
return x, y
|
||||
@@ -0,0 +1,188 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from pypdf import PdfReader
|
||||
from docx import Document
|
||||
from pptx import Presentation
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from helpers.ConfigHelper import is_windows, get_config
|
||||
|
||||
|
||||
def build_conflicted_regex(filename):
|
||||
if "." in filename:
|
||||
# TODO: improve this for complex filenames
|
||||
namepart = filename.split(".")[0]
|
||||
extpart = filename.split(".")[1]
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
return rf"{namepart} \(conflicted copy \d{{4}}-\d{{2}}-\d{{2}} \d{{6}}\)\.{extpart}"
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
return rf"{filename} \(conflicted copy \d{{4}}-\d{{2}}-\d{{2}} \d{{6}}\)"
|
||||
|
||||
|
||||
def sanitize_path(path):
|
||||
return path.replace("//", "/")
|
||||
|
||||
|
||||
def prefix_path_namespace(path):
|
||||
if is_windows():
|
||||
# https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file?redirectedfrom=MSDN#win32-file-namespaces
|
||||
# disable string parsing
|
||||
# - long path
|
||||
# - trailing whitespaces
|
||||
return f"\\\\?\\{path}"
|
||||
return path
|
||||
|
||||
|
||||
def can_read(resource):
|
||||
read = False
|
||||
try:
|
||||
with open(resource, encoding="utf-8") as f:
|
||||
read = True
|
||||
except:
|
||||
pass
|
||||
return read and os.access(resource, os.R_OK)
|
||||
|
||||
|
||||
def can_write(resource):
|
||||
write = False
|
||||
try:
|
||||
with open(resource, "w", encoding="utf-8") as f:
|
||||
write = True
|
||||
except:
|
||||
pass
|
||||
return write and os.access(resource, os.W_OK)
|
||||
|
||||
|
||||
def read_file_content(file):
|
||||
with open(file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return content
|
||||
|
||||
|
||||
def is_empty_sync_folder(folder):
|
||||
ignore_files = ["Desktop.ini"]
|
||||
for item in os.listdir(folder):
|
||||
# do not count the hidden files as they are ignored by the client
|
||||
if not item.startswith(".") and not item in ignore_files:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_size_in_bytes(size):
|
||||
match = re.match(r"(\d+)((?: )?[KkMmGgBb]{0,2})?", str(size))
|
||||
units = ["b", "kb", "mb", "gb"]
|
||||
multiplier = 1024
|
||||
if match:
|
||||
size_num = int(match.group(1))
|
||||
size_unit = match.group(2)
|
||||
|
||||
if not (size_unit := size_unit.lower()):
|
||||
return size_num
|
||||
if size_unit in units:
|
||||
if size_unit == "b":
|
||||
return size_num
|
||||
if size_unit == "kb":
|
||||
return size_num * multiplier
|
||||
if size_unit == "mb":
|
||||
return size_num * (multiplier**2)
|
||||
if size_unit == "gb":
|
||||
return size_num * (multiplier**3)
|
||||
|
||||
raise ValueError("Invalid size: " + size)
|
||||
|
||||
|
||||
def get_file_size(resource_path):
|
||||
return os.stat(resource_path).st_size
|
||||
|
||||
|
||||
# temp paths created outside the temporary directory during the test
|
||||
CREATED_PATHS = []
|
||||
|
||||
|
||||
def remember_path(path):
|
||||
CREATED_PATHS.append(path)
|
||||
|
||||
|
||||
def cleanup_created_paths():
|
||||
global CREATED_PATHS
|
||||
for path in CREATED_PATHS:
|
||||
if os.path.exists(path):
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(prefix_path_namespace(path))
|
||||
else:
|
||||
os.unlink(prefix_path_namespace(path))
|
||||
CREATED_PATHS = []
|
||||
|
||||
|
||||
def get_file_for_upload(file_name):
|
||||
base_upload_dir = get_config("files_for_upload")
|
||||
return os.path.join(base_upload_dir, file_name)
|
||||
|
||||
|
||||
def convert_path_separators_for_os(path):
|
||||
"""
|
||||
Convert path separators to match the current operating system.
|
||||
On Windows, converts forward slashes to backslashes.
|
||||
On other systems, returns the path unchanged.
|
||||
"""
|
||||
if is_windows():
|
||||
return path.replace("/", "\\")
|
||||
return path
|
||||
|
||||
|
||||
def get_pdf_content(pdf_file):
|
||||
reader = PdfReader(pdf_file)
|
||||
content = ""
|
||||
for page in reader.pages:
|
||||
if page_text := page.extract_text():
|
||||
content += page_text
|
||||
return content
|
||||
|
||||
|
||||
def get_docs_content(docs_file):
|
||||
doc = Document(docs_file)
|
||||
content = "\n".join(p.text for p in doc.paragraphs)
|
||||
return content
|
||||
|
||||
|
||||
def get_presentation_content(ppt_file):
|
||||
presentation = Presentation(ppt_file)
|
||||
text = []
|
||||
for slide in presentation.slides:
|
||||
for shape in slide.shapes:
|
||||
if hasattr(shape, "text"):
|
||||
text.append(shape.text)
|
||||
return "\n".join(text)
|
||||
|
||||
|
||||
def get_excel_content(excel_file):
|
||||
# parse with read_only mode
|
||||
workbook = load_workbook(excel_file, read_only=True, data_only=True)
|
||||
text = []
|
||||
for sheet in workbook.worksheets:
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
for cell in row:
|
||||
if cell is not None:
|
||||
text.append(str(cell))
|
||||
return "\n".join(text)
|
||||
|
||||
|
||||
def get_document_content(document):
|
||||
content = ""
|
||||
doc_ext = Path(document).suffix.lower().lstrip(".")
|
||||
if doc_ext == "pdf":
|
||||
content = get_pdf_content(document)
|
||||
elif doc_ext == "docx":
|
||||
content = get_docs_content(document)
|
||||
elif doc_ext == "pptx":
|
||||
content = get_presentation_content(document)
|
||||
elif doc_ext == "xlsx":
|
||||
content = get_excel_content(document)
|
||||
elif doc_ext in ["txt", "md"]:
|
||||
with open(document, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
else:
|
||||
raise ValueError(f"Unsupported document format: {doc_ext}")
|
||||
return content
|
||||
@@ -0,0 +1,69 @@
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import test
|
||||
|
||||
from helpers.ConfigHelper import get_config
|
||||
from helpers.FilesHelper import prefix_path_namespace
|
||||
|
||||
|
||||
def get_screenrecords_path():
|
||||
return os.path.join(get_config("guiTestReportDir"), "screenrecords")
|
||||
|
||||
|
||||
def get_screenshots_path():
|
||||
return os.path.join(get_config("guiTestReportDir"), "screenshots")
|
||||
|
||||
|
||||
def is_video_enabled():
|
||||
return get_config("record_video_on_failure") and not reached_video_limit()
|
||||
|
||||
|
||||
def reached_video_limit():
|
||||
video_report_dir = get_screenrecords_path()
|
||||
if not os.path.exists(video_report_dir):
|
||||
return False
|
||||
entries = [f for f in os.scandir(video_report_dir) if f.is_file()]
|
||||
return len(entries) >= get_config("video_record_limit")
|
||||
|
||||
|
||||
def save_video_recording(filename, test_failed):
|
||||
try:
|
||||
# do not throw if stopVideoCapture() fails
|
||||
test.stopVideoCapture()
|
||||
except:
|
||||
test.log("Failed to stop screen recording")
|
||||
|
||||
if not (video_dir := squishinfo.resultDir):
|
||||
video_dir = squishinfo.testCase
|
||||
else:
|
||||
test_case = "/".join(squishinfo.testCase.split("/")[-2:])
|
||||
video_dir = os.path.join(video_dir, test_case)
|
||||
video_dir = os.path.join(video_dir, "attachments")
|
||||
|
||||
# if the test failed
|
||||
# move videos to the screenrecords directory
|
||||
if test_failed:
|
||||
video_files = glob.glob(f"{video_dir}/**/*.mp4", recursive=True)
|
||||
screenrecords_dir = get_screenrecords_path()
|
||||
if not os.path.exists(screenrecords_dir):
|
||||
os.makedirs(screenrecords_dir)
|
||||
# reverse the list to get the latest video first
|
||||
video_files.reverse()
|
||||
for idx, video in enumerate(video_files):
|
||||
if idx:
|
||||
file_parts = filename.rsplit(".", 1)
|
||||
filename = f"{file_parts[0]}_{idx+1}.{file_parts[1]}"
|
||||
shutil.move(video, os.path.join(screenrecords_dir, filename))
|
||||
# remove the video directory
|
||||
shutil.rmtree(prefix_path_namespace(video_dir))
|
||||
|
||||
|
||||
def take_screenshot(filename):
|
||||
directory = get_screenshots_path()
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
try:
|
||||
squish.saveDesktopScreenshot(os.path.join(directory, filename))
|
||||
except:
|
||||
test.log("Failed to save screenshot")
|
||||
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import mss
|
||||
import numpy as np
|
||||
import imageio_ffmpeg
|
||||
from datetime import datetime
|
||||
|
||||
from helpers.ConfigHelper import get_config
|
||||
|
||||
|
||||
_recording_thread = None
|
||||
_stop_event = threading.Event()
|
||||
_video_path = None
|
||||
|
||||
|
||||
def _build_video_path(scenario):
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9_]", "_", scenario.name)
|
||||
timestamp = datetime.now().strftime("%d-%b-%Y_%H-%M-%S")
|
||||
|
||||
recordings_dir = os.path.join(get_config("guiTestReportDir"), "recordings")
|
||||
os.makedirs(recordings_dir, exist_ok=True)
|
||||
|
||||
return os.path.join(recordings_dir, f"{safe_name}_{timestamp}.mp4")
|
||||
|
||||
|
||||
def _record_loop(video_path):
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0]
|
||||
width, height = monitor["width"], monitor["height"]
|
||||
|
||||
writer = imageio_ffmpeg.write_frames(
|
||||
video_path,
|
||||
size=(width, height),
|
||||
fps=24,
|
||||
codec="libx264",
|
||||
output_params=["-crf", "23", "-pix_fmt", "yuv420p"],
|
||||
)
|
||||
writer.send(None)
|
||||
|
||||
interval = 1.0 / 24 # 1/24 seconds between each frame so we get 24 frames per second
|
||||
next_frame_at = time.monotonic()
|
||||
|
||||
while not _stop_event.is_set():
|
||||
frame = sct.grab(monitor)
|
||||
# mss gives BGRA — drop alpha, flip B and R channels to get RGB
|
||||
rgb = np.flip(np.array(frame)[:, :, :3], axis=2).tobytes()
|
||||
writer.send(rgb)
|
||||
|
||||
next_frame_at += interval
|
||||
sleep_for = next_frame_at - time.monotonic()
|
||||
if sleep_for > 0:
|
||||
time.sleep(sleep_for)
|
||||
|
||||
writer.close()
|
||||
|
||||
|
||||
def start_recording(scenario):
|
||||
global _recording_thread, _video_path
|
||||
|
||||
_video_path = _build_video_path(scenario)
|
||||
_stop_event.clear()
|
||||
|
||||
_recording_thread = threading.Thread(target=_record_loop, args=(_video_path,), daemon=True)
|
||||
_recording_thread.start()
|
||||
|
||||
|
||||
def stop_recording(passed):
|
||||
global _recording_thread, _video_path
|
||||
|
||||
if _recording_thread is None:
|
||||
return
|
||||
|
||||
_stop_event.set()
|
||||
_recording_thread.join()
|
||||
_recording_thread = None
|
||||
|
||||
if passed and os.path.exists(_video_path):
|
||||
os.remove(_video_path)
|
||||
|
||||
_video_path = None
|
||||
@@ -0,0 +1,227 @@
|
||||
import uuid
|
||||
import os
|
||||
import subprocess
|
||||
import test
|
||||
from urllib.parse import urlparse
|
||||
from os import makedirs
|
||||
from os.path import exists, join
|
||||
from PySide6.QtCore import QSettings, QUuid, QUrl, QJsonValue
|
||||
|
||||
from helpers.SpaceHelper import get_space_id, get_personal_space_id
|
||||
from helpers.ConfigHelper import get_config, set_config, is_windows
|
||||
from helpers.SyncHelper import listen_sync_status_for_item
|
||||
from helpers.api.utils import url_join
|
||||
from helpers.UserHelper import get_displayname_for_user
|
||||
from helpers.api import provisioning
|
||||
from helpers.AppHelper import create_app_session
|
||||
|
||||
|
||||
def substitute_inline_codes(value):
|
||||
value = value.replace('%local_server%', get_config('localBackendUrl'))
|
||||
value = value.replace('%client_root_sync_path%', get_config('clientRootSyncPath'))
|
||||
value = value.replace('%current_user_sync_path%', get_config('currentUserSyncPath'))
|
||||
value = value.replace(
|
||||
'%local_server_hostname%', urlparse(get_config('localBackendUrl')).netloc
|
||||
)
|
||||
value = value.replace('%home%', get_config('home_dir'))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def get_client_details(table):
|
||||
client_details = {
|
||||
'server': '',
|
||||
'user': '',
|
||||
'password': '',
|
||||
'sync_folder': '',
|
||||
}
|
||||
for key, value in table.items():
|
||||
value = substitute_inline_codes(value)
|
||||
if key == 'server':
|
||||
client_details.update({'server': value})
|
||||
elif key == 'user':
|
||||
client_details.update({'user': value})
|
||||
elif key == 'password':
|
||||
client_details.update({'password': value})
|
||||
elif key == 'sync_folder':
|
||||
client_details.update({'sync_folder': value})
|
||||
return client_details
|
||||
|
||||
|
||||
def create_user_sync_path(username):
|
||||
# '' at the end adds '/' to the path
|
||||
user_sync_path = join(get_config('clientRootSyncPath'), username, '')
|
||||
|
||||
if not exists(user_sync_path):
|
||||
makedirs(user_sync_path)
|
||||
|
||||
set_current_user_sync_path(user_sync_path)
|
||||
return user_sync_path
|
||||
|
||||
|
||||
def create_space_path(username, space='Personal'):
|
||||
user_sync_path = create_user_sync_path(username)
|
||||
space_path = join(user_sync_path, space, '')
|
||||
if not exists(space_path):
|
||||
makedirs(space_path)
|
||||
return space_path
|
||||
|
||||
|
||||
def set_current_user_sync_path(sync_path):
|
||||
set_config('currentUserSyncPath', sync_path)
|
||||
|
||||
|
||||
def get_resource_path(resource='', user='', space=''):
|
||||
sync_path = get_config('currentUserSyncPath')
|
||||
if user:
|
||||
sync_path = user
|
||||
space = space or get_config('syncConnectionName')
|
||||
sync_path = join(sync_path, space)
|
||||
sync_path = join(get_config('clientRootSyncPath'), sync_path)
|
||||
resource = resource.replace(sync_path, '').strip('/').strip('\\')
|
||||
return join(
|
||||
sync_path,
|
||||
resource,
|
||||
)
|
||||
|
||||
|
||||
def parse_username_from_sync_path(sync_path):
|
||||
return sync_path.split('/').pop()
|
||||
|
||||
|
||||
def get_temp_resource_path(resource_name):
|
||||
return join(get_config('tempFolderPath'), resource_name)
|
||||
|
||||
|
||||
def get_current_user_sync_path():
|
||||
return get_config('currentUserSyncPath')
|
||||
|
||||
|
||||
def start_client():
|
||||
create_app_session()
|
||||
|
||||
|
||||
def get_polling_interval():
|
||||
polling_interval = '''
|
||||
[QSfera]
|
||||
remotePollInterval={polling_interval}
|
||||
'''
|
||||
args = {'polling_interval': 5000}
|
||||
polling_interval = polling_interval.format(**args)
|
||||
return polling_interval
|
||||
|
||||
|
||||
def generate_account_config(users, space='Personal'):
|
||||
sync_paths = {}
|
||||
settings = QSettings(get_config('clientConfigFile'), QSettings.Format.IniFormat)
|
||||
users_uuids = {}
|
||||
server_url = get_config('localBackendUrl')
|
||||
capabilities = provisioning.get_capabilities()
|
||||
capabilities_variant = QJsonValue(capabilities).toVariant()
|
||||
|
||||
for idx, username in enumerate(users):
|
||||
users_uuids[username] = QUuid.createUuid()
|
||||
settings.beginGroup("Accounts")
|
||||
settings.beginWriteArray(str(idx + 1), len(users))
|
||||
|
||||
settings.setValue("capabilities", capabilities_variant)
|
||||
settings.setValue("default_sync_root", create_user_sync_path(username))
|
||||
settings.setValue("uuid", users_uuids[username])
|
||||
settings.setValue("display-name", get_displayname_for_user(username))
|
||||
settings.setValue("url", server_url)
|
||||
settings.setValue("userExplicitlySignedOut", 'false')
|
||||
|
||||
settings.endArray()
|
||||
settings.setValue("size", len(users))
|
||||
settings.endGroup()
|
||||
|
||||
settings.beginGroup("Folders")
|
||||
for idx, username in enumerate(users):
|
||||
sync_path = create_space_path(username, space)
|
||||
settings.beginWriteArray(str(idx + 1), len(users))
|
||||
|
||||
if space == 'Personal':
|
||||
space_id = get_personal_space_id(username)
|
||||
else:
|
||||
space_id = get_space_id(space, username)
|
||||
dav_endpoint = QUrl(url_join(server_url, '/dav/spaces/', space_id))
|
||||
settings.setValue("spaceId", space_id)
|
||||
settings.setValue("accountUUID", users_uuids[username])
|
||||
settings.setValue("davUrl", dav_endpoint)
|
||||
settings.setValue("deployed", 'false')
|
||||
settings.setValue("displayString", get_config('syncConnectionName'))
|
||||
settings.setValue("ignoreHiddenFiles", 'true')
|
||||
settings.setValue("localPath", sync_path)
|
||||
settings.setValue("paused", 'false')
|
||||
settings.setValue("priority", '50')
|
||||
if is_windows():
|
||||
settings.setValue("virtualFilesMode", 'cfapi')
|
||||
else:
|
||||
settings.setValue("virtualFilesMode", 'off')
|
||||
settings.setValue("journalPath", ".sync_journal.db")
|
||||
settings.endArray()
|
||||
settings.setValue("size", len(users))
|
||||
sync_paths.update({username: sync_path})
|
||||
|
||||
settings.endGroup()
|
||||
|
||||
settings.sync()
|
||||
return sync_paths
|
||||
|
||||
|
||||
def setup_client(username, space='Personal'):
|
||||
set_config('syncConnectionName', space)
|
||||
sync_paths = generate_account_config([username], space)
|
||||
start_client()
|
||||
for _, sync_path in sync_paths.items():
|
||||
listen_sync_status_for_item(sync_path)
|
||||
|
||||
|
||||
def generate_uuidv4():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
# sometimes the keyring is locked during the test execution, and we need to unlock it
|
||||
def unlock_keyring():
|
||||
if is_windows():
|
||||
return
|
||||
|
||||
stdout, stderr, _ = run_sys_command(
|
||||
[
|
||||
'busctl',
|
||||
'--user',
|
||||
'get-property',
|
||||
'org.freedesktop.secrets',
|
||||
'/org/freedesktop/secrets/collection/login',
|
||||
'org.freedesktop.Secret.Collection',
|
||||
'Locked',
|
||||
]
|
||||
)
|
||||
output = ''
|
||||
if stdout:
|
||||
output = stdout.decode('utf-8')
|
||||
if stderr:
|
||||
output = stderr.decode('utf-8')
|
||||
|
||||
if not output.strip().endswith('false'):
|
||||
test.log('Unlocking keyring...')
|
||||
password = os.getenv('VNC_PW')
|
||||
command = f'echo -n "{password}" | gnome-keyring-daemon -r -d --unlock'
|
||||
stdout, stderr, returncode = run_sys_command(command, True)
|
||||
if stdout:
|
||||
output = stdout.decode('utf-8')
|
||||
if stderr:
|
||||
output = stderr.decode('utf-8')
|
||||
if returncode:
|
||||
test.log(f'Failed to unlock keyring:\n{output}')
|
||||
|
||||
|
||||
def run_sys_command(command=None, shell=False):
|
||||
cmd = subprocess.run(
|
||||
command,
|
||||
shell=shell,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
return cmd.stdout, cmd.stderr, cmd.returncode
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
from urllib import parse
|
||||
|
||||
from helpers.ConfigHelper import get_config
|
||||
from helpers.api.utils import url_join
|
||||
import helpers.api.http_helper as request
|
||||
|
||||
created_spaces = {}
|
||||
user_spaces = {}
|
||||
space_role = ['manager', 'editor', 'viewer']
|
||||
|
||||
|
||||
def get_space_endpint():
|
||||
return url_join(get_config('localBackendUrl'), 'graph', 'v1.0', 'drives')
|
||||
|
||||
|
||||
def get_dav_endpint():
|
||||
return url_join(get_config('localBackendUrl'), 'dav', 'spaces')
|
||||
|
||||
|
||||
def get_share_endpint():
|
||||
return url_join(
|
||||
get_config('localBackendUrl'),
|
||||
'ocs/v2.php/apps',
|
||||
'files_sharing/api/v1/shares',
|
||||
)
|
||||
|
||||
|
||||
def create_space(space_name):
|
||||
body = json.dumps({'name': space_name})
|
||||
response = request.post(get_space_endpint(), body)
|
||||
request.assert_http_status(response, 201, f'Failed to create space {space_name}')
|
||||
# save created space
|
||||
resp_object = response.json()
|
||||
created_spaces[space_name] = resp_object['id']
|
||||
|
||||
|
||||
def fetch_spaces(user=None, query=''):
|
||||
if query:
|
||||
query = '?' + query
|
||||
url = get_space_endpint() + query
|
||||
response = request.get(url=url, user=user)
|
||||
request.assert_http_status(response, 200, 'Failed to get spaces')
|
||||
return response.json()['value']
|
||||
|
||||
|
||||
def get_project_spaces(user=None):
|
||||
search_query = '$filter=driveType eq \'project\''
|
||||
return fetch_spaces(query=search_query, user=user)
|
||||
|
||||
def get_personal_space_id(user):
|
||||
search_query = '$filter=driveType eq \'personal\''
|
||||
space = fetch_spaces(query=search_query, user=user)
|
||||
return space[0]['id']
|
||||
|
||||
def get_space_id(space_name, user=None):
|
||||
spaces = {**created_spaces, **user_spaces}
|
||||
if not space_name in spaces.keys():
|
||||
return fetch_space_id(space_name, user)
|
||||
return spaces.get(space_name)
|
||||
|
||||
|
||||
def fetch_space_id(space_name, user=None):
|
||||
spaces = fetch_spaces(user=user)
|
||||
space_id = None
|
||||
for space in spaces:
|
||||
if space['name'] == space_name:
|
||||
user_spaces[space_name] = space['id']
|
||||
space_id = space['id']
|
||||
break
|
||||
return space_id
|
||||
|
||||
|
||||
def delete_project_spaces():
|
||||
global created_spaces, user_spaces
|
||||
for _, space_id in created_spaces.items():
|
||||
disable_project_space(space_id)
|
||||
delete_project_space(space_id)
|
||||
created_spaces = {}
|
||||
user_spaces = {}
|
||||
|
||||
|
||||
def disable_project_space(space_id):
|
||||
url = url_join(get_space_endpint(), space_id)
|
||||
response = request.delete(url)
|
||||
request.assert_http_status(response, 204, f'Failed to disable space {space_id}')
|
||||
|
||||
|
||||
def delete_project_space(space_id):
|
||||
url = url_join(get_space_endpint(), space_id)
|
||||
response = request.delete(url, {'Purge': 'T'})
|
||||
request.assert_http_status(response, 204, f'Failed to delete space {space_id}')
|
||||
|
||||
|
||||
def create_space_folder(space_name, folder_name):
|
||||
space_id = get_space_id(space_name)
|
||||
url = url_join(get_dav_endpint(), space_id, folder_name)
|
||||
response = request.mkcol(url)
|
||||
request.assert_http_status(
|
||||
response,
|
||||
201,
|
||||
f'Failed to create folder "{folder_name}" in space "{space_name}"',
|
||||
)
|
||||
|
||||
|
||||
def create_space_file(space_name, file_name, content):
|
||||
space_id = get_space_id(space_name)
|
||||
url = url_join(get_dav_endpint(), space_id, file_name)
|
||||
response = request.put(url, content)
|
||||
if response.status_code not in (201, 204):
|
||||
raise AssertionError(
|
||||
f"Creating file '{file_name}' in space '{space_name}' failed with {response.status_code}\n"
|
||||
+ response.text
|
||||
)
|
||||
|
||||
|
||||
def add_user_to_space(user, space_name, role):
|
||||
role = role.lower()
|
||||
if not role in space_role:
|
||||
raise ValueError(f"Cannot set the role '{role}' to a space")
|
||||
|
||||
space_id = get_space_id(space_name)
|
||||
url = get_share_endpint()
|
||||
body = parse.urlencode(
|
||||
{
|
||||
'space_ref': space_id,
|
||||
'shareType': 7,
|
||||
'shareWith': user,
|
||||
'role': role,
|
||||
}
|
||||
)
|
||||
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
response = request.post(url, body, headers)
|
||||
request.assert_http_status(
|
||||
response, 200, f'Failed to add user "{user}" to space "{space_name}"'
|
||||
)
|
||||
|
||||
|
||||
def get_file_content(space_name, file_name, user=None):
|
||||
space_id = get_space_id(space_name, user)
|
||||
url = url_join(get_dav_endpint(), space_id, file_name)
|
||||
response = request.get(url=url, user=user)
|
||||
request.assert_http_status(response, 200, f'Failed to get file "{file_name}"')
|
||||
return response.text
|
||||
|
||||
|
||||
def resource_exists(space_name, resource, user=None):
|
||||
space_id = get_space_id(space_name, user)
|
||||
url = url_join(get_dav_endpint(), space_id, resource)
|
||||
response = request.get(url=url, user=user)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import subprocess
|
||||
import glob
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from helpers.ConfigHelper import is_windows
|
||||
|
||||
|
||||
def get_core_dumps():
|
||||
# TODO: find a way to use coredump in windows
|
||||
if is_windows():
|
||||
return False
|
||||
# read coredump location
|
||||
with open('/proc/sys/kernel/core_pattern', 'r', encoding='utf-8') as f:
|
||||
coredump_path = f.read().strip('\n')
|
||||
|
||||
# yields something like: /tmp/core-*-*-*-*
|
||||
coredump_file_pattern = re.sub(r'%[a-zA-Z]{1}', '*', coredump_path)
|
||||
return glob.glob(coredump_file_pattern)
|
||||
|
||||
|
||||
def generate_stacktrace(scenario_title, coredumps):
|
||||
message = ['###########################################']
|
||||
message.append(f'Scenario: {scenario_title}')
|
||||
|
||||
for coredump_file in coredumps:
|
||||
message.append(parse_stacktrace(coredump_file))
|
||||
|
||||
message.append('###########################################')
|
||||
message.append('')
|
||||
stacktrace = '\n'.join(message)
|
||||
|
||||
stacktrace_file = os.environ.get('STACKTRACE_FILE', '../stacktrace.log')
|
||||
# save stacktrace to a file
|
||||
with open(stacktrace_file, 'a', encoding='utf-8') as f:
|
||||
f.write(stacktrace)
|
||||
|
||||
|
||||
def parse_stacktrace(coredump_file):
|
||||
message = []
|
||||
if coredump_file:
|
||||
coredump_filename = os.path.basename(coredump_file)
|
||||
# example coredump file: core-1648445754-1001-11-!drone!src!build-GUI-tests!bin!qsfera
|
||||
patterns = coredump_filename.split('-')
|
||||
app_binary = 'qsfera'
|
||||
if len(patterns) == 1:
|
||||
patterns.append('N/A')
|
||||
patterns.append('N/A')
|
||||
patterns.append('N/A')
|
||||
else:
|
||||
app_binary = '-'.join(patterns[4:]).replace('!', '/')
|
||||
|
||||
timestamp = datetime.fromtimestamp(
|
||||
float(patterns[1] if patterns[1] != 'N/A' else time.time())
|
||||
)
|
||||
message.append('-------------------------------------------')
|
||||
message.append(f'Executable: {app_binary}')
|
||||
message.append(f'Timestamp: {str(timestamp)}')
|
||||
message.append(f'Process ID: {patterns[2]}')
|
||||
message.append(f'Signal Number: {patterns[3]}')
|
||||
message.append('-------------------------------------------')
|
||||
message.append('<<<<< STACKTRACE START >>>>>')
|
||||
message.append(
|
||||
subprocess.run(
|
||||
[
|
||||
'gdb',
|
||||
app_binary,
|
||||
coredump_file,
|
||||
'-batch',
|
||||
'-ex',
|
||||
'bt full',
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
check=False,
|
||||
).stdout.decode('utf-8')
|
||||
)
|
||||
message.append('<<<<< STACKTRACE END >>>>>')
|
||||
|
||||
# remove coredump file
|
||||
os.unlink(coredump_file)
|
||||
|
||||
return '\n'.join(message)
|
||||
@@ -0,0 +1,422 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
|
||||
from pageObjects.SyncConnection import SyncConnection
|
||||
from helpers.ConfigHelper import get_config, is_linux, is_windows
|
||||
from helpers.FilesHelper import sanitize_path
|
||||
|
||||
if is_windows():
|
||||
from helpers.WinPipeHelper import WinPipeConnect as SocketConnect
|
||||
else:
|
||||
# NOTE: 'syncstate.py' was removed from client
|
||||
# and is now available at https://github.com/opencloud-eu/desktop-shell-integration-nautilus
|
||||
# check if 'syncstate.py' is available, if not, download it
|
||||
custom_lib = get_config('custom_lib')
|
||||
syncstate_lib_file = os.path.join(custom_lib, 'syncstate.py')
|
||||
os.makedirs(custom_lib, exist_ok=True)
|
||||
|
||||
if not os.path.exists(syncstate_lib_file):
|
||||
urllib.request.urlretrieve(
|
||||
'https://raw.githubusercontent.com/opencloud-eu/desktop-shell-integration-nautilus/refs/heads/main/src/syncstate.py',
|
||||
syncstate_lib_file,
|
||||
)
|
||||
# do not instantiate SocketConnect in the script.
|
||||
with open(syncstate_lib_file, 'r') as f:
|
||||
content = f.read()
|
||||
content = content.replace('socketConnect = SocketConnect()', '')
|
||||
content = content.replace(
|
||||
'from gi.repository import GObject, Nautilus',
|
||||
'import gi\n\ngi.require_version(\'Nautilus\', \'4.0\')\nfrom gi.repository import GObject, Nautilus',
|
||||
)
|
||||
|
||||
with open(syncstate_lib_file, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
# the script needs to use the system-wide python
|
||||
# to switch from the built-in interpreter
|
||||
# see https://kb.froglogic.com/squish/howto/using-external-python-interpreter-squish-6-6/
|
||||
# if the IDE fails to reference the script,
|
||||
# add the folder in Edit->Preferences->PyDev->Interpreters->Libraries
|
||||
from helpers.custom_lib.syncstate import SocketConnect
|
||||
|
||||
# socket messages
|
||||
socket_messages = []
|
||||
SOCKET_CONNECT = None
|
||||
# Whether wait has been made or not after account is set up
|
||||
# This is useful for waiting only for the first time
|
||||
WAITED_AFTER_SYNC = False
|
||||
|
||||
# File syncing in client has the following status
|
||||
SYNC_STATUS = {
|
||||
'SYNC': 'STATUS:SYNC', # sync in progress
|
||||
'OK': 'STATUS:OK', # sync completed
|
||||
'OKAL': 'STATUS:OK+AL', # sync completed (Always Local)
|
||||
'OKOO': 'STATUS:OK+OO', # sync completed (Online Only)
|
||||
'ERROR': 'STATUS:ERROR', # sync error
|
||||
'IGNORE': 'STATUS:IGNORE', # sync ignored
|
||||
'NOP': 'STATUS:NOP', # not in sync yet
|
||||
'REGISTER': 'REGISTER_PATH',
|
||||
'UNREGISTER': 'UNREGISTER_PATH',
|
||||
'UPDATE': 'UPDATE_VIEW',
|
||||
}
|
||||
|
||||
SYNC_PATTERNS = {
|
||||
# default sync patterns for the initial sync (after adding account)
|
||||
# the pattern can be of TWO types depending on the available resources (files/folders)
|
||||
'initial': [
|
||||
# when adding account via New Account wizard
|
||||
[
|
||||
SYNC_STATUS['NOP'],
|
||||
SYNC_STATUS['REGISTER'],
|
||||
SYNC_STATUS['UPDATE'],
|
||||
],
|
||||
# when syncing empty account (hidden files are ignored)
|
||||
# [SYNC_STATUS['UPDATE'], SYNC_STATUS['OK']],
|
||||
# [SYNC_STATUS['UPDATE'], SYNC_STATUS['OKAL']],
|
||||
# when syncing an account that has some files/folders
|
||||
# [SYNC_STATUS['SYNC'], SYNC_STATUS['OK']],
|
||||
# initial root sync
|
||||
[
|
||||
SYNC_STATUS['OK'],
|
||||
SYNC_STATUS['OK'],
|
||||
SYNC_STATUS['UPDATE'],
|
||||
],
|
||||
],
|
||||
'root_synced': [
|
||||
[
|
||||
SYNC_STATUS['OK'],
|
||||
SYNC_STATUS['OK'],
|
||||
SYNC_STATUS['UPDATE'],
|
||||
],
|
||||
# [
|
||||
# SYNC_STATUS['SYNC'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['UPDATE'],
|
||||
# ],
|
||||
# [
|
||||
# SYNC_STATUS['SYNC'],
|
||||
# SYNC_STATUS['UPDATE'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['UPDATE'],
|
||||
# ],
|
||||
# # used for local resource creation and deletion
|
||||
# [
|
||||
# SYNC_STATUS['OKAL'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['OK'],
|
||||
# SYNC_STATUS['UPDATE'],
|
||||
# ],
|
||||
],
|
||||
'single_synced': [
|
||||
[SYNC_STATUS['SYNC'], SYNC_STATUS['OK']],
|
||||
# file/folder deletion
|
||||
[SYNC_STATUS['SYNC'], SYNC_STATUS['NOP']],
|
||||
],
|
||||
'error': [SYNC_STATUS['ERROR']],
|
||||
}
|
||||
|
||||
|
||||
def get_socket_connection():
|
||||
global SOCKET_CONNECT
|
||||
if not SOCKET_CONNECT or not SOCKET_CONNECT.connected:
|
||||
SOCKET_CONNECT = SocketConnect()
|
||||
return SOCKET_CONNECT
|
||||
|
||||
|
||||
def read_socket_messages():
|
||||
messages = []
|
||||
socket_connect = get_socket_connection()
|
||||
socket_connect.read_socket_data_with_timeout(0.1)
|
||||
for line in socket_connect.get_available_responses():
|
||||
messages.append(line)
|
||||
return messages
|
||||
|
||||
|
||||
def read_and_update_socket_messages():
|
||||
messages = read_socket_messages()
|
||||
return update_socket_messages(messages)
|
||||
|
||||
|
||||
def update_socket_messages(messages):
|
||||
socket_messages.extend(filter_sync_messages(messages))
|
||||
return socket_messages
|
||||
|
||||
|
||||
def clear_socket_messages(resource=''):
|
||||
global socket_messages
|
||||
if resource:
|
||||
resource_messages = set(filter_messages_for_item(socket_messages, resource))
|
||||
socket_messages = [
|
||||
msg for msg in socket_messages if msg not in resource_messages
|
||||
]
|
||||
else:
|
||||
socket_messages.clear()
|
||||
|
||||
|
||||
def close_socket_connection():
|
||||
socket_messages.clear()
|
||||
if SOCKET_CONNECT:
|
||||
SOCKET_CONNECT.connected = False
|
||||
if is_windows():
|
||||
SOCKET_CONNECT.close_conn()
|
||||
elif is_linux():
|
||||
SOCKET_CONNECT._sock.close() # pylint: disable=protected-access
|
||||
|
||||
|
||||
def get_initial_sync_patterns():
|
||||
return SYNC_PATTERNS['initial']
|
||||
|
||||
|
||||
def get_synced_pattern(resource=''):
|
||||
# get only the resource path
|
||||
sync_path = get_config('currentUserSyncPath')
|
||||
sync_path = os.path.join(sync_path, get_config('syncConnectionName'))
|
||||
|
||||
if resource := resource.replace(sync_path, '').strip('\\').strip('/'):
|
||||
return SYNC_PATTERNS['single_synced']
|
||||
return SYNC_PATTERNS['root_synced']
|
||||
|
||||
|
||||
# generate sync pattern from the socket messages
|
||||
#
|
||||
# returns List
|
||||
# e.g: ['UPDATE_VIEW', 'STATUS:OK']
|
||||
def generate_sync_pattern_from_messages(messages):
|
||||
pattern = []
|
||||
if not messages:
|
||||
return pattern
|
||||
|
||||
sync_messages = filter_sync_messages(messages)
|
||||
for message in sync_messages:
|
||||
# E.g; from;
|
||||
# Linux: "STATUS:OK:/tmp/client-bdd/Alice/"
|
||||
# Win: "STATUS:OK:C:\tmp\client-bdd\Alice\"
|
||||
# excludes ":/tmp/client-bdd/Alice/"
|
||||
# adds only "STATUS:OK" to the pattern list
|
||||
if match := re.search(r':(/|[A-Za-z]:[\\/]).*', message):
|
||||
end, _ = match.span()
|
||||
# shared resources will have status like "STATUS:OK+SWM"
|
||||
status = message[:end].replace('+SWM', '')
|
||||
pattern.append(status)
|
||||
return pattern
|
||||
|
||||
|
||||
# strip out the messages that are not related to sync
|
||||
def filter_sync_messages(messages):
|
||||
start_idx = 0
|
||||
if 'GET_STRINGS:END' in messages:
|
||||
start_idx = messages.index('GET_STRINGS:END') + 1
|
||||
return messages[start_idx:]
|
||||
|
||||
|
||||
def filter_messages_for_item(messages, item):
|
||||
filtered_messages = []
|
||||
for msg in messages:
|
||||
msg = msg.rstrip('/').rstrip('\\')
|
||||
item = item.rstrip('/').rstrip('\\')
|
||||
if msg.endswith(item):
|
||||
filtered_messages.append(msg)
|
||||
return filtered_messages
|
||||
|
||||
|
||||
def listen_sync_status_for_item(item, resource_type='FOLDER'):
|
||||
if (resource_type := resource_type.upper()) not in ('FILE', 'FOLDER'):
|
||||
raise ValueError('resource_type must be "FILE" or "FOLDER"')
|
||||
socket_connect = get_socket_connection()
|
||||
item = item.rstrip('\\').rstrip('/')
|
||||
socket_connect.sendCommand(f'RETRIEVE_{resource_type}_STATUS:{item}\n')
|
||||
|
||||
|
||||
def get_current_sync_status(resource, resource_type):
|
||||
listen_sync_status_for_item(resource, resource_type)
|
||||
messages = filter_messages_for_item(read_socket_messages(), resource)
|
||||
# return the last message from the list
|
||||
return messages[-1]
|
||||
|
||||
|
||||
def wait_for_resource_to_sync(
|
||||
resource, resource_type='FOLDER', patterns=None, force_sync=False
|
||||
):
|
||||
listen_sync_status_for_item(resource, resource_type)
|
||||
|
||||
initial_timeout = 0
|
||||
timeout = get_config('maxSyncTimeout') * 1000
|
||||
|
||||
if patterns is None:
|
||||
patterns = get_synced_pattern(resource)
|
||||
|
||||
if force_sync:
|
||||
initial_timeout = 5000
|
||||
# first try with 5 seconds timeout
|
||||
synced = wait_for(
|
||||
lambda: has_sync_pattern(patterns, resource),
|
||||
initial_timeout,
|
||||
)
|
||||
if not synced:
|
||||
# trigger force sync if the current status is OK
|
||||
status = get_current_sync_status(resource, resource_type)
|
||||
if status.startswith(SYNC_STATUS['OK']):
|
||||
print('[WARN] Retrying sync pattern check with force sync')
|
||||
SyncConnection.force_sync()
|
||||
else:
|
||||
clear_socket_messages(resource)
|
||||
return
|
||||
|
||||
synced = wait_for(
|
||||
lambda: has_sync_pattern(patterns, resource),
|
||||
timeout - initial_timeout,
|
||||
)
|
||||
|
||||
messages = read_and_update_socket_messages()
|
||||
messages = filter_messages_for_item(messages, resource)
|
||||
clear_socket_messages(resource)
|
||||
if synced:
|
||||
return
|
||||
elif not force_sync:
|
||||
# if the sync pattern doesn't match then check the last sync status
|
||||
# and pass the step if the last sync status is STATUS:OK
|
||||
status = get_current_sync_status(resource, resource_type)
|
||||
if status.startswith(SYNC_STATUS['OK']):
|
||||
print(
|
||||
'[WARN] Failed to match sync pattern for resource: '
|
||||
+ resource
|
||||
+ f'\nBut its last status is "{SYNC_STATUS["OK"]}"'
|
||||
+ '. So passing the step.'
|
||||
)
|
||||
return
|
||||
raise TimeoutError(
|
||||
'Timeout while waiting for sync to complete for '
|
||||
+ str(timeout)
|
||||
+ ' milliseconds'
|
||||
)
|
||||
|
||||
|
||||
def wait_for_initial_sync_to_complete(path):
|
||||
wait_for_resource_to_sync(
|
||||
path,
|
||||
'FOLDER',
|
||||
get_initial_sync_patterns(),
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def has_sync_pattern(patterns, resource=None):
|
||||
if isinstance(patterns[0], str):
|
||||
patterns = [patterns]
|
||||
messages = read_and_update_socket_messages()
|
||||
if resource:
|
||||
messages = filter_messages_for_item(messages, resource)
|
||||
for pattern in patterns:
|
||||
pattern_len = len(pattern)
|
||||
for idx, _ in enumerate(messages):
|
||||
actual_pattern = generate_sync_pattern_from_messages(
|
||||
messages[idx : idx + pattern_len]
|
||||
)
|
||||
if len(actual_pattern) < pattern_len:
|
||||
break
|
||||
if pattern_len == len(actual_pattern) and pattern == actual_pattern:
|
||||
print("MATCHED SYNC PATTERN:", pattern)
|
||||
return True
|
||||
# 100 milliseconds polling interval
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
# Using socket API to check file sync status
|
||||
def has_sync_status(item_name, status):
|
||||
sync_messages = read_and_update_socket_messages()
|
||||
sync_messages = filter_messages_for_item(sync_messages, item_name)
|
||||
for line in sync_messages:
|
||||
line = line.rstrip('/').rstrip('\\')
|
||||
item_name = item_name.rstrip('/').rstrip('\\')
|
||||
if line.startswith(status) and line.endswith(item_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# useful for checking sync status such as 'error', 'ignore'
|
||||
# but not quite so reliable for checking 'ok' sync status
|
||||
def wait_for_resource_to_have_sync_status(
|
||||
resource, resource_type, status=SYNC_STATUS['OK'], timeout=None
|
||||
):
|
||||
resource = sanitize_path(resource)
|
||||
|
||||
listen_sync_status_for_item(resource, resource_type)
|
||||
|
||||
if not timeout:
|
||||
timeout = get_config('maxSyncTimeout') * 1000
|
||||
|
||||
result = wait_for(
|
||||
lambda: has_sync_status(resource, status),
|
||||
timeout,
|
||||
)
|
||||
|
||||
if not result:
|
||||
if status == SYNC_STATUS['ERROR']:
|
||||
expected = 'have sync error'
|
||||
elif status == SYNC_STATUS['IGNORE']:
|
||||
expected = 'be sync ignored'
|
||||
else:
|
||||
expected = 'be synced'
|
||||
raise ValueError(
|
||||
f'Expected {resource_type} "{resource}" to {expected}, but not.'
|
||||
)
|
||||
|
||||
|
||||
def wait_for_resource_to_have_sync_error(resource, resource_type):
|
||||
wait_for_resource_to_have_sync_status(resource, resource_type, SYNC_STATUS['ERROR'])
|
||||
|
||||
|
||||
# performing actions immediately after completing the sync from the server does not work
|
||||
# The test should wait for a while before performing the action
|
||||
# issue: https://github.com/owncloud/client/issues/8832
|
||||
def wait_for_client_to_be_ready():
|
||||
global WAITED_AFTER_SYNC
|
||||
if not WAITED_AFTER_SYNC:
|
||||
time.sleep(get_config('minSyncTimeout'))
|
||||
WAITED_AFTER_SYNC = True
|
||||
|
||||
|
||||
def clear_waited_after_sync():
|
||||
global WAITED_AFTER_SYNC
|
||||
WAITED_AFTER_SYNC = False
|
||||
|
||||
|
||||
def perform_file_explorer_vfs_action(resource_path, action):
|
||||
if action == 'Free up space':
|
||||
make_online_only(resource_path)
|
||||
elif action == 'Always keep on this device':
|
||||
make_available_locally(resource_path)
|
||||
else:
|
||||
raise ValueError(f'Invalid file explorer action: {action}')
|
||||
|
||||
|
||||
def make_online_only(resource_path):
|
||||
socket_connect = get_socket_connection()
|
||||
resource_path = resource_path.rstrip('\\').rstrip('/')
|
||||
socket_connect.sendCommand(f'MAKE_ONLINE_ONLY:{resource_path}\n')
|
||||
|
||||
|
||||
def make_available_locally(resource_path):
|
||||
socket_connect = get_socket_connection()
|
||||
resource_path = resource_path.rstrip('\\').rstrip('/')
|
||||
socket_connect.sendCommand(f'MAKE_AVAILABLE_LOCALLY:{resource_path}\n')
|
||||
|
||||
|
||||
def wait_for(condition, timeout, interval=0.5):
|
||||
from helpers.AppHelper import app
|
||||
|
||||
wait = WebDriverWait(app(), timeout / 1000, poll_frequency=interval)
|
||||
try:
|
||||
wait.until(lambda _: condition())
|
||||
return True
|
||||
except TimeoutException:
|
||||
return False
|
||||
@@ -0,0 +1,102 @@
|
||||
from behave.model import Table
|
||||
|
||||
|
||||
def table_raw(table: Table):
|
||||
"""
|
||||
Args:
|
||||
table (Table): Behave Table object.
|
||||
Returns:
|
||||
list: List of lists (including header row) - each row is a list of cells.
|
||||
|
||||
Example:
|
||||
| header1 | header2 | header3 |
|
||||
| value1 | value2 | value3 |
|
||||
Output:
|
||||
[
|
||||
['header1', 'header2', 'header3'],
|
||||
['value1', 'value2', 'value3'],
|
||||
]
|
||||
"""
|
||||
data_table = [table.headings]
|
||||
data_table.extend(table_rows(table))
|
||||
return data_table
|
||||
|
||||
|
||||
def table_rows(table: Table):
|
||||
"""
|
||||
Args:
|
||||
table (Table): Behave Table object.
|
||||
Returns:
|
||||
list: List of lists (excluding header row) - each row is a list of cells.
|
||||
|
||||
Example:
|
||||
| header1 | header2 | header3 |
|
||||
| value1 | value2 | value3 |
|
||||
Output:
|
||||
[
|
||||
['value1', 'value2', 'value3'],
|
||||
]
|
||||
"""
|
||||
data_table = []
|
||||
for row in table:
|
||||
data_table.append(row.cells)
|
||||
return data_table
|
||||
|
||||
|
||||
def table_rows_hash(table: Table):
|
||||
"""
|
||||
Args:
|
||||
table (Table): Behave Table object. Table MUST have exactly 2 columns.
|
||||
Returns:
|
||||
dict: Dictionary where keys are from the first column and values are from the second column.
|
||||
Raises:
|
||||
ValueError: If the table does not have exactly 2 columns.
|
||||
|
||||
Example:
|
||||
| key1 | value1 |
|
||||
| key2 | value2 |
|
||||
| key3 | value3 |
|
||||
Output:
|
||||
{
|
||||
'key1': 'value1',
|
||||
'key2': 'value2',
|
||||
'key3': 'value3',
|
||||
}
|
||||
"""
|
||||
if len(table.headings) != 2:
|
||||
raise ValueError(
|
||||
"table_rows_hash() can only be called on a data table where all rows have exactly two columns."
|
||||
)
|
||||
|
||||
data_table = {
|
||||
table.headings[0]: table.headings[1],
|
||||
}
|
||||
for row in table:
|
||||
data_table[row[0]] = row[1]
|
||||
return data_table
|
||||
|
||||
|
||||
def table_hashes(table: Table):
|
||||
"""
|
||||
Args:
|
||||
table (Table): Behave Table object.
|
||||
Returns:
|
||||
list: List of dictionaries, where each dictionary represents a row with keys from the header and values from the corresponding cells.
|
||||
|
||||
Example:
|
||||
| key1 | key2 | key3 |
|
||||
| value1 | value2 | value3 |
|
||||
| value4 | value5 | value6 |
|
||||
Output:
|
||||
[
|
||||
{'key1': 'value1', 'key2': 'value2', 'key3': 'value3'},
|
||||
{'key1': 'value4', 'key2': 'value5', 'key3': 'value6'},
|
||||
]
|
||||
"""
|
||||
data_table = []
|
||||
for row in table:
|
||||
row_dict = {}
|
||||
for idx, heading in enumerate(table.headings):
|
||||
row_dict[heading] = row.cells[idx]
|
||||
data_table.append(row_dict)
|
||||
return data_table
|
||||
@@ -0,0 +1,74 @@
|
||||
from base64 import b64encode
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class User(NamedTuple):
|
||||
username: str
|
||||
password: str
|
||||
displayname: str
|
||||
email: str
|
||||
|
||||
|
||||
test_users = {
|
||||
"admin": User(
|
||||
username="admin",
|
||||
password="admin",
|
||||
displayname="adminUsername",
|
||||
email="admin@example.org",
|
||||
),
|
||||
"Alice": User(
|
||||
username="Alice",
|
||||
password="1234",
|
||||
displayname="Alice Hansen",
|
||||
email="alice@example.org",
|
||||
),
|
||||
"Brian": User(
|
||||
username="Brian",
|
||||
password="AaBb2Cc3Dd4",
|
||||
displayname="Brian Murphy",
|
||||
email="brian@example.org",
|
||||
),
|
||||
"Carol": User(
|
||||
username="Carol",
|
||||
password="1234",
|
||||
displayname="Carol King",
|
||||
email="carol@example.org",
|
||||
),
|
||||
"David": User(
|
||||
username="David",
|
||||
password="1234",
|
||||
displayname="David Lopez",
|
||||
email="david@example.org",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_default_password():
|
||||
return "1234"
|
||||
|
||||
|
||||
def basic_auth_header(user=None, password=None):
|
||||
if not user and not password:
|
||||
user = "admin"
|
||||
password = "admin"
|
||||
elif not user == "public" and not password:
|
||||
password = get_password_for_user(user)
|
||||
|
||||
token = b64encode((f"{user}:{password}").encode()).decode()
|
||||
return {"Authorization": "Basic " + token}
|
||||
|
||||
|
||||
def get_user_info(username, attribute):
|
||||
if username in test_users:
|
||||
return getattr(test_users[username], attribute)
|
||||
if attribute == "password":
|
||||
return get_default_password()
|
||||
raise ValueError(f"Invalid user attribute: {attribute}")
|
||||
|
||||
|
||||
def get_displayname_for_user(username):
|
||||
return get_user_info(username, "displayname")
|
||||
|
||||
|
||||
def get_password_for_user(username):
|
||||
return get_user_info(username, "password")
|
||||
@@ -0,0 +1,125 @@
|
||||
import inspect
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from enum import IntFlag, Enum, unique
|
||||
|
||||
from helpers.ConfigHelper import is_windows
|
||||
|
||||
|
||||
error_message = "'%s' function is only supported in Windows OS."
|
||||
|
||||
# ==========================
|
||||
# Structures
|
||||
# ==========================
|
||||
class FILETIME(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwLowDateTime", wintypes.DWORD),
|
||||
("dwHighDateTime", wintypes.DWORD),
|
||||
]
|
||||
|
||||
class WIN32_FILE_ATTRIBUTE_DATA(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwFileAttributes", wintypes.DWORD),
|
||||
("ftCreationTime", FILETIME),
|
||||
("ftLastAccessTime", FILETIME),
|
||||
("ftLastWriteTime", FILETIME),
|
||||
("nFileSizeHigh", wintypes.DWORD),
|
||||
("nFileSizeLow", wintypes.DWORD),
|
||||
]
|
||||
|
||||
# Ref: https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
|
||||
@unique
|
||||
class FileAttributeConstants(IntFlag):
|
||||
__str__ = Enum.__str__
|
||||
FILE_ATTRIBUTE_PINNED = 0x00080000
|
||||
FILE_ATTRIBUTE_UNPINNED = 0x00100000
|
||||
FILE_ATTRIBUTE_ARCHIVE = 0x00000020
|
||||
|
||||
GetFileAttributesExW = None
|
||||
GetCompressedFileSizeW = None
|
||||
|
||||
if is_windows():
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
|
||||
GetFileAttributesExW = kernel32.GetFileAttributesExW
|
||||
GetFileAttributesExW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.c_int,
|
||||
ctypes.POINTER(WIN32_FILE_ATTRIBUTE_DATA),
|
||||
]
|
||||
GetFileAttributesExW.restype = wintypes.BOOL
|
||||
|
||||
GetCompressedFileSizeW = kernel32.GetCompressedFileSizeW
|
||||
GetCompressedFileSizeW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
]
|
||||
GetCompressedFileSizeW.restype = wintypes.DWORD
|
||||
|
||||
|
||||
def get_file_attributes(path):
|
||||
if is_windows():
|
||||
data = WIN32_FILE_ATTRIBUTE_DATA()
|
||||
success = GetFileAttributesExW(path, 0, ctypes.byref(data))
|
||||
if not success:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
attributes = FileAttributeConstants(data.dwFileAttributes)
|
||||
mask = (
|
||||
FileAttributeConstants.FILE_ATTRIBUTE_PINNED |
|
||||
FileAttributeConstants.FILE_ATTRIBUTE_UNPINNED |
|
||||
FileAttributeConstants.FILE_ATTRIBUTE_ARCHIVE
|
||||
)
|
||||
return attributes & mask
|
||||
raise OSError(error_message % inspect.currentframe().f_back.f_code.co_name)
|
||||
|
||||
|
||||
def get_compressed_file_size(path):
|
||||
if is_windows():
|
||||
high = wintypes.DWORD(0)
|
||||
low = GetCompressedFileSizeW(path, ctypes.byref(high))
|
||||
|
||||
if low == 0xFFFFFFFF:
|
||||
err = ctypes.get_last_error()
|
||||
if err != 0:
|
||||
raise ctypes.WinError(err)
|
||||
|
||||
return (high.value << 32) | low
|
||||
raise OSError(error_message % inspect.currentframe().f_back.f_code.co_name)
|
||||
|
||||
|
||||
def resource_archived(resource_path):
|
||||
if is_windows():
|
||||
return bool(get_file_attributes(resource_path) & FileAttributeConstants.FILE_ATTRIBUTE_ARCHIVE)
|
||||
raise OSError(error_message % inspect.currentframe().f_back.f_code.co_name)
|
||||
|
||||
|
||||
def resource_pinned(resource_path):
|
||||
if is_windows():
|
||||
return bool(get_file_attributes(resource_path) & FileAttributeConstants.FILE_ATTRIBUTE_PINNED)
|
||||
raise OSError(error_message % inspect.currentframe().f_back.f_code.co_name)
|
||||
|
||||
|
||||
def resource_unpinned(resource_path):
|
||||
if is_windows():
|
||||
return bool(get_file_attributes(resource_path) & FileAttributeConstants.FILE_ATTRIBUTE_UNPINNED)
|
||||
raise OSError(error_message % inspect.currentframe().f_back.f_code.co_name)
|
||||
|
||||
|
||||
def is_placeholder_resource(resource_path):
|
||||
if is_windows():
|
||||
size_on_disk = get_compressed_file_size(resource_path)
|
||||
unpinned = resource_unpinned(resource_path)
|
||||
pinned = resource_pinned(resource_path)
|
||||
archived = resource_archived(resource_path)
|
||||
return (not size_on_disk or unpinned) and not (pinned and archived)
|
||||
raise OSError(error_message % inspect.currentframe().f_back.f_code.co_name)
|
||||
|
||||
|
||||
def is_file_downloaded(resource_path):
|
||||
if is_windows():
|
||||
size_on_disk = get_compressed_file_size(resource_path)
|
||||
pinned = resource_pinned(resource_path)
|
||||
unpinned = resource_unpinned(resource_path)
|
||||
archived = resource_archived(resource_path)
|
||||
return size_on_disk and (pinned or archived) and not unpinned
|
||||
raise OSError(error_message % inspect.currentframe().f_back.f_code.co_name)
|
||||
@@ -0,0 +1,20 @@
|
||||
import pyperclip
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
def authorize_via_webui(username, password):
|
||||
url = pyperclip.paste()
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
context = browser.new_context(ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
|
||||
page.goto(url)
|
||||
page.fill('#oc-login-username', username)
|
||||
page.fill('#oc-login-password', password)
|
||||
page.click('button :text("Log in")')
|
||||
page.click('button :text("Allow")')
|
||||
page.wait_for_selector(':text("Login successful")')
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
# pylint: disable=import-error
|
||||
import win32pipe
|
||||
import win32file
|
||||
import winerror
|
||||
import pywintypes
|
||||
import win32event
|
||||
|
||||
TIMEOUT = 100
|
||||
DEFAULT_BUFLEN = 4096
|
||||
CLIENT_MESSAGES = [
|
||||
'STATUS',
|
||||
'REGISTER_PATH',
|
||||
'UNREGISTER_PATH',
|
||||
'UPDATE_VIEW',
|
||||
'GET_STRINGS',
|
||||
'STRING',
|
||||
'VERSION',
|
||||
'SHARE',
|
||||
'GET_MENU_ITEMS',
|
||||
'MENU_ITEM',
|
||||
]
|
||||
|
||||
|
||||
def get_pipe_path():
|
||||
pipename = r'\\.\\pipe\\'
|
||||
pipename = os.path.join(pipename, 'openCloud-' + os.getenv('USERNAME'))
|
||||
return pipename
|
||||
|
||||
|
||||
class WinPipeConnect:
|
||||
def __init__(self):
|
||||
self.connected = False
|
||||
self._pipe = None
|
||||
self._remainder = ''.encode('utf-8')
|
||||
self._overlapped = pywintypes.OVERLAPPED()
|
||||
self._overlapped.hEvent = win32event.CreateEvent(None, 1, 0, None)
|
||||
self.connect_to_pipe_server()
|
||||
|
||||
def connect_to_pipe_server(self):
|
||||
try:
|
||||
pipename = get_pipe_path()
|
||||
|
||||
self._pipe = win32file.CreateFile(
|
||||
pipename,
|
||||
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
||||
0,
|
||||
None,
|
||||
win32file.OPEN_EXISTING,
|
||||
win32file.FILE_FLAG_OVERLAPPED,
|
||||
None,
|
||||
)
|
||||
|
||||
if self._pipe == win32file.INVALID_HANDLE_VALUE:
|
||||
win32pipe.CloseHandle(self._pipe)
|
||||
raise OSError('Invalid _pipe value')
|
||||
|
||||
self.connected = True
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
print(f'Could not connect to named pipe {pipename}\n' + str(e))
|
||||
win32file.CloseHandle(self._pipe)
|
||||
|
||||
def sendCommand(self, cmd): # pylint: disable=invalid-name
|
||||
if self.connected:
|
||||
w_res, _ = win32file.WriteFile(
|
||||
self._pipe, cmd.encode('utf-8'), self._overlapped
|
||||
)
|
||||
if w_res == winerror.ERROR_IO_PENDING:
|
||||
res = win32event.WaitForSingleObject(self._overlapped.hEvent, TIMEOUT)
|
||||
if res != win32event.WAIT_OBJECT_0:
|
||||
print('Sending timed out!')
|
||||
return False
|
||||
if not win32file.GetOverlappedResult(
|
||||
self._pipe, self._overlapped, False
|
||||
):
|
||||
print('GetOverlappedResult failed')
|
||||
return False
|
||||
else:
|
||||
print('Cannot send, not connected!')
|
||||
return False
|
||||
return True
|
||||
|
||||
# Reads data that becomes available.
|
||||
# New responses can be accessed with get_available_responses().
|
||||
def read_socket_data_with_timeout(self, timeout):
|
||||
messages = b''
|
||||
start_time = time.time()
|
||||
while True: # pylint: disable=too-many-nested-blocks
|
||||
if (time.time() - start_time) >= timeout:
|
||||
self._remainder += messages
|
||||
break
|
||||
|
||||
peek_bytes = win32pipe.PeekNamedPipe(self._pipe, DEFAULT_BUFLEN)[1]
|
||||
if isinstance(peek_bytes, int) and peek_bytes > 0:
|
||||
_, message_mem = win32file.ReadFile(
|
||||
self._pipe, DEFAULT_BUFLEN, self._overlapped
|
||||
)
|
||||
if message_mem:
|
||||
m_bytes = bytes(message_mem)
|
||||
if b'\n' in m_bytes:
|
||||
for m in m_bytes.split(b'\n'):
|
||||
try:
|
||||
# append decodable and client specific bytes
|
||||
m.decode('utf-8')
|
||||
start = m.split(b':', 1)[0]
|
||||
if start.decode('utf-8') in CLIENT_MESSAGES:
|
||||
messages += m + b'\n'
|
||||
except:
|
||||
pass
|
||||
|
||||
else:
|
||||
res = win32event.WaitForSingleObject(
|
||||
self._overlapped.hEvent, int(timeout * 1000)
|
||||
)
|
||||
if res != win32event.WAIT_OBJECT_0:
|
||||
print('Reading timed out!')
|
||||
return False
|
||||
if not win32file.GetOverlappedResult(
|
||||
self._pipe, self._overlapped, False
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Parses response lines out of collected data, returns list of strings
|
||||
def get_available_responses(self):
|
||||
if (end := self._remainder.rfind(b'\n')) == -1:
|
||||
return []
|
||||
data = self._remainder[:end]
|
||||
self._remainder = self._remainder[end + 1 :]
|
||||
return data.decode('utf-8').split('\n')
|
||||
|
||||
def close_conn(self):
|
||||
win32file.CloseHandle(self._pipe)
|
||||
@@ -0,0 +1,60 @@
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
from helpers.UserHelper import basic_auth_header
|
||||
from helpers.ConfigHelper import get_config
|
||||
|
||||
urllib3.disable_warnings()
|
||||
|
||||
|
||||
def send_request(url, method, body=None, headers=None, user=None, password=None):
|
||||
auth_header = basic_auth_header(user, password)
|
||||
if not headers:
|
||||
headers = {}
|
||||
headers.update(auth_header)
|
||||
return requests.request(
|
||||
method,
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
verify=False,
|
||||
# in seconds
|
||||
# e.g.: 60
|
||||
timeout=get_config("maxSyncTimeout"),
|
||||
)
|
||||
|
||||
|
||||
def get(url, headers=None, user=None, password=None):
|
||||
return send_request(
|
||||
url=url, method="GET", headers=headers, user=user, password=password
|
||||
)
|
||||
|
||||
|
||||
def post(url, body=None, headers=None, user=None):
|
||||
return send_request(url, "POST", body, headers, user)
|
||||
|
||||
|
||||
def put(url, body=None, headers=None, user=None):
|
||||
return send_request(url, "PUT", body, headers, user)
|
||||
|
||||
|
||||
def delete(url, headers=None, user=None):
|
||||
return send_request(url=url, method="DELETE", headers=headers, user=user)
|
||||
|
||||
|
||||
def mkcol(url, headers=None, user=None):
|
||||
return send_request(url=url, method="MKCOL", headers=headers, user=user)
|
||||
|
||||
|
||||
def propfind(url, body=None, headers=None, user=None, password=None):
|
||||
return send_request(url, "PROPFIND", body, headers, user, password)
|
||||
|
||||
|
||||
def assert_http_status(response, expected_code, message=""):
|
||||
response_body = ""
|
||||
if response.text:
|
||||
response_body = response.text
|
||||
|
||||
assert (
|
||||
response.status_code == expected_code
|
||||
), f"{message}\nRequest failed with status code '{response.status_code}'\n{response_body}"
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
from PySide6.QtCore import QJsonDocument
|
||||
|
||||
import helpers.api.http_helper as request
|
||||
from helpers.ConfigHelper import get_config
|
||||
from helpers import UserHelper
|
||||
from helpers.api.utils import url_join
|
||||
|
||||
|
||||
created_groups = {}
|
||||
created_users = {}
|
||||
|
||||
|
||||
def get_graph_url():
|
||||
return url_join(get_config("localBackendUrl"), "graph", "v1.0")
|
||||
|
||||
|
||||
def create_user(username):
|
||||
if username in UserHelper.test_users:
|
||||
user = UserHelper.test_users[username]
|
||||
else:
|
||||
user = UserHelper.User(
|
||||
username=username,
|
||||
displayname=username,
|
||||
email=f'{username}@mail.com',
|
||||
password=UserHelper.get_default_password(),
|
||||
)
|
||||
|
||||
url = url_join(get_graph_url(), "users")
|
||||
body = json.dumps(
|
||||
{
|
||||
"onPremisesSamAccountName": user.username,
|
||||
"passwordProfile": {"password": user.password},
|
||||
"displayName": user.displayname,
|
||||
"mail": user.email,
|
||||
}
|
||||
)
|
||||
response = request.post(url, body)
|
||||
request.assert_http_status(response, 201, f"Failed to create user '{username}'")
|
||||
resp_object = response.json()
|
||||
|
||||
# Check if the user already exists
|
||||
user_info = {
|
||||
"id": resp_object["id"],
|
||||
"username": user.username,
|
||||
"password": user.password,
|
||||
"displayname": resp_object["displayName"],
|
||||
"email": resp_object["mail"],
|
||||
}
|
||||
created_users[username] = user_info
|
||||
|
||||
|
||||
def delete_created_users():
|
||||
for username, user_info in list(created_users.items()):
|
||||
user_id = user_info['id']
|
||||
url = url_join(get_graph_url(), "users", user_id)
|
||||
response = request.delete(url)
|
||||
request.assert_http_status(response, 204, "Failed to delete user")
|
||||
del created_users[username]
|
||||
|
||||
|
||||
def get_capabilities():
|
||||
server_url = get_config('localBackendUrl')
|
||||
url = url_join(server_url, '/ocs/v1.php/cloud/capabilities?format=json')
|
||||
response = request.get(url)
|
||||
response_str = response.text
|
||||
response_doc = QJsonDocument.fromJson(response_str.encode("utf-8"))
|
||||
response_obj = response_doc.object()
|
||||
capabilities = response_obj.get('ocs').get('data').get('capabilities')
|
||||
return capabilities
|
||||
@@ -0,0 +1,8 @@
|
||||
from posixpath import join
|
||||
|
||||
|
||||
def url_join(*args):
|
||||
paths = []
|
||||
for path in list(args):
|
||||
paths.append(path.strip("/"))
|
||||
return join("", *paths)
|
||||
@@ -0,0 +1,147 @@
|
||||
from urllib.parse import quote
|
||||
import xml.etree.ElementTree as ET
|
||||
import json
|
||||
|
||||
import helpers.api.http_helper as request
|
||||
from helpers.api.utils import url_join
|
||||
from helpers.ConfigHelper import get_config, PERMISSION_ROLES
|
||||
from helpers.FilesHelper import get_file_for_upload
|
||||
from helpers.SpaceHelper import get_personal_space_id
|
||||
from helpers.api import provisioning
|
||||
|
||||
|
||||
def get_webdav_url():
|
||||
return url_join(get_config('localBackendUrl'), 'remote.php/dav/files')
|
||||
|
||||
|
||||
def get_beta_graph_url():
|
||||
return url_join(get_config("localBackendUrl"), "graph", "v1beta1")
|
||||
|
||||
|
||||
def get_resource_path(user, resource):
|
||||
resource = resource.strip('/').replace('\\', '/')
|
||||
encoded_resource_path = [quote(path, safe='') for path in resource.split('/')]
|
||||
encoded_resource_path = '/'.join(encoded_resource_path)
|
||||
url = url_join(get_webdav_url(), user, encoded_resource_path)
|
||||
return url
|
||||
|
||||
|
||||
def resource_exists(user, resource):
|
||||
response = request.propfind(get_resource_path(user, resource), user=user)
|
||||
if response.status_code == 207:
|
||||
return True
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
raise AssertionError(f'Server returned status code: {response.status_code}')
|
||||
|
||||
|
||||
def get_file_content(user, resource):
|
||||
response = request.get(get_resource_path(user, resource), user=user)
|
||||
if resource.lower().endswith('.txt'):
|
||||
return response.text
|
||||
return response.content
|
||||
|
||||
|
||||
def get_folder_items(user, resource):
|
||||
"""Get the root XML element from a PROPFIND request"""
|
||||
path = get_resource_path(user, resource)
|
||||
xml_response = request.propfind(path, user=user)
|
||||
|
||||
if xml_response.status_code != 207:
|
||||
raise AssertionError(f'Failed to get resource properties: {xml_response.status_code}')
|
||||
|
||||
return ET.fromstring(xml_response.content)
|
||||
|
||||
|
||||
def get_folder_items_count(user, folder_name):
|
||||
folder_name = folder_name.strip('/')
|
||||
root_element = get_folder_items(user, folder_name)
|
||||
total_items = 0
|
||||
for response_element in root_element:
|
||||
for href_element in response_element:
|
||||
# The first item is folder itself so excluding it
|
||||
if href_element.tag == '{DAV:}href' and not href_element.text.endswith(
|
||||
f'{user}/{folder_name}/'
|
||||
):
|
||||
total_items += 1
|
||||
return str(total_items)
|
||||
|
||||
|
||||
def create_folder(user, folder_name):
|
||||
url = get_resource_path(user, folder_name)
|
||||
response = request.mkcol(url, user=user)
|
||||
assert (
|
||||
response.status_code == 201
|
||||
), f'Could not create the folder: {folder_name} for user {user}'
|
||||
|
||||
|
||||
def create_file(user, file_name, contents):
|
||||
url = get_resource_path(user, file_name)
|
||||
response = request.put(url, body=contents, user=user)
|
||||
assert response.status_code in [
|
||||
201,
|
||||
204,
|
||||
], f"Could not create file '{file_name}' for user {user}"
|
||||
|
||||
|
||||
def upload_file(user, file_name, destination):
|
||||
file_path = get_file_for_upload(file_name)
|
||||
with open(file_path, 'rb') as file:
|
||||
contents = file.read()
|
||||
create_file(user, destination, contents)
|
||||
|
||||
|
||||
def delete_resource(user, resource):
|
||||
url = get_resource_path(user, resource)
|
||||
response = request.delete(url, user=user)
|
||||
assert response.status_code == 204, f"Could not delete folder '{resource}'"
|
||||
|
||||
|
||||
def get_resource_id(user, resource):
|
||||
root_element = get_folder_items(user, resource)
|
||||
|
||||
# Finding the fileid elements using XPath with namespace notation
|
||||
fileid_elements = root_element.findall(".//{http://owncloud.org/ns}fileid")
|
||||
|
||||
# The First element is the desired resource's file id
|
||||
if fileid_elements:
|
||||
return fileid_elements[0].text
|
||||
|
||||
raise AssertionError(f'Could not find resource ID for {resource}')
|
||||
|
||||
|
||||
def get_permission_role_id(role_name):
|
||||
"""Get the permission role ID for a given role name"""
|
||||
if role_name not in PERMISSION_ROLES:
|
||||
raise ValueError(f"Unknown permission role: {role_name}")
|
||||
return PERMISSION_ROLES[role_name]
|
||||
|
||||
|
||||
def get_user_id(username):
|
||||
"""Get the user ID for a given username from created users"""
|
||||
if username in provisioning.created_users:
|
||||
return provisioning.created_users[username]['id']
|
||||
|
||||
raise AssertionError(f'User {username} not found in created users. Make sure the user is created first.')
|
||||
|
||||
|
||||
def send_resource_share_invitation(user, resource, sharee, permission_role):
|
||||
space_id = get_personal_space_id(user)
|
||||
resource_id = get_resource_id(user, resource)
|
||||
recipient_user_id = get_user_id(sharee)
|
||||
role_id = get_permission_role_id(permission_role)
|
||||
|
||||
url = url_join(get_beta_graph_url(), "drives", space_id, "items", resource_id, "invite")
|
||||
|
||||
body = {
|
||||
"roles": [role_id],
|
||||
"recipients": [
|
||||
{
|
||||
"objectId": recipient_user_id,
|
||||
"@libre.graph.recipient.type": "user"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = request.post(url, body=json.dumps(body), user=user)
|
||||
assert response.status_code in [200, 201], f"Failed to send share invitation: {response.status_code}"
|
||||
@@ -0,0 +1,76 @@
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
# Key mapping from Selenium's Keys to pyautogui's key names
|
||||
# See:
|
||||
# - https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.keys
|
||||
# - https://pyautogui.readthedocs.io/en/latest/keyboard.html?highlight=keys#keyboard-keys
|
||||
KEY_MAP = {
|
||||
Keys.ADD: 'add',
|
||||
Keys.ALT: 'alt',
|
||||
Keys.ARROW_DOWN: 'down',
|
||||
Keys.ARROW_LEFT: 'left',
|
||||
Keys.ARROW_RIGHT: 'right',
|
||||
Keys.ARROW_UP: 'up',
|
||||
Keys.BACKSPACE: 'backspace',
|
||||
Keys.BACK_SPACE: 'backspace',
|
||||
Keys.CLEAR: 'clear',
|
||||
Keys.COMMAND: 'command',
|
||||
Keys.CONTROL: 'ctrl',
|
||||
Keys.DECIMAL: 'decimal',
|
||||
Keys.DELETE: 'delete',
|
||||
Keys.DIVIDE: 'divide',
|
||||
Keys.DOWN: 'down',
|
||||
Keys.END: 'end',
|
||||
Keys.ENTER: 'enter',
|
||||
Keys.EQUALS: '=',
|
||||
Keys.ESCAPE: 'escape',
|
||||
Keys.F1: 'f1',
|
||||
Keys.F10: 'f10',
|
||||
Keys.F11: 'f11',
|
||||
Keys.F12: 'f12',
|
||||
Keys.F2: 'f2',
|
||||
Keys.F3: 'f3',
|
||||
Keys.F4: 'f4',
|
||||
Keys.F5: 'f5',
|
||||
Keys.F6: 'f6',
|
||||
Keys.F7: 'f7',
|
||||
Keys.F8: 'f8',
|
||||
Keys.F9: 'f9',
|
||||
Keys.HELP: 'help',
|
||||
Keys.HOME: 'home',
|
||||
Keys.INSERT: 'insert',
|
||||
Keys.LEFT: 'left',
|
||||
Keys.LEFT_ALT: 'altleft',
|
||||
Keys.LEFT_CONTROL: 'ctrlleft',
|
||||
Keys.LEFT_SHIFT: 'shiftleft',
|
||||
Keys.META: 'win',
|
||||
Keys.MULTIPLY: 'multiply',
|
||||
Keys.NUMPAD0: 'num0',
|
||||
Keys.NUMPAD1: 'num1',
|
||||
Keys.NUMPAD2: 'num2',
|
||||
Keys.NUMPAD3: 'num3',
|
||||
Keys.NUMPAD4: 'num4',
|
||||
Keys.NUMPAD5: 'num5',
|
||||
Keys.NUMPAD6: 'num6',
|
||||
Keys.NUMPAD7: 'num7',
|
||||
Keys.NUMPAD8: 'num8',
|
||||
Keys.NUMPAD9: 'num9',
|
||||
Keys.PAGE_DOWN: 'pagedown',
|
||||
Keys.PAGE_UP: 'pageup',
|
||||
Keys.PAUSE: 'pause',
|
||||
Keys.RETURN: 'return',
|
||||
Keys.RIGHT: 'right',
|
||||
Keys.SEMICOLON: ';',
|
||||
Keys.SEPARATOR: 'separator',
|
||||
Keys.SHIFT: 'shift',
|
||||
Keys.SPACE: 'space',
|
||||
Keys.SUBTRACT: 'subtract',
|
||||
Keys.TAB: 'tab',
|
||||
Keys.UP: 'up',
|
||||
}
|
||||
|
||||
|
||||
def get_key(key):
|
||||
if key in KEY_MAP:
|
||||
return KEY_MAP[key]
|
||||
return key
|
||||
Reference in New Issue
Block a user