Files
QSfera/Desktop/test/gui/helpers/SyncHelper.py
T
Курнат Андрей 2315f25754 Initial QSfera import
2026-06-07 10:20:04 +03:00

423 lines
14 KiB
Python

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