Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+119
View File
@@ -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']
+185
View File
@@ -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
+188
View File
@@ -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
+69
View File
@@ -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
+153
View File
@@ -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)
+422
View File
@@ -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
+102
View File
@@ -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
+74
View File
@@ -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")
+125
View File
@@ -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)
+20
View File
@@ -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()
+136
View File
@@ -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
+8
View File
@@ -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}"
+76
View File
@@ -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