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