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
+5
View File
@@ -0,0 +1,5 @@
custom_lib/
reports
venv/
__webdriver/
reports/
+87
View File
@@ -0,0 +1,87 @@
[MASTER]
init-hook='import sys; sys.path.append("./shared/scripts")'
[MAIN]
enable=all
disable=
missing-docstring,
global-statement,
locally-disabled,
suppressed-message,
unused-variable,
too-many-public-methods,
bare-except,
fixme,
fail-under=10
ignore-paths=^tst_.*/test.py$,
shared/scripts/names.py,
shared/scripts/custom_lib
ignored-modules=
squish,
squishinfo,
object,
objectmaphelper,
test,
requests,
psutil,
urllib3,
custom_lib.syncstate,
load-plugins=
pylint.extensions.check_elif,
pylint.extensions.bad_builtin,
pylint.extensions.code_style,
pylint.extensions.overlapping_exceptions,
pylint.extensions.typing,
pylint.extensions.redefined_variable_type,
py-version=3.10
unsafe-load-any-extension=no
[BASIC]
attr-naming-style=snake_case
; TODO: enable it later
; class-attribute-naming-style=snake_case
class-const-naming-style=UPPER_CASE
class-naming-style=PascalCase
const-naming-style=UPPER_CASE
function-naming-style=snake_case
inlinevar-naming-style=snake_case
method-naming-style=snake_case
module-naming-style=any
variable-naming-style=snake_case
[DESIGN]
max-args=8
[VARIABLES]
additional-builtins=
squish,
squishinfo,
test,
testSettings,
OnFeatureStart,
OnFeatureEnd,
OnScenarioStart,
OnScenarioEnd,
QApplication,
Given,
When,
Then,
Step,
allow-global-unused-variables=no
dummy-variables-rgx=^hook$|^_*$|^step$
ignored-argument-names=^context$|^_*$
[STRING]
check-quote-consistency=yes
[FORMAT]
indent-string=' '
max-line-length=120
[TYPECHECK]
ignored-classes=object,test
[SIMILARITIES]
ignore-comments=yes
ignore-docstrings=yes
ignore-imports=yes
+1
View File
@@ -0,0 +1 @@
ATSPI_WEBDRIVER_VERSION="6682aeb734730c50949f0fda69827b6c5b50dbc7"
+30
View File
@@ -0,0 +1,30 @@
PYTHON_LINT_PATHS:="./**/*.py"
.PHONY: install
install: pip-install install-chromium
.PHONY: install-chromium
install-chromium:
playwright install chromium
.PHONY: pip-install
pip-install:
python3 -m pip install -r requirements.txt
.PHONY: python-lint
python-lint:
black --check --diff .
python3 -m pylint --rcfile ./.pylintrc $(PYTHON_LINT_PATHS)
.PHONY: python-lint-fix
python-lint-fix:
black .
python3 -m pylint --rcfile ./.pylintrc $(PYTHON_LINT_PATHS)
.PHONY: gherkin-lint
gherkin-lint:
gherlint -c ./config/.gherlintrc.json .
.PHONY: gherkin-lint-fix
gherkin-lint-fix:
gherlint --fix -c ./config/.gherlintrc.json .
+21
View File
@@ -0,0 +1,21 @@
[behave]
paths=features
default_format = pretty
; NOTE: 'pretty' formatter eats up the last print statement in the test,
; so switch to 'plain' formatter for better debugging.
format = pretty
html-pretty
outfiles = reports/report.log
reports/report.html
default_tags = not @skip
logging_level = WARNING
capture = false
capture_hooks = false
show_skipped = false
[behave.formatters]
html-pretty = behave_html_pretty_formatter:PrettyHTMLFormatter
[behave.userdata]
behave.formatter.html-pretty.title_string = GUI Test Report
behave.formatter.html-pretty.pretty_output = false
+10
View File
@@ -0,0 +1,10 @@
[DEFAULT]
APP_PATH=
BACKEND_HOST=
CLIENT_ROOT_SYNC_PATH=
MAX_SYNC_TIMEOUT=
MIN_SYNC_TIMEOUT=
LOWEST_SYNC_TIMEOUT=
TEMP_FOLDER_PATH=
GUI_TEST_REPORT_DIR=
RECORD_VIDEO_ON_FAILURE=false
+14
View File
@@ -0,0 +1,14 @@
{
"rules": {
"indentation": ["error", 4],
"no_repetitive_step_keyword": "error",
"require_step": "error",
"no_trailing_whitespace": "error",
"require_scenario": "error",
"no_then_as_first_step": "error",
"only_given_step_in_background": "error",
"newline_before_scenario": ["error", 2, true],
"lowercase_title": "off",
"no_but_in_given_when": "error"
}
}
+97
View File
@@ -0,0 +1,97 @@
import shutil
import os
import re
import pyautogui
from behave.model_core import Status
from datetime import datetime
from helpers import ScreenRecorder
from helpers.ConfigHelper import init_config
from helpers.api.provisioning import delete_created_users
from helpers.SpaceHelper import delete_project_spaces
from helpers.ConfigHelper import get_config
from helpers.FilesHelper import prefix_path_namespace, cleanup_created_paths
from helpers.AppHelper import close_and_kill_app
from helpers.SyncHelper import clear_socket_messages
from step_types.types import * # register all step types
def append_scenario_to_app_log(scenario):
with open(get_config('appLogFile'), 'a') as log_file:
logs = ["=" * 80]
logs.append(
f"Scenario: {scenario.name}\nLocation: {scenario.filename}:{scenario.line}"
)
logs.append("-" * 80)
logs.append("") # extra line break
log_file.write("\n".join(logs))
def store_app_log():
with open(get_config('appLogFile'), 'a') as log_file:
# client log is stored in utf-16.
with open(
get_config('currentAppLogFile'), 'r', encoding='utf-16'
) as current_log:
log_file.write(f"{current_log.read()}\n\n")
def cleanup_app_log():
if os.path.exists(get_config('currentAppLogFile')):
os.remove(get_config('currentAppLogFile'))
def before_feature(context, feature):
init_config()
def before_scenario(context, scenario):
if os.getenv("CI"):
ScreenRecorder.start_recording(scenario)
def after_step(context, step):
if step.status in [Status.failed, Status.error] and os.getenv("CI"):
scenario = context.scenario.name.lower()
scenario = re.sub(r'[^a-zA-Z0-9_]', '_', scenario)
timestamp = datetime.now().strftime("%d-%b-%Y_%H-%M-%S")
screenshots_dir = os.path.join(get_config("guiTestReportDir"), "screenshots")
os.makedirs(screenshots_dir, exist_ok=True)
file_path = os.path.join(screenshots_dir, f"{scenario}_{timestamp}.png")
pyautogui.screenshot(file_path)
def after_scenario(context, scenario):
# stop screen recording
if os.getenv("CI"):
ScreenRecorder.stop_recording(passed=scenario.status == Status.passed)
# clean up sync dir
if os.path.exists(get_config("clientRootSyncPath")):
for entry in os.scandir(get_config("clientRootSyncPath")):
try:
if entry.is_file() or entry.is_symlink():
print("Deleting file: " + entry.name)
os.unlink(prefix_path_namespace(entry.path))
elif entry.is_dir():
print("Deleting folder: " + entry.name)
shutil.rmtree(prefix_path_namespace(entry.path))
except OSError as e:
print(f"Failed to delete '{entry.name}'.\nReason: {e}.")
# cleanup paths created outside of the temporary directory during the test
cleanup_created_paths()
delete_project_spaces()
delete_created_users()
# quit the application
close_and_kill_app()
# store app log on scenario failure
if scenario.status in [Status.failed, Status.error] and os.path.exists(
get_config('currentAppLogFile')
):
append_scenario_to_app_log(scenario)
store_app_log()
cleanup_app_log()
clear_socket_messages()
@@ -0,0 +1,57 @@
Feature: filter activity for user
As a user
I want to filter activity
So that I can view activity of specific user
@smoke
Scenario: filter synced activities
Given user "Alice" has been created in the server with default attributes
And user "Brian" has been created in the server with default attributes
And user "Alice" has created folder "simple-folder" in the server
And user "Brian" has created folder "brian-folder" in the server
And the user has set up the following accounts with default settings:
| users |
| Alice |
| Brian |
When the user opens the activity tab
And the user selects "Local Activity" tab in the activity
And the user checks the activities of account "Alice Hansen@%local_server_hostname%"
Then the following activities should be displayed in synced table
| resource | action | account |
| simple-folder | Downloaded | Alice Hansen@%local_server_hostname% |
But the following activities should not be displayed in synced table
| resource | action | account |
| brian-folder | Downloaded | Brian Murphy@%local_server_hostname% |
@skipOnWindows
Scenario: filter not synced activities (Linux only)
Given user "Alice" has been created in the server with default attributes
And user "Alice" has set up a client with default settings
And user "Alice" has created a folder "Folder1" inside the sync folder
When user "Alice" creates the following files inside the sync folder:
| files |
| /.htaccess |
| /Folder1/a\\a.txt |
And the user opens the activity tab
And the user selects "Not Synced" tab in the activity
Then the file "Folder1/a\\a.txt" should be blacklisted
And the file ".htaccess" should be excluded
When the user unchecks the "Excluded" filter
Then the following activities should be displayed in not synced table
| resource | status | account |
| Folder1/a\\a.txt | Blacklisted | Alice Hansen@%local_server_hostname% |
@skipOnLinux
Scenario: filter not synced activities (Windows only)
Given user "Alice" has been created in the server with default attributes
And user "Alice" has set up a client with default settings
When user "Alice" creates the following files inside the sync folder:
| files |
| /.htaccess |
And the user opens the activity tab
And the user selects "Not Synced" tab in the activity
Then the file ".htaccess" should be excluded
When the user unchecks the "Excluded" filter
Then the following activities should not be displayed in not synced table
| resource | status | account |
| .htaccess | Excluded | Alice Hansen@%local_server_hostname% |
@@ -0,0 +1,91 @@
Feature: adding accounts
As a user
I want to be able join multiple QSfera servers to the client
So that I can sync data with various organisations
Background:
Given user "Alice" has been created in the server with default attributes
Scenario: Check default options in advanced configuration
Given the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user opens the advanced configuration
Then the download everything option should be selected by default for Linux
And the user should be able to choose the local download directory
@smoke
Scenario: Adding normal Account
Given the user has started the client
When the user adds the following account:
| server | %local_server% |
| user | Alice |
| password | 1234 |
Then "Alice" account should be added
@smoke
Scenario: Adding multiple accounts
Given user "Brian" has been created in the server with default attributes
And user "Alice" has set up a client with default settings
When the user opens the add-account dialog
And the user adds the following account:
| server | %local_server% |
| user | Brian |
| password | AaBb2Cc3Dd4 |
Then "Brian" account should be opened
And "Alice" account should be added
Scenario: Adding account with self signed certificate for the first time
Given the user has started the client
When the user adds the server "%local_server%"
And the user accepts the certificate
Then credentials wizard should be visible
When the user adds the following account:
| user | Alice |
| password | 1234 |
Then "Alice" account should be opened
@smoke
Scenario: Add space manually from sync connection window
Given user "Alice" has created folder "simple-folder" in the server
And the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user selects manual sync folder option in advanced section
And the user syncs the "Personal" space
Then the folder "simple-folder" should exist on the file system
Scenario: Check for suffix when sync path exists
Given the user has created folder "QSfera" in the default home path
And the user has started the client
And the user has entered the following account information:
| server | %local_server% |
When the user adds the following user credentials:
| user | Alice |
| password | 1234 |
And the user opens the advanced configuration
Then the default local sync path should contain "%home%/QSfera (2)" in the configuration wizard
When the user selects download everything option in advanced section
Then the button to open sync connection wizard should be disabled
@smoke
Scenario: Re-add an account
Given user "Alice" has created folder "large-folder" in the server
And user "Alice" has uploaded file with content "test content" to "testFile.txt" in the server
And user "Alice" has set up a client with default settings
And the user has removed the connection for user "Alice"
When the user opens the add-account dialog
And the user adds the following account:
| server | %local_server% |
| user | Alice |
| password | 1234 |
Then "Alice" account should be added
And the folder "large-folder" should exist on the file system
And the file "testFile.txt" should exist on the file system
@@ -0,0 +1,80 @@
Feature: deleting files and folders
As a user
I want to delete files and folders
So that I can keep my filing system clean and tidy
Background:
Given user "Alice" has been created in the server with default attributes
@issue-9439 @smoke
Scenario Outline: Delete a file
Given user "Alice" has uploaded file with content "openCloud test text file 0" to "<fileName>" in the server
And user "Alice" has set up a client with default settings
When the user deletes the file "<fileName>"
And the user waits for file "<fileName>" to be synced
Then as "Alice" file "<fileName>" should not exist in the server
Examples:
| fileName |
| textfile0.txt |
| textfile0-with-name-more-than-20-characters |
| ~`!@#$^&()-_=+{[}];',textfile.txt |
@issue-9439 @smoke
Scenario Outline: Delete a folder
Given user "Alice" has created folder "<folderName>" in the server
And user "Alice" has set up a client with default settings
When the user deletes the folder "<folderName>"
And the user waits for folder "<folderName>" to be synced
Then as "Alice" file "<folderName>" should not exist in the server
Examples:
| folderName |
| simple-empty-folder |
| simple-folder-with-name-more-than-20-characters |
@smoke
Scenario: Delete a file and a folder
Given user "Alice" has uploaded file with content "test file 1" to "textfile1.txt" in the server
And user "Alice" has uploaded file with content "test file 2" to "textfile2.txt" in the server
And user "Alice" has created folder "test-folder1" in the server
And user "Alice" has created folder "test-folder2" in the server
And user "Alice" has set up a client with default settings
When the user deletes the file "textfile1.txt"
And the user deletes the folder "test-folder1"
And the user waits for the files to sync
Then as "Alice" file "textfile1.txt" should not exist in the server
And as "Alice" folder "test-folder1" should not exist in the server
And as "Alice" file "textfile2.txt" should exist in the server
And as "Alice" folder "test-folder2" should exist in the server
Scenario: Delete multiple files
Given user "Alice" has uploaded the following files to the server
| file | content |
| textfile0.txt | openCloud test text file 0 |
| textfile1.txt | openCloud test text file 1 |
| textfile2.txt | openCloud test text file 2 |
And user "Alice" has set up a client with default settings
When the user deletes the following files
| file |
| textfile0.txt |
| textfile1.txt |
And the user waits for the files to sync
Then as "Alice" following files should not exist in the server
| file |
| textfile0.txt |
| textfile1.txt |
And as "Alice" file "textfile2.txt" should exist in the server
Scenario Outline: Create and delete a file with special characters
Given user "Alice" has set up a client with default settings
When user "Alice" creates a file "<fileName>" with the following content inside the sync folder
"""
special characters
"""
And the user deletes the file "<fileName>"
And the user waits for the files to sync
Then as "Alice" file "<fileName>" should not exist in the server
Examples:
| fileName |
| ~`!@#$^&()-_=+{[}];',$%ñ&💥🫨❤️‍🔥.txt |
@@ -0,0 +1,44 @@
Feature: edit files
As a user
I want to be able to edit the file content
So that I can modify and change file data
Background:
Given user "Alice" has been created in the server with default attributes
@smoke
Scenario: Modify original content of a file with special character
Given user "Alice" has uploaded file with content "openCloud test text file 0" to "S@mpleFile!With,$pecial&Characters.txt" in the server
And user "Alice" has set up a client with default settings
When the user overwrites the file "S@mpleFile!With,$pecial&Characters.txt" with content "overwrite openCloud test text file"
And the user waits for file "S@mpleFile!With,$pecial&Characters.txt" to be synced
Then as "Alice" the file "S@mpleFile!With,$pecial&Characters.txt" should have the content "overwrite openCloud test text file" in the server
@smoke
Scenario: Modify original content of a file
Given user "Alice" has set up a client with default settings
When user "Alice" creates a file "testfile.txt" with the following content inside the sync folder
"""
test content
"""
And the user waits for file "testfile.txt" to be synced
And the user overwrites the file "testfile.txt" with content "overwrite openCloud test text file"
And the user waits for file "testfile.txt" to be synced
Then as "Alice" the file "testfile.txt" should have the content "overwrite openCloud test text file" in the server
Scenario Outline: Replace and modify the content of a file multiple times
Given user "Alice" has set up a client with default settings
And the user has copied file "<initialFile>" from outside the sync folder to "/" in the sync folder
When the user copies file "<updateFile1>" from outside the sync folder to "/<initialFile>" in the sync folder
And the user waits for the files to sync
And the user copies file "<updateFile2>" from outside the sync folder to "/<initialFile>" in the sync folder
And the user waits for the files to sync
And the user copies file "<updateFile3>" from outside the sync folder to "/<initialFile>" in the sync folder
And the user waits for the files to sync
Then as "Alice" the content of file "<initialFile>" in the server should match the content of local file "<updateFile3>"
Examples:
| initialFile | updateFile1 | updateFile2 | updateFile3 |
| simple.pdf | simple1.pdf | simple2.pdf | simple3.pdf |
| simple.docx | simple1.docx | simple2.docx | simple3.docx |
| simple.xlsx | simple1.xlsx | simple2.xlsx | simple3.xlsx |
@@ -0,0 +1,23 @@
Feature: Logout users
As a user
I want to be able to login and logout of my account
So that I can protect my work and identity and be assured of privacy
Background:
Given user "Alice" has been created in the server with default attributes
@smoke
Scenario: logging out
Given user "Alice" has set up a client with default settings
When the user "Alice" logs out using the client-UI
Then user "Alice" should be signed out
@smoke
Scenario: login after logging out
Given user "Alice" has set up a client with default settings
And user "Alice" has logged out from the client-UI
When user "Alice" logs in using the client-UI
Then user "Alice" should be connected to the server
When the user quits the client
And the user starts the client
Then user "Alice" should be connected to the server
@@ -0,0 +1,90 @@
Feature: move file and folder
As a user
I want to be able to move a folder and a file
So that I can organize my files and folders
Background:
Given user "Alice" has been created in the server with default attributes
And user "Alice" has created folder "folder1" in the server
And user "Alice" has created folder "folder1/folder2" in the server
And user "Alice" has created folder "folder1/folder2/folder3" in the server
And user "Alice" has created folder "folder1/folder2/folder3/folder4" in the server
And user "Alice" has created folder "folder1/folder2/folder3/folder4/folder5" in the server
Scenario: Move folder and file from level 5 sub-folder to sync root
Given user "Alice" has created folder "folder1/folder2/folder3/folder4/folder5/test-folder" in the server
And user "Alice" has uploaded file with content "openCloud" to "folder1/folder2/folder3/folder4/folder5/lorem.txt" in the server
And user "Alice" has set up a client with default settings
When user "Alice" moves file "folder1/folder2/folder3/folder4/folder5/lorem.txt" to "/" in the sync folder
And user "Alice" moves folder "folder1/folder2/folder3/folder4/folder5/test-folder" to "/" in the sync folder
And the user waits for the files to sync
Then as "Alice" the file "lorem.txt" should have the content "openCloud" in the server
And as "Alice" folder "test-folder" should exist in the server
And as "Alice" file "folder1/folder2/folder3/folder4/folder5/lorem.txt" should not exist in the server
And as "Alice" folder "folder1/folder2/folder3/folder4/folder5/test-folder" should not exist in the server
Scenario: Move two folders and a file down to the level 5 sub-folder
And user "Alice" has created folder "test-folder1" in the server
And user "Alice" has created folder "test-folder2" in the server
And user "Alice" has uploaded file with content "openCloud test" to "testFile.txt" in the server
And user "Alice" has set up a client with default settings
When user "Alice" moves folder "test-folder1" to "folder1/folder2/folder3/folder4/folder5" in the sync folder
And user "Alice" moves folder "test-folder2" to "folder1/folder2/folder3/folder4/folder5" in the sync folder
And user "Alice" moves file "testFile.txt" to "folder1/folder2/folder3/folder4/folder5" in the sync folder
And the user waits for the files to sync
Then as "Alice" the file "folder1/folder2/folder3/folder4/folder5/testFile.txt" should have the content "openCloud test" in the server
And as "Alice" folder "folder1/folder2/folder3/folder4/folder5/test-folder1" should exist in the server
And as "Alice" folder "folder1/folder2/folder3/folder4/folder5/test-folder2" should exist in the server
And as "Alice" file "testFile.txt" should not exist in the server
And as "Alice" folder "test-folder1" should not exist in the server
And as "Alice" folder "test-folder2" should not exist in the server
@smoke
Scenario: Rename a file and a folder
Given user "Alice" has uploaded file with content "test file 1" to "textfile.txt" in the server
And user "Alice" has set up a client with default settings
When the user renames a file "textfile.txt" to "lorem.txt"
And the user renames a folder "folder1" to "FOLDER"
And the user waits for the files to sync
Then as "Alice" the file "lorem.txt" should have the content "test file 1" in the server
And as "Alice" folder "FOLDER" should exist in the server
But as "Alice" file "textfile.txt" should not exist in the server
And as "Alice" folder "folder1" should not exist in the server
@smoke
Scenario: Move files from one folder to another
Given user "Alice" has uploaded file with content "test file 1" to "folder1/file1.txt" in the server
And user "Alice" has uploaded file with content "test file 2" to "folder1/file2.txt" in the server
And user "Alice" has set up a client with default settings
When user "Alice" moves file "folder1/file1.txt" to "folder1/folder2" in the sync folder
And user "Alice" moves file "folder1/file2.txt" to "folder1/folder2" in the sync folder
And the user waits for the files to sync
Then as "Alice" the file "folder1/folder2/file1.txt" should have the content "test file 1" in the server
And as "Alice" the file "folder1/folder2/file2.txt" should have the content "test file 2" in the server
And as "Alice" file "folder1/file1.txt" should not exist in the server
And as "Alice" file "folder1/file2.txt" should not exist in the server
Scenario: Move resources from different sub-levels to sync root
Given user "Alice" has created folder "folder1/folder2/folder3/folder4/test-folder" in the server
And user "Alice" has uploaded file with content "openCloud" to "folder1/folder2/lorem.txt" in the server
And user "Alice" has set up a client with default settings
When user "Alice" moves file "folder1/folder2/lorem.txt" to "/" in the sync folder
And user "Alice" moves folder "folder1/folder2/folder3/folder4/test-folder" to "/" in the sync folder
And the user waits for the files to sync
Then as "Alice" the file "lorem.txt" should have the content "openCloud" in the server
And as "Alice" folder "test-folder" should exist in the server
And as "Alice" file "folder1/folder2/lorem.txt" should not exist in the server
And as "Alice" folder "folder1/folder2/folder3/folder4/test-folder" should not exist in the server
Scenario: Syncing a 50MB file moved into the local sync folder
Given user "Alice" has set up a client with default settings
And user "Alice" has created a folder "NewFolder" inside the sync folder
And user "Alice" has created a file "newfile.txt" with size "50MB" in the sync folder
When user "Alice" moves file "newfile.txt" to "NewFolder" in the sync folder
And the user waits for file "NewFolder/newfile.txt" to be synced
Then as "Alice" file "NewFolder/newfile.txt" should exist in the server
@@ -0,0 +1,28 @@
Feature: remove account connection
As a user
I want to remove my account
So that I won't be using any client-UI services
@smoke
Scenario: remove an account connection
Given user "Alice" has been created in the server with default attributes
And user "Brian" has been created in the server with default attributes
And the user has set up the following accounts with default settings:
| users |
| Alice |
| Brian |
When the user removes the connection for user "Brian"
Then "Brian" account should not be displayed
But "Alice" account should be added
Scenario: remove the only account connection
Given user "Alice" has been created in the server with default attributes
And user "Alice" has created folder "large-folder" in the server
And user "Alice" has uploaded file with content "test content" to "testFile.txt" in the server
And user "Alice" has set up a client with default settings
When the user removes the connection for user "Alice"
Then the settings tab should have the following options in the general section:
| Start on Login |
And the folder "large-folder" should exist on the file system
And the file "testFile.txt" should exist on the file system
@@ -0,0 +1,98 @@
Feature: Project spaces
As a user
I want to sync project space
So that I can do view and manage the space
Background:
Given user "Alice" has been created in the server with default attributes
And the administrator has created a space "Project101"
@smoke
Scenario: User with Viewer role can open the file
Given the administrator has created a folder "planning" in space "Project101"
And the administrator has uploaded a file "testfile.txt" with content "some content" inside space "Project101"
And the administrator has added user "Alice" to space "Project101" with role "viewer"
And user "Alice" has set up a client with space "Project101"
Then user "Alice" should be able to open the file "testfile.txt" on the file system
And as "Alice" the file "testfile.txt" should have content "some content" on the file system
Scenario: User with Viewer role cannot edit the file
Given the administrator has created a folder "planning" in space "Project101"
And the administrator has uploaded a file "testfile.txt" with content "some content" inside space "Project101"
And the administrator has added user "Alice" to space "Project101" with role "viewer"
And user "Alice" has set up a client with space "Project101"
Then user "Alice" should not be able to edit the file "testfile.txt" on the file system
And as "Alice" the file "testfile.txt" in the space "Project101" should have content "some content" in the server
@smoke
Scenario: User with Editor role can edit the file
Given the administrator has created a folder "planning" in space "Project101"
And the administrator has uploaded a file "testfile.txt" with content "some content" inside space "Project101"
And the administrator has added user "Alice" to space "Project101" with role "editor"
And user "Alice" has set up a client with space "Project101"
When the user overwrites the file "testfile.txt" with content "some content edited"
And the user waits for file "testfile.txt" to be synced
Then as "Alice" the file "testfile.txt" in the space "Project101" should have content "some content edited" in the server
@smoke
Scenario: User with Manager role can add files and folders
Given the administrator has added user "Alice" to space "Project101" with role "manager"
And user "Alice" has set up a client with space "Project101"
When user "Alice" creates a file "localFile.txt" with the following content inside the sync folder
"""
test content
"""
And user "Alice" creates a folder "localFolder" inside the sync folder
And the user waits for file "localFile.txt" to be synced
And the user waits for folder "localFolder" to be synced
Then as "Alice" the file "localFile.txt" in the space "Project101" should have content "test content" in the server
And as "Alice" the space "Project101" should have folder "localFolder" in the server
@smoke
Scenario: User with Editor role can rename a file
Given the administrator has uploaded a file "testfile.txt" with content "some content" inside space "Project101"
And the administrator has added user "Alice" to space "Project101" with role "editor"
And user "Alice" has set up a client with space "Project101"
When the user renames a file "testfile.txt" to "renamedFile.txt"
And the user waits for file "renamedFile.txt" to be synced
Then as "Alice" the space "Project101" should have file "renamedFile.txt" in the server
And as "Alice" the file "renamedFile.txt" in the space "Project101" should have content "some content" in the server
@smoke
Scenario: Remove folder sync connection (Project Space)
Given the administrator has uploaded a file "testfile.txt" with content "some content" inside space "Project101"
And the administrator has added user "Alice" to space "Project101" with role "manager"
And user "Alice" has set up a client with space "Project101"
When the user removes the folder sync connection
Then for user "Alice" sync folder "Project101" should not be displayed
But the file "testfile.txt" should exist on the file system
Scenario: User with Viewer role cannot create resource
Given the administrator has added user "Alice" to space "Project101" with role "viewer"
And user "Alice" has set up a client with space "Project101"
When user "Alice" creates a folder "simple-folder" inside the sync folder
Then the following error message should appear in the client
"""
simple-folder: Not allowed because you don't have permission to add subfolders to that folder
"""
When the user opens the activity tab
And the user selects "Not Synced" tab in the activity
Then the following activities should be displayed in not synced table
| resource | status | account |
| simple-folder | Blacklisted | Alice Hansen@%local_server_hostname% |
@smoke
Scenario: Sharee with Editor role deletes the shared resource
Given user "Brian" has been created in the server with default attributes
And user "Alice" has created folder "simple-folder" in the server
And user "Alice" has uploaded file with content "test content" to "simple-folder/uploaded-lorem.txt" in the server
And user "Alice" has sent the following resource share invitation:
| resource | simple-folder |
| sharee | Brian |
| permissionsRole | Editor |
And user "Brian" has set up a client with space "Shares"
When user "Brian" deletes the folder "Shares/simple-folder" in the server
And the user waits for the files to sync
Then the folder "simple-folder" should not exist on the file system
@@ -0,0 +1,610 @@
Feature: Syncing files
As a user
I want to be able to sync my local folders to to my QSfera server
so that I dont have to upload and download files manually
Background:
Given user "Alice" has been created in the server with default attributes
@issue-9281 @smoke
Scenario: Syncing a file to the server
Given user "Alice" has set up a client with default settings
When user "Alice" creates a file "lorem-for-upload.txt" with the following content inside the sync folder
"""
test content
"""
And the user waits for file "lorem-for-upload.txt" to be synced
And the user opens the activity tab
And the user selects "Local Activity" tab in the activity
Then the file "lorem-for-upload.txt" should have status "Uploaded" in the activity tab
And as "Alice" the file "lorem-for-upload.txt" should have the content "test content" in the server
@smoke
Scenario: Syncing all files and folders from the server
Given user "Alice" has created folder "simple-folder" in the server
And user "Alice" has created folder "large-folder" in the server
And user "Alice" has uploaded file with content "test content" to "uploaded-lorem.txt" in the server
And user "Alice" has set up a client with default settings
Then the file "uploaded-lorem.txt" should exist on the file system
And the file "uploaded-lorem.txt" should exist on the file system with the following content
"""
test content
"""
And the folder "simple-folder" should exist on the file system
And the folder "large-folder" should exist on the file system
@issue-9733
Scenario: Syncing a file from the server and creating a conflict
Given user "Alice" has uploaded file with content "server content" to "/conflict.txt" in the server
And user "Alice" has set up a client with default settings
And the user has paused the file sync
And the user has changed the content of local file "conflict.txt" to:
"""
client content
"""
And user "Alice" has uploaded file with content "changed server content" to "/conflict.txt" in the server
And the user has waited for "5" seconds
When the user resumes the file sync on the client
And the user opens the activity tab
And the user selects "Not Synced" tab in the activity
Then the table of conflict warnings should include file "conflict.txt"
And the file "conflict.txt" should exist on the file system with the following content
"""
changed server content
"""
And a conflict file for "conflict.txt" should exist on the file system with the following content
"""
client content
"""
@skipOnWindows
Scenario: Sync all is selected by default
Given user "Alice" has created folder "simple-folder" in the server
And user "Alice" has created folder "large-folder" in the server
And user "Alice" has uploaded file with content "test content" to "testFile.txt" in the server
And user "Alice" has uploaded file with content "lorem content" to "lorem.txt" in the server
And the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user selects manual sync folder option in advanced section
And the user sets the sync path in sync connection wizard
And the user navigates back in the sync connection wizard
And the user sets the temp folder "localSyncFolder" as local sync path in sync connection wizard
And the user selects "Personal" space in sync connection wizard
Then the sync all checkbox should be checked
When user unselects all the remote folders
And the user adds the folder sync connection
And the user waits for the files to sync
Then the file "testFile.txt" should exist on the file system
And the file "lorem.txt" should exist on the file system
But the folder "simple-folder" should not exist on the file system
And the folder "large-folder" should not exist on the file system
@skipOnWindows @smoke
Scenario: Sync only one folder from the server
Given user "Alice" has created folder "simple-folder" in the server
And user "Alice" has created folder "large-folder" in the server
And the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user selects manual sync folder option in advanced section
And the user sets the sync path in sync connection wizard
And the user selects "Personal" space in sync connection wizard
And the user selects the following folders to sync:
| folder |
| simple-folder |
Then the folder "simple-folder" should exist on the file system
But the folder "large-folder" should not exist on the file system
When user "Alice" uploads file with content "some content" to "simple-folder/lorem.txt" in the server
And user "Alice" uploads file with content "openCloud" to "large-folder/lorem.txt" in the server
And user "Alice" creates a file "simple-folder/localFile.txt" with the following content inside the sync folder
"""
test content
"""
And the user waits for the files to sync
Then the file "simple-folder/lorem.txt" should exist on the file system
And the file "large-folder/lorem.txt" should not exist on the file system
And as "Alice" file "simple-folder/localFile.txt" should exist in the server
@issue-9733 @skipOnWindows
Scenario: sort folders list by name and size
Given user "Alice" has created folder "123Folder" in the server
And user "Alice" has uploaded file with content "small" to "123Folder/lorem.txt" in the server
And user "Alice" has created folder "aFolder" in the server
And user "Alice" has uploaded file with content "more contents" to "aFolder/lorem.txt" in the server
And user "Alice" has created folder "bFolder" in the server
And the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user selects manual sync folder option in advanced section
And the user sets the sync path in sync connection wizard
And the user selects "Personal" space in sync connection wizard
# folders are sorted by name in ascending order by default
Then the folders should be in the following order:
| folder |
| 123Folder |
| aFolder |
| bFolder |
# sort folder by name in descending order
When the user sorts the folder list by "Name"
Then the folders should be in the following order:
| folder |
| bFolder |
| aFolder |
| 123Folder |
# sort folder by size in ascending order
When the user sorts the folder list by "Size"
Then the folders should be in the following order:
| folder |
| bFolder |
| 123Folder |
| aFolder |
# sort folder by size in descending order
When the user sorts the folder list by "Size"
Then the folders should be in the following order:
| folder |
| aFolder |
| 123Folder |
| bFolder |
And the user cancels the sync connection wizard
@smoke
Scenario Outline: Syncing a folder to the server
Given user "Alice" has set up a client with default settings
When user "Alice" creates a folder <foldername> inside the sync folder
And the user waits for folder <foldername> to be synced
Then as "Alice" folder <foldername> should exist in the server
Examples:
| foldername |
| "myFolder" |
| "really long folder name with some spaces and special char such as $%ñ&" |
@skipOnWindows
Scenario Outline: Syncing a folder having space at the end (Linux only)
Given user "Alice" has set up a client with default settings
When user "Alice" creates a folder <foldername> inside the sync folder
And the user waits for folder <foldername> to be synced
Then as "Alice" folder <foldername> should exist in the server
Examples:
| foldername |
| "folder with space at end " |
@skipOnLinux
Scenario: Try to sync files having space at the end (Windows only)
Given user "Alice" has uploaded file with content "lorem epsum" to "trailing-space.txt " in the server
And user "Alice" has set up a client with default settings
When user "Alice" creates a folder "folder with space at end " inside the sync folder
And the user force syncs the files
And the user opens the activity tab
And the user selects "Not Synced" tab in the activity
Then the file "trailing-space.txt " should be ignored
And the file "folder with space at end " should be ignored
@smoke
Scenario: Many subfolders can be synced
Given user "Alice" has created folder "parent" in the server
And user "Alice" has set up a client with default settings
When user "Alice" creates a folder "parent/subfolderEmpty1" inside the sync folder
And user "Alice" creates a folder "parent/subfolderEmpty2" inside the sync folder
And user "Alice" creates a folder "parent/subfolderEmpty3" inside the sync folder
And user "Alice" creates a folder "parent/subfolderEmpty4" inside the sync folder
And user "Alice" creates a folder "parent/subfolderEmpty5" inside the sync folder
And user "Alice" creates a folder "parent/subfolder1" inside the sync folder
And user "Alice" creates a folder "parent/subfolder2" inside the sync folder
And user "Alice" creates a folder "parent/subfolder3" inside the sync folder
And user "Alice" creates a folder "parent/subfolder4" inside the sync folder
And user "Alice" creates a folder "parent/subfolder5" inside the sync folder
And user "Alice" creates a file "parent/subfolder1/test.txt" with the following content inside the sync folder
"""
test content
"""
And user "Alice" creates a file "parent/subfolder2/test.txt" with the following content inside the sync folder
"""
test content
"""
And user "Alice" creates a file "parent/subfolder3/test.txt" with the following content inside the sync folder
"""
test content
"""
And user "Alice" creates a file "parent/subfolder4/test.txt" with the following content inside the sync folder
"""
test content
"""
And user "Alice" creates a file "parent/subfolder5/test.txt" with the following content inside the sync folder
"""
test content
"""
And the user waits for file "parent/subfolder5/test.txt" to be synced
Then as "Alice" folder "parent/subfolderEmpty1" should exist in the server
And as "Alice" folder "parent/subfolderEmpty2" should exist in the server
And as "Alice" folder "parent/subfolderEmpty3" should exist in the server
And as "Alice" folder "parent/subfolderEmpty4" should exist in the server
And as "Alice" folder "parent/subfolderEmpty5" should exist in the server
And as "Alice" folder "parent/subfolder1" should exist in the server
And as "Alice" folder "parent/subfolder2" should exist in the server
And as "Alice" folder "parent/subfolder3" should exist in the server
And as "Alice" folder "parent/subfolder4" should exist in the server
And as "Alice" folder "parent/subfolder5" should exist in the server
@smoke
Scenario: Both original and copied folders can be synced
Given user "Alice" has set up a client with default settings
When user "Alice" creates a folder "original" inside the sync folder
And user "Alice" creates a file "original/localFile.txt" with the following content inside the sync folder
"""
test content
"""
And the user copies folder "original" into the same directory
And the user waits for folder "original (Copy)" to be synced
Then as "Alice" folder "original" should exist in the server
And as "Alice" the file "original/localFile.txt" should have the content "test content" in the server
And as "Alice" folder "original (Copy)" should exist in the server
And as "Alice" the file "original (Copy)/localFile.txt" should have the content "test content" in the server
@issue-9281 @smoke
Scenario: Verify that you can create a subfolder with long name(~220 characters)
Given user "Alice" has created a folder "Folder1" inside the sync folder
And user "Alice" has set up a client with default settings
When user "Alice" creates a folder "Folder1/thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks" inside the sync folder
And the user waits for folder "Folder1/thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks" to be synced
Then the folder "Folder1/thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks" should exist on the file system
And as "Alice" folder "Folder1/thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks-thisIsAVeryLongFolderNameToCheckThatItWorks" should exist in the server
@smoke
Scenario: Verify pre existing folders in local (Desktop client) are copied over to the server
Given user "Alice" has created a folder "Folder1" inside the sync folder
And user "Alice" has created a folder "Folder1/subFolder1" inside the sync folder
And user "Alice" has created a folder "Folder1/subFolder1/subFolder2" inside the sync folder
And user "Alice" has set up a client with default settings
Then as "Alice" folder "Folder1" should exist in the server
And as "Alice" folder "Folder1/subFolder1" should exist in the server
And as "Alice" folder "Folder1/subFolder1/subFolder2" should exist in the server
@skipOnWindows
Scenario: Filenames that are rejected by the server are reported (Linux only)
Given user "Alice" has created folder "Folder1" in the server
And user "Alice" has set up a client with default settings
When user "Alice" creates a file "Folder1/a\\a.txt" with the following content inside the sync folder
"""
test content
"""
And the user opens the activity tab
And the user selects "Not Synced" tab in the activity
Then the file "Folder1/a\\a.txt" should exist on the file system
And the file "Folder1/a\\a.txt" should be blacklisted
Scenario Outline: Sync long nested folder
Given user "Alice" has created folder "<foldername>" in the server
And user "Alice" has set up a client with default settings
When user "Alice" creates a folder "<foldername>/<foldername>" inside the sync folder
And user "Alice" creates a folder "<foldername>/<foldername>/<foldername>" inside the sync folder
And user "Alice" creates a folder "<foldername>/<foldername>/<foldername>/<foldername>" inside the sync folder
And user "Alice" creates a folder "<foldername>/<foldername>/<foldername>/<foldername>/<foldername>" inside the sync folder
And the user waits for folder "<foldername>/<foldername>/<foldername>/<foldername>/<foldername>" to be synced
Then as "Alice" folder "<foldername>/<foldername>" should exist in the server
And as "Alice" folder "<foldername>/<foldername>/<foldername>" should exist in the server
And as "Alice" folder "<foldername>/<foldername>/<foldername>/<foldername>" should exist in the server
And as "Alice" folder "<foldername>/<foldername>/<foldername>/<foldername>/<foldername>" should exist in the server
Examples:
| foldername |
| An empty folder which name is obviously more than 59 characters |
@skipOnWindows @smoke
Scenario: Invalid system names are synced (Linux only)
Given user "Alice" has created folder "CON" in the server
And user "Alice" has created folder "test%" in the server
And user "Alice" has uploaded file with content "server content" to "/PRN" in the server
And user "Alice" has uploaded file with content "server content" to "/foo%" in the server
And user "Alice" has set up a client with default settings
Then the folder "CON" should exist on the file system
And the folder "test%" should exist on the file system
And the file "PRN" should exist on the file system
And the file "foo%" should exist on the file system
And as "Alice" folder "CON" should exist in the server
And as "Alice" folder "test%" should exist in the server
And as "Alice" file "/PRN" should exist in the server
And as "Alice" file "/foo%" should exist in the server
@skipOnLinux
Scenario: Sync invalid system names (Windows only)
Given user "Alice" has created folder "CON" in the server
And user "Alice" has created folder "test%" in the server
And user "Alice" has uploaded file with content "server content" to "/PRN" in the server
And user "Alice" has uploaded file with content "server content" to "/foo%" in the server
And user "Alice" has set up a client with default settings
Then the folder "test%" should exist on the file system
And the file "foo%" should exist on the file system
But the folder "CON" should not exist on the file system
And the file "PRN" should not exist on the file system
@smoke
Scenario: various types of files can be synced from server to client
Given user "Alice" has created folder "simple-folder" in the server
And user "Alice" has uploaded file "testavatar.png" to "simple-folder/testavatar.png" in the server
And user "Alice" has uploaded file "testavatar.jpg" to "simple-folder/testavatar.jpg" in the server
And user "Alice" has uploaded file "testavatar.jpeg" to "simple-folder/testavatar.jpeg" in the server
And user "Alice" has uploaded file "testimage.mp3" to "simple-folder/testimage.mp3" in the server
And user "Alice" has uploaded file "test_video.mp4" to "simple-folder/test_video.mp4" in the server
And user "Alice" has uploaded file "simple.pdf" to "simple-folder/simple.pdf" in the server
And user "Alice" has uploaded file "simple.docx" to "simple-folder/simple.docx" in the server
And user "Alice" has uploaded file "simple.pptx" to "simple-folder/simple.pptx" in the server
And user "Alice" has uploaded file "simple.xlsx" to "simple-folder/simple.xlsx" in the server
And user "Alice" has set up a client with default settings
Then the folder "simple-folder" should exist on the file system
And the file "simple-folder/testavatar.png" should exist on the file system
And the file "simple-folder/testavatar.jpg" should exist on the file system
And the file "simple-folder/testavatar.jpeg" should exist on the file system
And the file "simple-folder/testimage.mp3" should exist on the file system
And the file "simple-folder/test_video.mp4" should exist on the file system
And the file "simple-folder/simple.pdf" should exist on the file system
And the file "simple-folder/simple.docx" should exist on the file system
And the file "simple-folder/simple.pptx" should exist on the file system
And the file "simple-folder/simple.xlsx" should exist on the file system
Scenario: various types of files can be synced from client to server
Given user "Alice" has set up a client with default settings
When user "Alice" creates the following files inside the sync folder:
| files |
| /testavatar.png |
| /testavatar.jpg |
| /testavatar.jpeg |
| /testaudio.mp3 |
| /test_video.mp4 |
| /simple.txt |
| /simple.docx |
| /simple.pptx |
| /simple.xlsx |
And the user waits for the files to sync
Then as "Alice" file "testavatar.png" should exist in the server
And as "Alice" file "testavatar.jpg" should exist in the server
And as "Alice" file "testavatar.jpeg" should exist in the server
And as "Alice" file "testaudio.mp3" should exist in the server
And as "Alice" file "test_video.mp4" should exist in the server
And as "Alice" file "simple.txt" should exist in the server
And as "Alice" file "simple.docx" should exist in the server
And as "Alice" file "simple.pptx" should exist in the server
And as "Alice" file "simple.xlsx" should exist in the server
@smoke
Scenario Outline: File with long name can be synced
Given user "Alice" has set up a client with default settings
When user "Alice" creates a file "<filename>" with the following content inside the sync folder
"""
test content
"""
And the user waits for file "<filename>" to be synced
Then as "Alice" file "<filename>" should exist in the server
Examples:
| filename |
| thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIs.txt |
@smoke
Scenario: Syncing file of 1 GB size
Given user "Alice" has set up a client with default settings
When user "Alice" creates a file "newfile.txt" with size "1GB" inside the sync folder
And the user waits for file "newfile.txt" to be synced
Then as "Alice" file "newfile.txt" should exist in the server
Scenario: File with spaces in the name can sync
Given user "Alice" has set up a client with default settings
When user "Alice" creates a file "file with space.txt" with the following content inside the sync folder
"""
test contents
"""
And the user waits for file "file with space.txt" to be synced
Then as "Alice" file "file with space.txt" should exist in the server
Scenario: Syncing folders each having large number of files
Given the user has created a folder "folder1" in temp folder
And the user has created "500" files each of size "1048576" bytes inside folder "folder1" in temp folder
And the user has created a folder "folder2" in temp folder
And the user has created "500" files each of size "1048576" bytes inside folder "folder2" in temp folder
And the user has created a folder "folder3" in temp folder
And the user has created "1000" files each of size "1048576" bytes inside folder "folder3" in temp folder
And user "Alice" has set up a client with default settings
When user "Alice" moves folder "folder1" from the temp folder into the sync folder
And user "Alice" moves folder "folder2" from the temp folder into the sync folder
And user "Alice" moves folder "folder3" from the temp folder into the sync folder
And the user waits for folder "folder1" to be synced
And the user waits for folder "folder2" to be synced
And the user waits for folder "folder3" to be synced
Then as "Alice" folder "folder1" should exist in the server
And as user "Alice" folder "folder1" should contain "500" items in the server
And as "Alice" folder "folder2" should exist in the server
And as user "Alice" folder "folder2" should contain "500" items in the server
And as "Alice" folder "folder3" should exist in the server
And as user "Alice" folder "folder3" should contain "1000" items in the server
@smoke
Scenario: Skip sync folder configuration
Given the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user selects manual sync folder option in advanced section
And the user cancels the sync connection wizard
Then "Alice" account should be added
And for user "Alice" sync folder "Personal" should not be displayed
And for user "Alice" sync folder "Shares" should not be displayed
Scenario: extract a zip file in the sync folder
Given the user has created a zip file "archive.zip" with the following resources in the temp folder
| resource | type | content |
| folder1 | folder | |
| folder2 | folder | |
| file1.txt | file | Test file1 |
| file2.txt | file | Test file2 |
And user "Alice" has set up a client with default settings
When user "Alice" moves file "archive.zip" from the temp folder into the sync folder
And user "Alice" unzips the zip file "archive.zip" inside the sync root
And the user waits for the files to sync
Then as "Alice" folder "folder1" should exist in the server
And as "Alice" folder "folder2" should exist in the server
And as "Alice" the file "file1.txt" should have the content "Test file1" in the server
And as "Alice" the file "file2.txt" should have the content "Test file2" in the server
@skipOnWindows
Scenario: sync remote folder to a local sync folder having special characters
Given user "Alice" has created folder "~`!@#$^&()-_=+{[}];',)" in the server
And user "Alice" has created folder "simple-folder" in the server
And user "Alice" has created folder "test-folder" in the server
And user "Alice" has created folder "test-folder/sub-folder1" in the server
And user "Alice" has created folder "test-folder/sub-folder2" in the server
And user "Alice" has created folder "~test%" in the server
And the user has created a folder "~`!@#$^&()-_=+{[}];',)PRN%" in temp folder
And the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user selects manual sync folder option in advanced section
And the user sets the temp folder "~`!@#$^&()-_=+{[}];',)PRN%" as local sync path in sync connection wizard
And the user selects "Personal" space in sync connection wizard
And the user selects the following folders to sync:
| folder |
| ~`!@#$^&()-_=+{[}];',) |
| simple-folder |
| test-folder/sub-folder2 |
Then the folder "~`!@#$^&()-_=+{[}];',)" should exist on the file system
And the folder "simple-folder" should exist on the file system
But the folder "~test%" should not exist on the file system
When user "Alice" deletes the folder "simple-folder" in the server
And the user waits for the files to sync
Then the folder "simple-folder" should not exist on the file system
And the folder "test-folder/sub-folder2" should exist on the file system
And the folder "test-folder/sub-folder1" should not exist on the file system
Scenario: Syncing a local folder having special characters to the server
Given user "Alice" has set up a client with default settings
When user "Alice" creates a folder "~`!@#$^&()-_=+{[}];',)💥🫨🔥" inside the sync folder
And the user waits for folder "~`!@#$^&()-_=+{[}];',)💥🫨🔥" to be synced
Then as "Alice" folder "~`!@#$^&()-_=+{[}];',)💥🫨🔥" should exist in the server
@issue-11814
Scenario: Remove folder sync connection (Personal Space)
Given user "Alice" has created folder "simple-folder" in the server
And user "Alice" has set up a client with default settings
When the user removes the folder sync connection
Then for user "Alice" sync folder "Personal" should not be displayed
And the folder "simple-folder" should exist on the file system
And as "Alice" folder "simple-folder" should exist in the server
Scenario: Sync a received shared folder with Viewer permission role
Given user "Brian" has been created in the server with default attributes
And user "Alice" has created folder "simple-folder" in the server
And user "Alice" has uploaded file with content "test content" to "simple-folder/uploaded-lorem.txt" in the server
And user "Alice" has sent the following resource share invitation:
| resource | simple-folder |
| sharee | Brian |
| permissionsRole | Viewer |
And user "Brian" has set up a client with space "Shares"
When user "Brian" creates a folder "simple-folder/sub-folder" inside the sync folder
Then the folder "simple-folder/sub-folder" should exist on the file system
But the following error message should appear in the client
"""
simple-folder/sub-folder: Not allowed because you don't have permission to add subfolders to that folder
"""
When the user copies file "simple.pdf" from outside the sync folder to "simple-folder/simple.pdf" in the sync folder
Then the file "simple-folder/simple.pdf" should exist on the file system
But the following error message should appear in the client
"""
simple-folder/simple.pdf: Not allowed because you don't have permission to add files in that folder
"""
And as "Brian" folder "simple-folder/sub-folder" should not exist in the server
And as "Brian" file "simple-folder/simple.pdf" should not exist in the server
When the user opens the activity tab
And the user selects "Not Synced" tab in the activity
Then the following activities should be displayed in not synced table
| resource | status | account |
| simple-folder/sub-folder | Blacklisted | Brian Murphy@%local_server_hostname% |
| simple-folder/simple.pdf | Blacklisted | Brian Murphy@%local_server_hostname% |
Scenario Outline: File with long multi-byte characters name can be synced (76 characters, 255 bytes including extension)
Given user "Alice" has set up a client with default settings
When user "Alice" creates a file "<filename>" with the following content inside the sync folder
"""
test content
"""
And the user waits for file "<filename>" to be synced
Then as "Alice" file "<filename>" should exist in the server
Examples:
| filename |
| 𒁰𒁱𒁲𒁳𒁴𒁵𒁶𒁷𒁸𒁹𒁺𒁻𒁼𒁾𒁿𒁰𒁱𒁲𒁳𒁴𒁵𒁶𒁷𒁸𒁹𒁺𒁻𒁼𒁾𒁿𒁰𒁱𒁲𒁳𒁴𒁵𒁶𒁷𒁸𒁹𒁺abôǣ.txt |
Scenario: Sync a received shared folder with Editor permission role
Given user "Brian" has been created in the server with default attributes
And user "Alice" has created folder "simple-folder" in the server
And user "Alice" has uploaded file with content "test content" to "simple-folder/uploaded-lorem.txt" in the server
And user "Alice" has sent the following resource share invitation:
| resource | simple-folder |
| sharee | Brian |
| permissionsRole | Editor |
And user "Brian" has set up a client with space "Shares"
When user "Brian" creates a folder "simple-folder/sub-folder" inside the sync folder
And the user copies file "simple.pdf" from outside the sync folder to "simple-folder/simple.pdf" in the sync folder
And the user overwrites the file "simple-folder/uploaded-lorem.txt" with content "overwrite openCloud test text file"
And the user waits for the files to sync
And the user waits for folder "simple-folder/sub-folder" to be synced
Then the folder "simple-folder/sub-folder" should exist on the file system
And the file "simple-folder/simple.pdf" should exist on the file system
And as "Brian" folder "Shares/simple-folder/sub-folder" should exist in the server
And as "Brian" file "Shares/simple-folder/simple.pdf" should exist in the server
And as "Brian" the file "Shares/simple-folder/uploaded-lorem.txt" should have the content "overwrite openCloud test text file" in the server
@skipOnWindows @smoke
Scenario: Unselected subfolders are excluded from local sync
Given user "Alice" has created folder "test-folder" in the server
And user "Alice" has created folder "test-folder/sub-folder1" in the server
And user "Alice" has created folder "test-folder/sub-folder2" in the server
And user "Alice" has set up a client with default settings
When the user unselects the following folders to sync in "Choose what to sync" window:
| folder |
| test-folder/sub-folder2 |
And the user waits for folder "test-folder/sub-folder2" to be synced
Then the folder "test-folder/sub-folder1" should exist on the file system
And the folder "test-folder/sub-folder2" should not exist on the file system
When user "Alice" uploads file with content "some content" to "test-folder/sub-folder2/lorem.txt" in the server
And the user force syncs the files
And the user waits for the files to sync
Then the file "test-folder/sub-folder2/lorem.txt" should not exist on the file system
@skipOnWindows
Scenario: Only root level files sync when all folders are unselected
Given user "Alice" has created folder "test-folder" in the server
And user "Alice" has created folder "test-folder/sub-folder1" in the server
And user "Alice" has created folder "test-folder/sub-folder2" in the server
And user "Alice" has uploaded file with content "root file content" to "root-file.txt" in the server
And user "Alice" has uploaded file with content "some subfolder content" to "test-folder/sub-folder1/lorem.txt" in the server
And the user has started the client
And the user has entered the following account information:
| server | %local_server% |
| user | Alice |
| password | 1234 |
When the user selects manual sync folder option in advanced section
And the user sets the sync path in sync connection wizard
And the user selects "Personal" space in sync connection wizard
And user unselects all the remote folders
And the user adds the folder sync connection
And the user waits for the files to sync
Then the folder "test-folder/sub-folder1" should not exist on the file system
And the folder "test-folder/sub-folder2" should not exist on the file system
And the file "test-folder/sub-folder1/lorem.txt" should not exist on the file system
But the file "root-file.txt" should exist on the file system
@@ -0,0 +1,31 @@
Feature: Visually check all tabs
As a user
I want to visually check all tabs in client
So that I can perform all the actions related to client
@smoke
Scenario: Tabs in toolbar looks correct
Given user "Alice" has been created in the server with default attributes
And user "Alice" has set up a client with default settings
Then the toolbar should have the following tabs:
| Add Account |
| Activity |
| Settings |
| Quit |
@smoke
Scenario: Verify various setting options in Settings tab
Given user "Alice" has been created in the server with default attributes
And user "Alice" has set up a client with default settings
When the user opens the settings tab
Then the settings tab should have the following options in the general section:
| Start on Login |
And the settings tab should have the following options in the advanced section:
| Sync hidden files |
| Edit ignored files |
| Log settings |
And the settings tab should have the following options in the network section:
| Download Bandwidth |
| Upload Bandwidth |
When the user opens the about dialog
Then the about dialog should be opened
+174
View File
@@ -0,0 +1,174 @@
@skipOnLinux
Feature: VFS support
As a user
I want to sync files with vfs
So that I can decide which files to download
Scenario: Default VFS sync
Given user "Alice" has been created in the server with default attributes
And user "Alice" has uploaded file with content "openCloud" to "testFile.txt" in the server
And user "Alice" has created folder "parent" in the server
And user "Alice" has uploaded file with content "some contents" to "parent/lorem.txt" in the server
And user "Alice" has set up a client with default settings
Then the placeholder file "testFile.txt" should exist on the file system
And the placeholder file "parent/lorem.txt" should exist on the file system
When user "Alice" reads the content of file "parent/lorem.txt"
Then the file "parent/lorem.txt" should be downloaded
And the placeholder file "testFile.txt" should exist on the file system
Scenario: Copy placeholder file
Given user "Alice" has been created in the server with default attributes
And user "Alice" has uploaded file with content "sample file" to "sampleFile.txt" in the server
And user "Alice" has uploaded file with content "lorem file" to "lorem.txt" in the server
And user "Alice" has uploaded file with content "test file" to "testFile.txt" in the server
And user "Alice" has created folder "Folder" in the server
And user "Alice" has set up a client with default settings
Then the placeholder file "lorem.txt" should exist on the file system
And the placeholder file "sampleFile.txt" should exist on the file system
And the placeholder file "testFile.txt" should exist on the file system
When user "Alice" copies file "sampleFile.txt" to temp folder
And the user copies file "lorem.txt" into folder "Folder"
And the user copies file "testFile.txt" into the same directory
And the user waits for file "Folder/lorem.txt" to be synced
Then the file "sampleFile.txt" should be downloaded
And the file "Folder/lorem.txt" should be downloaded
And the file "lorem.txt" should be downloaded
And the file "testFile.txt" should be downloaded
And the file "testFile (Copy).txt" should be downloaded
And as "Alice" file "Folder/lorem.txt" should exist in the server
And as "Alice" file "lorem.txt" should exist in the server
And as "Alice" file "sampleFile.txt" should exist in the server
And as "Alice" file "testFile.txt" should exist in the server
And as "Alice" file "testFile (Copy).txt" should exist in the server
Scenario: Move placeholder file
Given user "Alice" has been created in the server with default attributes
And user "Alice" has uploaded file with content "lorem file" to "lorem.txt" in the server
And user "Alice" has uploaded file with content "some contents" to "sampleFile.txt" in the server
And user "Alice" has created folder "Folder" in the server
And user "Alice" has set up a client with default settings
When user "Alice" moves file "lorem.txt" to "Folder" in the sync folder
And user "Alice" moves file "sampleFile.txt" to the temp folder
And the user waits for file "Folder/lorem.txt" to be synced
Then the placeholder file "Folder/lorem.txt" should exist on the file system
And as "Alice" file "Folder/lorem.txt" should exist in the server
And as "Alice" file "lorem.txt" should not exist in the server
And as "Alice" file "sampleFile.txt" should not exist in the server
Scenario: Hydration and dehydration of files via file explorer
Given user "Alice" has been created in the server with default attributes
And user "Alice" has uploaded file with content "test content" to "testFile.txt" in the server
And user "Alice" has uploaded file with content "test content" to "simple.txt" in the server
And user "Alice" has uploaded file with content "test content" to "large.txt" in the server
And user "Alice" has created folder "parent" in the server
And user "Alice" has uploaded file with content "test content" to "parent/lorem.txt" in the server
And user "Alice" has uploaded file with content "test content" to "parent/epsum.txt" in the server
And user "Alice" has set up a client with default settings
Then the placeholder file "testFile.txt" should exist on the file system
And the placeholder file "simple.txt" should exist on the file system
And the placeholder file "large.txt" should exist on the file system
And the placeholder file "parent/lorem.txt" should exist on the file system
And the placeholder file "parent/epsum.txt" should exist on the file system
# Hydrate some files by reading the content
When user "Alice" reads the content of file "testFile.txt"
And user "Alice" reads the content of file "parent/lorem.txt"
Then the file "testFile.txt" should be downloaded
And the file "parent/lorem.txt" should be downloaded
And the placeholder file "parent/epsum.txt" should exist on the file system
# mark files "Always keep on this device"
When user "Alice" marks file "testFile.txt" as "Always keep on this device" from the file explorer
And the user waits for file "testFile.txt" to be synced
Then the file "testFile.txt" should be downloaded
When user "Alice" marks file "simple.txt" as "Always keep on this device" from the file explorer
And the user waits for file "simple.txt" to be synced
Then the file "simple.txt" should be downloaded
And the placeholder file "large.txt" should exist on the file system
# mark files "Free up space"
When user "Alice" marks file "testFile.txt" as "Free up space" from the file explorer
And the user waits for file "testFile.txt" to be synced
Then the placeholder file "testFile.txt" should exist on the file system
When user "Alice" marks file "parent/lorem.txt" as "Free up space" from the file explorer
And the user waits for file "parent/lorem.txt" to be synced
Then the placeholder file "parent/lorem.txt" should exist on the file system
When user "Alice" marks file "simple.txt" as "Free up space" from the file explorer
And the user waits for file "simple.txt" to be synced
Then the placeholder file "simple.txt" should exist on the file system
Scenario: Hydration and dehydration of folders via file explorer
Given user "Alice" has been created in the server with default attributes
And user "Alice" has created folder "testFol" in the server
And user "Alice" has created folder "nested" in the server
And user "Alice" has created folder "nested/subfol1" in the server
And user "Alice" has created folder "nested/subfol1/subfol2" in the server
And user "Alice" has created folder "nested/subfol1/subfol2/subfol3" in the server
And user "Alice" has created folder "nested/subfol1/subfol2/subfol3/subfol4" in the server
And user "Alice" has uploaded file with content "test content" to "simple.txt" in the server
And user "Alice" has uploaded file with content "some contents" to "nested/lorem.txt" in the server
And user "Alice" has uploaded file with content "some contents" to "nested/subfol1/subfile1.txt" in the server
And user "Alice" has uploaded file with content "some contents" to "nested/subfol1/subfol2/subfile2.txt" in the server
And user "Alice" has uploaded file with content "some contents" to "nested/subfol1/subfol2/subfol3/subfile3.txt" in the server
And user "Alice" has uploaded file with content "some contents" to "nested/subfol1/subfol2/subfol3/subfol4/subfile4.txt" in the server
And user "Alice" has set up a client with default settings
Then the placeholder file "simple.txt" should exist on the file system
And the placeholder file "nested/lorem.txt" should exist on the file system
And the placeholder file "nested/subfol1/subfol2/subfol3/subfol4/subfile4.txt" should exist on the file system
# mark sub folder as "Always keep on this device"
When user "Alice" reads the content of file "nested/subfol1/subfol2/subfile2.txt"
And user "Alice" marks folder "nested/subfol1" as "Always keep on this device" from the file explorer
And the user waits for folder "nested/subfol1" to be synced
Then the file "nested/subfol1/subfile1.txt" should be downloaded
And the file "nested/subfol1/subfol2/subfile2.txt" should be downloaded
And the file "nested/subfol1/subfol2/subfol3/subfile3.txt" should be downloaded
And the file "nested/subfol1/subfol2/subfol3/subfol4/subfile4.txt" should be downloaded
And the placeholder file "nested/lorem.txt" should exist on the file system
# create local files and folders in "Always keep on this device" folder
When user "Alice" creates a folder "nested/subfol1/subfol2/localFol" inside the sync folder
And user "Alice" creates a file "nested/subfol1/subfol2/local.txt" with the following content inside the sync folder
"""
local file
"""
And the user waits for folder "nested/subfol1/subfol2/localFol" to be synced
And the user waits for file "nested/subfol1/subfol2/local.txt" to be synced
Then the file "nested/subfol1/subfol2/local.txt" should be downloaded
# create local files and folders in "Free up space" folder
When user "Alice" creates a folder "nested/localFol" inside the sync folder
And user "Alice" creates a file "nested/local.txt" with the following content inside the sync folder
"""
local file
"""
And the user waits for folder "nested/localFol" to be synced
And the user waits for file "nested/local.txt" to be synced
Then the file "nested/local.txt" should be downloaded
# upload files to "Always keep on this device" folder in the server
When user "Alice" uploads file with content "server content" to "nested/subfol1/subfol2/localFol/fromServer.txt" in the server
And the user waits for file "nested/subfol1/subfol2/localFol/fromServer.txt" to be synced
Then the file "nested/subfol1/subfol2/localFol/fromServer.txt" should be downloaded
# upload files to "Free up space" folder in the server
When user "Alice" uploads file with content "server content" to "nested/fromServer.txt" in the server
And user "Alice" uploads file with content "server content" to "nested/localFol/fromServer.txt" in the server
And the user waits for file "nested/localFol/fromServer.txt" to be synced
Then the placeholder file "nested/fromServer.txt" should exist on the file system
And the placeholder file "nested/localFol/fromServer.txt" should exist on the file system
# mark sub folder as "Free up space"
When user "Alice" marks folder "nested/subfol1/subfol2" as "Free up space" from the file explorer
And the user waits for folder "nested/subfol1/subfol2" to be synced
Then the placeholder file "nested/subfol1/subfol2/subfile2.txt" should exist on the file system
And the placeholder file "nested/subfol1/subfol2/local.txt" should exist on the file system
And the placeholder file "nested/subfol1/subfol2/localFol/fromServer.txt" should exist on the file system
And the placeholder file "nested/subfol1/subfol2/subfol3/subfile3.txt" should exist on the file system
And the placeholder file "nested/subfol1/subfol2/subfol3/subfol4/subfile4.txt" should exist on the file system
And the file "nested/subfol1/subfile1.txt" should be downloaded
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.
+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
@@ -0,0 +1,236 @@
import os
from types import SimpleNamespace
from appium.webdriver.common.appiumby import AppiumBy as By
from helpers.WebUIHelper import authorize_via_webui
from helpers.ConfigHelper import get_config
from helpers.SetupClientHelper import (
create_user_sync_path,
get_temp_resource_path,
set_current_user_sync_path,
)
from helpers.SyncHelper import listen_sync_status_for_item
from helpers.AppHelper import app
class AccountConnectionWizard:
SERVER_ADDRESS_BOX = SimpleNamespace(
by=By.ACCESSIBILITY_ID,
selector="QApplication.Settings.centralwidget.dialogStack.SetupWizardWidget.contentWidget.ServerUrlSetupWizardPage.urlLineEdit",
)
NEXT_BUTTON = SimpleNamespace(
by=By.ACCESSIBILITY_ID,
selector="QApplication.Settings.centralwidget.dialogStack.SetupWizardWidget.nextButton",
)
ACCEPT_CERTIFICATE_YES = SimpleNamespace(
by=By.NAME,
selector="Yes",
)
SELECT_LOCAL_FOLDER = SimpleNamespace(by=None, selector=None)
DIRECTORY_NAME_BOX = SimpleNamespace(
by=By.ACCESSIBILITY_ID,
selector="QApplication.Settings.centralwidget.dialogStack.SetupWizardWidget.contentWidget.AccountConfiguredWizardPage.advancedConfigGroupBox.advancedConfigGroupBoxContentWidget.localDirectoryGroupBox.chooseLocalDirectoryButton",
)
CHOOSE_FOLDER_BUTTON = SimpleNamespace(by=By.NAME, selector="Choose")
OAUTH_CREDENTIAL_PAGE = SimpleNamespace(by=None, selector=None)
COPY_URL_TO_CLIPBOARD_BUTTON = SimpleNamespace(
by=By.NAME,
selector="Copy URL",
)
CONF_SYNC_MANUALLY_RADIO_BUTTON = SimpleNamespace(
by=By.NAME, selector="Configure synchronization manually"
)
ADVANCED_CONFIGURATION_CHECKBOX = SimpleNamespace(
by=By.NAME,
selector="Advanced configuration",
)
DIRECTORY_NAME_EDIT_BOX = SimpleNamespace(
by=By.ACCESSIBILITY_ID,
selector="QApplication.QFileDialog.fileNameEdit",
)
SYNC_EVERYTHING_RADIO_BUTTON = SimpleNamespace(by=None, selector=None)
@staticmethod
def add_server(server_url):
url_input = app().find_element(
AccountConnectionWizard.SERVER_ADDRESS_BOX.by,
AccountConnectionWizard.SERVER_ADDRESS_BOX.selector,
)
url_input.clear()
url_input.send_keys(get_config("localBackendUrl"))
AccountConnectionWizard.next_step()
@staticmethod
def accept_certificate():
buttons = app().find_elements(
AccountConnectionWizard.ACCEPT_CERTIFICATE_YES.by,
AccountConnectionWizard.ACCEPT_CERTIFICATE_YES.selector,
)
# click the last button
last_button = buttons.pop()
last_button.click()
@staticmethod
def add_user_credentials(username, password):
AccountConnectionWizard.oidc_login(username, password)
@staticmethod
def oidc_login(username, password):
AccountConnectionWizard.browser_login(username, password)
@staticmethod
def copy_login_url():
app().find_element(
AccountConnectionWizard.COPY_URL_TO_CLIPBOARD_BUTTON.by,
AccountConnectionWizard.COPY_URL_TO_CLIPBOARD_BUTTON.selector,
).click()
@staticmethod
def browser_login(username, password):
AccountConnectionWizard.copy_login_url()
authorize_via_webui(username, password)
@staticmethod
def next_step():
app().find_element(
AccountConnectionWizard.NEXT_BUTTON.by,
AccountConnectionWizard.NEXT_BUTTON.selector,
).click()
@staticmethod
def select_sync_folder(user):
# create sync folder for user
sync_path = create_user_sync_path(user)
AccountConnectionWizard.select_advanced_config()
app().find_element(
AccountConnectionWizard.DIRECTORY_NAME_BOX.by,
AccountConnectionWizard.DIRECTORY_NAME_BOX.selector,
).click()
dir_location_input = app().find_element(
AccountConnectionWizard.DIRECTORY_NAME_EDIT_BOX.by,
AccountConnectionWizard.DIRECTORY_NAME_EDIT_BOX.selector,
)
dir_location_input.clear()
dir_location_input.send_keys(sync_path)
app().find_element(
AccountConnectionWizard.CHOOSE_FOLDER_BUTTON.by,
AccountConnectionWizard.CHOOSE_FOLDER_BUTTON.selector,
).click()
return os.path.join(sync_path, get_config('syncConnectionName'))
@staticmethod
def set_temp_folder_as_sync_folder(folder_name):
sync_path = get_temp_resource_path(folder_name)
# clear the current path
squish.mouseClick(
squish.waitForObject(AccountConnectionWizard.SELECT_LOCAL_FOLDER)
)
squish.waitForObject(AccountConnectionWizard.SELECT_LOCAL_FOLDER).setText("")
squish.type(
squish.waitForObject(AccountConnectionWizard.SELECT_LOCAL_FOLDER),
sync_path,
)
set_current_user_sync_path(sync_path)
return sync_path
@staticmethod
def add_account(account_details):
AccountConnectionWizard.add_account_information(account_details)
AccountConnectionWizard.next_step()
@staticmethod
def add_account_information(account_details):
if account_details["server"]:
AccountConnectionWizard.add_server(account_details["server"])
AccountConnectionWizard.accept_certificate()
if account_details["user"]:
AccountConnectionWizard.add_user_credentials(
account_details["user"],
account_details["password"],
)
sync_path = ""
if account_details["sync_folder"]:
AccountConnectionWizard.select_advanced_config()
sync_path = AccountConnectionWizard.set_temp_folder_as_sync_folder(
account_details["sync_folder"]
)
elif account_details["user"]:
sync_path = AccountConnectionWizard.select_sync_folder(
account_details["user"]
)
if sync_path:
# listen for sync status
listen_sync_status_for_item(sync_path)
@staticmethod
def select_manual_sync_folder_option():
app().find_element(
AccountConnectionWizard.CONF_SYNC_MANUALLY_RADIO_BUTTON.by,
AccountConnectionWizard.CONF_SYNC_MANUALLY_RADIO_BUTTON.selector,
).click()
@staticmethod
def select_download_everything_option():
squish.clickButton(
squish.waitForObject(AccountConnectionWizard.SYNC_EVERYTHING_RADIO_BUTTON)
)
@staticmethod
def is_new_connection_window_visible():
visible = False
try:
squish.waitForObject(AccountConnectionWizard.SERVER_ADDRESS_BOX)
visible = True
except:
pass
return visible
@staticmethod
def is_credential_window_visible():
visible = False
try:
squish.waitForObject(AccountConnectionWizard.OAUTH_CREDENTIAL_PAGE)
visible = True
except:
pass
return visible
@staticmethod
def select_advanced_config():
app().find_element(
AccountConnectionWizard.ADVANCED_CONFIGURATION_CHECKBOX.by,
AccountConnectionWizard.ADVANCED_CONFIGURATION_CHECKBOX.selector,
).click()
@staticmethod
def can_change_local_sync_dir():
can_change = False
try:
squish.waitForObjectExists(AccountConnectionWizard.SELECT_LOCAL_FOLDER)
squish.clickButton(
squish.waitForObject(AccountConnectionWizard.DIRECTORY_NAME_BOX)
)
squish.waitForObjectExists(AccountConnectionWizard.CHOOSE_FOLDER_BUTTON)
can_change = True
except:
pass
return can_change
@staticmethod
def is_sync_everything_option_checked():
return squish.waitForObjectExists(
AccountConnectionWizard.SYNC_EVERYTHING_RADIO_BUTTON
).checked
@staticmethod
def get_local_sync_path():
return str(
squish.waitForObjectExists(
AccountConnectionWizard.SELECT_LOCAL_FOLDER
).displayText
)
@@ -0,0 +1,172 @@
from types import SimpleNamespace
from appium.webdriver.common.appiumby import AppiumBy as By
from pageObjects.Toolbar import Toolbar
from helpers.UserHelper import get_displayname_for_user
from helpers.SetupClientHelper import substitute_inline_codes
from helpers.UserHelper import get_displayname_for_user
from helpers.AppHelper import app
from helpers.SyncHelper import wait_for
class AccountSetting:
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
by=By.NAME, selector="Sync connections"
)
MANAGE_ACCOUNT_BUTTON = SimpleNamespace(by=By.NAME, selector="Manage Account")
ACCOUNT_MENU = SimpleNamespace(by=By.NAME, selector="{menu_item}")
CONFIRM_REMOVE_CONNECTION_BUTTON = SimpleNamespace(
by=By.NAME, selector="Remove connection"
)
ACCOUNT_CONNECTION_LABEL = SimpleNamespace(
by=By.XPATH,
selector="//list[@name='Folder Sync']//label",
)
LOG_BROWSER_WINDOW = SimpleNamespace(by=None, selector=None)
ACCOUNT_LOADING = SimpleNamespace(by=None, selector=None)
DIALOG_STACK = SimpleNamespace(by=None, selector=None)
CONFIRMATION_YES_BUTTON = SimpleNamespace(by=None, selector=None)
@staticmethod
def account_action(action):
connections = app().find_elements(
AccountSetting.ACCOUNT_CONNECTION_CONTAINER.by,
AccountSetting.ACCOUNT_CONNECTION_CONTAINER.selector,
)
manage_button = None
for connection in connections:
# use the active connection
if connection.get_attribute("showing") == "true":
manage_button = connection.find_element(
AccountSetting.MANAGE_ACCOUNT_BUTTON.by,
AccountSetting.MANAGE_ACCOUNT_BUTTON.selector,
)
break
manage_button.click()
app().find_element(
AccountSetting.ACCOUNT_MENU.by,
AccountSetting.ACCOUNT_MENU.selector.format(menu_item=action),
).click()
@staticmethod
def remove_account_connection():
AccountSetting.account_action("Remove")
app().find_element(
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.by,
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.selector,
).click()
@staticmethod
def logout():
AccountSetting.account_action("Log out")
@staticmethod
def login():
AccountSetting.account_action("Log in")
@staticmethod
def get_account_connection_label():
labels = app().find_elements(
AccountSetting.ACCOUNT_CONNECTION_LABEL.by,
AccountSetting.ACCOUNT_CONNECTION_LABEL.selector,
)
# first label is the sync status label
return labels[0].text
@staticmethod
def is_connecting():
return "Connecting to" in AccountSetting.get_account_connection_label()
@staticmethod
def is_user_signed_out():
return "Signed out" in AccountSetting.get_account_connection_label()
@staticmethod
def is_user_signed_in():
return "Connected" in AccountSetting.get_account_connection_label()
@staticmethod
def wait_until_connection_is_configured(timeout=5000):
result = squish.waitFor(
AccountSetting.is_connecting,
timeout,
)
if not result:
raise TimeoutError(
"Timeout waiting for connection to be configured for "
+ str(timeout)
+ " milliseconds"
)
@staticmethod
def wait_until_account_is_connected(timeout=5000):
result = wait_for(
AccountSetting.is_user_signed_in,
timeout,
)
if not result:
raise TimeoutError(
"Timeout waiting for the account to be connected for "
+ str(timeout)
+ " milliseconds"
)
return result
@staticmethod
def wait_until_sync_folder_is_configured(timeout=5000):
result = squish.waitFor(
lambda: not squish.waitForObjectExists(
AccountSetting.ACCOUNT_LOADING
).visible,
timeout,
)
if not result:
raise TimeoutError(
"Timeout waiting for sync folder to be connected for "
+ str(timeout)
+ " milliseconds"
)
return result
@staticmethod
def press_key(key):
key = key.replace('"', "")
key = f"<{key}>"
squish.nativeType(key)
@staticmethod
def is_log_dialog_visible():
visible = False
try:
visible = squish.waitForObjectExists(
AccountSetting.LOG_BROWSER_WINDOW
).visible
except:
pass
return visible
@staticmethod
def remove_connection_for_user(username):
Toolbar.open_account(username)
AccountSetting.remove_account_connection()
@staticmethod
def wait_until_account_is_removed(username, timeout=10000):
displayname = get_displayname_for_user(username)
displayname = substitute_inline_codes(displayname)
def account_removed():
account = Toolbar.get_account(username)
return account is None
result = wait_for(account_removed, timeout)
if not result:
raise TimeoutError(
"Timeout waiting for account to be removed for "
+ str(timeout)
+ " milliseconds"
)
+216
View File
@@ -0,0 +1,216 @@
# from objectmaphelper import RegularExpression
from types import SimpleNamespace
from appium.webdriver.common.appiumby import AppiumBy as By
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import NoSuchElementException
from helpers.FilesHelper import build_conflicted_regex
from helpers.ConfigHelper import get_config
from helpers.AppHelper import app
class Activity:
TAB_CONTAINER = SimpleNamespace(by=None, selector=None)
SUBTAB_CONTAINER = SimpleNamespace(
by=By.CLASS_NAME, selector="[page tab | {tab_name}]"
)
NOT_SYNCED_TABLE = SimpleNamespace(by=None, selector=None)
LOCAL_ACTIVITY_FILTER_BUTTON = SimpleNamespace(by=By.NAME, selector="Filter")
LOCAL_ACTIVITY_FILTER_OPTION_SELECTOR = SimpleNamespace(by=By.NAME, selector=None)
LOCAL_ACTIVITY_TABLE = SimpleNamespace(by=By.NAME, selector="Local activity table")
FILTER_BUTTON_SELECTED_STATE = SimpleNamespace(
by=By.XPATH, selector="//*[contains(@name, '1 Filter')]"
)
NOT_SYNCED_FILTER_BUTTON = SimpleNamespace(by=None, selector=None)
NOT_SYNCED_FILTER_OPTION_SELECTOR = SimpleNamespace(by=None, selector=None)
SYNCED_ACTIVITY_TABLE_HEADER_SELECTOR = SimpleNamespace(by=None, selector=None)
NOT_SYNCED_ACTIVITY_TABLE_HEADER_SELECTOR = SimpleNamespace(by=None, selector=None)
SYNCED_ACTIVITY_STATUS = SimpleNamespace(by=By.NAME, selector=None)
@staticmethod
def get_not_synced_file_selector(resource):
return {
"column": 1,
"container": Activity.NOT_SYNCED_TABLE,
"text": resource,
"type": "QModelIndex",
}
@staticmethod
def get_not_synced_status(row):
return squish.waitForObjectExists(
{
"column": 6,
"row": row,
"container": Activity.NOT_SYNCED_TABLE,
"type": "QModelIndex",
}
).text
@staticmethod
def click_tab(tab_name):
selector = Activity.SUBTAB_CONTAINER.selector.format(tab_name=tab_name)
app().find_element(Activity.SUBTAB_CONTAINER.by, selector).click()
@staticmethod
def check_file_exist(filename):
squish.waitForObjectExists(
Activity.get_not_synced_file_selector(
RegularExpression(build_conflicted_regex(filename))
)
)
@staticmethod
def is_resource_blacklisted(filename):
result = squish.waitFor(
lambda: Activity.has_sync_status(filename, "Blacklisted"),
get_config("maxSyncTimeout") * 1000,
)
return result
@staticmethod
def is_resource_ignored(filename):
result = squish.waitFor(
lambda: Activity.has_sync_status(filename, "File Ignored"),
get_config("maxSyncTimeout") * 1000,
)
return result
@staticmethod
def is_resource_excluded(filename):
result = squish.waitFor(
lambda: Activity.has_sync_status(filename, "Excluded"),
get_config("maxSyncTimeout") * 1000,
)
return result
@staticmethod
def has_sync_status(filename, status):
try:
app().find_element(Activity.SYNCED_ACTIVITY_STATUS.by, status)
return True
except NoSuchElementException:
return False
@staticmethod
def select_synced_filter(sync_filter):
menu = app().find_element(
Activity.LOCAL_ACTIVITY_FILTER_BUTTON.by,
Activity.LOCAL_ACTIVITY_FILTER_BUTTON.selector,
)
menu.click()
# NOTE: Filter options are not visible in the accessibility tree.
# As a workaround, select the second filter option (which is an account filter).
# This means we cannot select a specific account filter for now.
menu.send_keys(Keys.ARROW_DOWN)
menu.send_keys(Keys.ARROW_DOWN)
menu.send_keys(Keys.ENTER)
# confirm filter is applied
app().find_element(
Activity.FILTER_BUTTON_SELECTED_STATE.by,
Activity.FILTER_BUTTON_SELECTED_STATE.selector,
)
@staticmethod
def get_synced_file_selector(resource):
return {
"column": Activity.get_synced_table_column_number_by_name("File"),
"container": Activity.LOCAL_ACTIVITY_TABLE,
"text": resource,
"type": "QModelIndex",
}
@staticmethod
def get_synced_table_column_number_by_name(column_name):
return squish.waitForObject(
{
"container": Activity.SYNCED_ACTIVITY_TABLE_HEADER_SELECTOR,
"text": column_name,
"type": "HeaderViewItem",
"visible": True,
}
)["section"]
@staticmethod
def has_activity(resource, action, account):
try:
row = app().find_element(By.NAME, resource)
row_y = row.rect['y']
# check other properties using current row position
action_cells = app().find_elements(By.NAME, action)
found_action_cell = False
for action_el in action_cells:
if action_el.rect['y'] == row_y:
found_action_cell = True
break
if not found_action_cell:
raise NoSuchElementException(
f'Activity for "{resource}" does not have "{action}" action'
)
account_cells = app().find_elements(By.NAME, account)
found_account_cell = False
for account_el in account_cells:
if account_el.rect['y'] == row_y:
found_account_cell = True
break
if not found_account_cell:
raise NoSuchElementException(
f'Activity for "{resource}" does not have "{account}" account label'
)
return True
except:
return False
@staticmethod
def select_not_synced_filter(filter_option):
squish.clickButton(squish.waitForObject(Activity.NOT_SYNCED_FILTER_BUTTON))
squish.activateItem(
squish.waitForObjectItem(
Activity.NOT_SYNCED_FILTER_OPTION_SELECTOR, filter_option
)
)
@staticmethod
def get_not_synced_table_column_number_by_name(column_name):
return squish.waitForObject(
{
"container": Activity.NOT_SYNCED_ACTIVITY_TABLE_HEADER_SELECTOR,
"text": column_name,
"type": "HeaderViewItem",
"visible": True,
}
)["section"]
@staticmethod
def check_not_synced_table(resource, status, account):
try:
file_row = squish.waitForObject(
Activity.get_not_synced_file_selector(resource),
get_config("lowestSyncTimeout") * 1000,
)["row"]
squish.waitForObjectExists(
{
"column": Activity.get_not_synced_table_column_number_by_name(
"Status"
),
"row": file_row,
"container": Activity.NOT_SYNCED_TABLE,
"text": status,
"type": "QModelIndex",
}
)
squish.waitForObjectExists(
{
"column": Activity.get_not_synced_table_column_number_by_name(
"Account"
),
"row": file_row,
"container": Activity.NOT_SYNCED_TABLE,
"text": account,
"type": "QModelIndex",
}
)
return True
except:
return False
@@ -0,0 +1,42 @@
from types import SimpleNamespace
from appium.webdriver.common.appiumby import AppiumBy as By
from pageObjects.AccountConnectionWizard import AccountConnectionWizard
from helpers.WebUIHelper import authorize_via_webui
from helpers.AppHelper import app
class EnterPassword:
LOGIN_CONTAINER = SimpleNamespace(by=None, selector=None)
LOGIN_USER_LABEL = SimpleNamespace(
by=By.XPATH,
selector="//filler[@name='Login required']//label[contains(@name, 'Connecting')]",
)
USERNAME_BOX = SimpleNamespace(by=None, selector=None)
LOGOUT_BUTTON = SimpleNamespace(by=None, selector=None)
def get_username(self):
# Parse username from the login label:
label = (
app()
.find_element(
EnterPassword.LOGIN_USER_LABEL.by,
EnterPassword.LOGIN_USER_LABEL.selector,
)
.text
)
username = label.split(" ", maxsplit=2)[1]
return username.capitalize()
def oidc_relogin(self, username, password):
AccountConnectionWizard.copy_login_url()
authorize_via_webui(username, password)
def relogin(self, username, password, oauth=False):
self.oidc_relogin(username, password)
def login_after_setup(self, username, password):
self.oidc_relogin(username, password)
def accept_certificate(self):
AccountConnectionWizard.accept_certificate()
@@ -0,0 +1,297 @@
from datetime import datetime
import test
import names
import squish
import object # pylint: disable=redefined-builtin
class PublicLinkDialog:
PUBLIC_LINKS_TAB = {
"container": names.sharingDialog_qt_tabwidget_tabbar_QTabBar,
"text": "Public Links",
"type": "TabItem",
}
PASSWORD_CHECKBOX = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "checkBox_password",
"type": "QCheckBox",
"visible": 1,
}
PASSWORD_INPUT_FIELD = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "lineEdit_password",
"type": "QLineEdit",
"visible": 1,
}
EXPIRYDATE_CHECKBOX = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "checkBox_expire",
"type": "QCheckBox",
"visible": 1,
}
CREATE_SHARE_BUTTON = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "createShareButton",
"type": "QPushButton",
"visible": 1,
}
PUBLIC_LINK_NAME = {
"column": 0,
"container": names.oCC_ShareLinkWidget_linkShares_QTableWidget,
"row": 0,
"type": "QModelIndex",
}
EXPIRATION_DATE_FIELD = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "qt_spinbox_lineedit",
"type": "QLineEdit",
"visible": 1,
}
READ_ONLY_RADIO_BUTTON = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "radio_readOnly",
"type": "QRadioButton",
"visible": 1,
}
READ_WRITE_RADIO_BUTTON = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "radio_readWrite",
"type": "QRadioButton",
"visible": 1,
}
UPLOAD_ONLY_RADIO_BUTTON = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "radio_uploadOnly",
"type": "QRadioButton",
"visible": 1,
}
DELETE_LINK_BUTTON = {
"container": names.oCC_ShareLinkWidget_linkShares_QTableWidget,
"type": "QToolButton",
"occurrence": 2,
}
CONFIRM_LINK_DELETE_BUTTON = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"text": "Delete",
"type": "QPushButton",
}
UPDATE_PASSWORD_BUTTON = {
"container": names.qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget,
"name": "pushButton_setPassword",
"type": "QPushButton",
"visible": 1,
}
DATE_FORMATS = [
"%m/%d/%Y",
"%d/%m/%Y",
"%Y-%m-%d",
"%d-%m-%Y",
"%Y/%m/%d",
"%m/%d/%y",
"%d/%m/%y",
]
# to store current default public link expiry date
default_expiry_date = ""
@staticmethod
def parse_date(date):
for date_format in PublicLinkDialog.DATE_FORMATS:
try:
date = str(datetime.strptime(date, date_format))
date = date.split(" ", maxsplit=1)[0]
break
except:
pass
return date
@staticmethod
def set_default_expiry_date(default_date):
PublicLinkDialog.default_expiry_date = PublicLinkDialog.parse_date(default_date)
@staticmethod
def get_default_expiry_date():
return PublicLinkDialog.default_expiry_date
@staticmethod
def open_public_link_tab():
squish.mouseClick(squish.waitForObject(PublicLinkDialog.PUBLIC_LINKS_TAB))
@staticmethod
def create_public_link(password="", permissions="", expire_date=""):
permission_locator = ""
if permissions:
permission_locator = PublicLinkDialog.get_radio_object_for_permssion(
permissions
)
if permission_locator:
squish.clickButton(squish.waitForObject(permission_locator))
if password:
PublicLinkDialog.set_password(password)
if expire_date:
PublicLinkDialog.set_expiration_date(expire_date)
squish.clickButton(squish.waitForObject(PublicLinkDialog.CREATE_SHARE_BUTTON))
squish.waitFor(
lambda: (
squish.waitForObject(PublicLinkDialog.PUBLIC_LINK_NAME).displayText
== "Public link"
)
)
@staticmethod
def toggle_password():
squish.clickButton(squish.waitForObject(PublicLinkDialog.PASSWORD_CHECKBOX))
@staticmethod
def set_password(password):
if not squish.waitForObjectExists(PublicLinkDialog.PASSWORD_CHECKBOX).checked:
PublicLinkDialog.toggle_password()
squish.mouseClick(squish.waitForObject(PublicLinkDialog.PASSWORD_INPUT_FIELD))
squish.type(
squish.waitForObject(PublicLinkDialog.PASSWORD_INPUT_FIELD),
password,
)
@staticmethod
def toggle_expiration_date():
squish.clickButton(squish.waitForObject(PublicLinkDialog.EXPIRYDATE_CHECKBOX))
@staticmethod
def set_expiration_date(expire_date):
enabled = squish.waitForObjectExists(
PublicLinkDialog.EXPIRYDATE_CHECKBOX
).checked
if not enabled:
PublicLinkDialog.toggle_expiration_date()
if not expire_date == "default":
exp_date = datetime.strptime(expire_date, "%Y-%m-%d")
exp_year = exp_date.year - 2000
squish.mouseClick(
squish.waitForObject(PublicLinkDialog.EXPIRATION_DATE_FIELD)
)
# Move the cursor to year (last) field and enter the year
squish.nativeType("<Ctrl+Right>")
squish.nativeType("<Ctrl+Right>")
squish.nativeType(exp_year)
# Move the cursor to day (middle) field and enter the day
squish.nativeType("<Ctrl+Left>")
squish.nativeType(exp_date.day)
# Move the cursor to month (first) field and enter the month
# Backspace works most of the time, so we clear the data using backspace
squish.nativeType("<Ctrl+Left>")
squish.nativeType("<Ctrl+Left>")
squish.nativeType("<Right>")
squish.nativeType("<Backspace>")
squish.nativeType("<Backspace>")
squish.nativeType(exp_date.month)
squish.nativeType("<Return>")
actual_date = str(
squish.waitForObjectExists(
PublicLinkDialog.EXPIRATION_DATE_FIELD
).displayText
)
expected_date = f"{exp_date.month}/{exp_date.day}/{exp_year}"
if not actual_date == expected_date:
test.log(
f"Expected date: {expected_date} is not same as that of actual date: {actual_date}"
+ ", trying workaround"
)
# retry with workaround
PublicLinkDialog.set_expiration_date_with_workaround(
exp_year, exp_date.month, exp_date.day
)
actual_date = str(
squish.waitForObjectExists(
PublicLinkDialog.EXPIRATION_DATE_FIELD
).displayText
)
if not actual_date == expected_date:
test.fail(
f"workaround failed:\nactual date: {actual_date}\nexpected date: {expected_date}"
)
else:
default_date = str(
squish.waitForObjectExists(
PublicLinkDialog.EXPIRATION_DATE_FIELD
).displayText
)
PublicLinkDialog.set_default_expiry_date(default_date)
# This workaround is needed because the above function 'set_expiration_date' can not set the month field sometime.
# See for more details: https://github.com/owncloud/client/issues/9218
@staticmethod
def set_expiration_date_with_workaround(year, month, day):
squish.mouseClick(squish.waitForObject(PublicLinkDialog.EXPIRATION_DATE_FIELD))
# date can only be set to future date. But sometimes it can not modify the month field in first attempt.
# date format is 'm/d/yy', so we have to edit 'month' first
# then 'day' and then 'year'.
# Delete data from month field
squish.nativeType("<Delete>")
squish.nativeType("<Delete>")
squish.nativeType(month)
squish.nativeType(day)
squish.nativeType(year)
squish.nativeType("<Return>")
@staticmethod
def get_radio_object_for_permssion(permissions):
permission_locator = None
if permissions in ("Download / View", "Viewer"):
permission_locator = PublicLinkDialog.READ_ONLY_RADIO_BUTTON
elif permissions in ("Download / View / Edit", "Editor"):
permission_locator = PublicLinkDialog.READ_WRITE_RADIO_BUTTON
elif permissions in ("Upload only (File Drop)", "Contributor"):
permission_locator = PublicLinkDialog.UPLOAD_ONLY_RADIO_BUTTON
else:
raise LookupError("No such radio object found for given permission")
return permission_locator
@staticmethod
def create_public_link_with_role(role):
permission_locator = PublicLinkDialog.get_radio_object_for_permssion(role)
squish.clickButton(squish.waitForObject(permission_locator))
squish.clickButton(squish.waitForObject(PublicLinkDialog.CREATE_SHARE_BUTTON))
squish.waitFor(
lambda: (
squish.findObject(PublicLinkDialog.PUBLIC_LINK_NAME).displayText
== "Public link"
)
)
@staticmethod
def change_password(password):
PublicLinkDialog.set_password(password)
squish.clickButton(
squish.waitForObject(PublicLinkDialog.UPDATE_PASSWORD_BUTTON)
)
@staticmethod
def delete_public_link():
squish.clickButton(squish.waitForObject(PublicLinkDialog.DELETE_LINK_BUTTON))
squish.clickButton(
squish.waitForObject(PublicLinkDialog.CONFIRM_LINK_DELETE_BUTTON)
)
squish.waitFor(
lambda: (not object.exists(PublicLinkDialog.DELETE_LINK_BUTTON)),
)
@staticmethod
def get_expiration_date():
default_date = str(
squish.waitForObjectExists(
PublicLinkDialog.EXPIRATION_DATE_FIELD
).displayText
)
return PublicLinkDialog.parse_date(default_date)
+101
View File
@@ -0,0 +1,101 @@
from types import SimpleNamespace
from appium.webdriver.common.appiumby import AppiumBy as By
from helpers.AppHelper import app
class Settings:
CHECKBOX_OPTION_ITEM = SimpleNamespace(by=None, selector=None)
NETWORK_OPTION_ITEM = SimpleNamespace(by=None, selector=None)
ABOUT_BUTTON = SimpleNamespace(by=By.NAME, selector="About")
ABOUT_DIALOG = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | About]")
ABOUT_DIALOG_OK_BUTTON = SimpleNamespace(by=By.NAME, selector="OK")
GENERAL_SETTING_START_ON_LOGIN = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Start on Login']"
)
GENERAL_SETTING_LANGUAGE = SimpleNamespace(
by=By.XPATH, selector="//panel/label[@name='Language']"
)
ADVANCED_SETTING_SYNC_HIDDEN_FILES = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Sync hidden files']"
)
ADVANCED_SETTING_EDIT_IGNORED_FILES = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Edit Ignored Files']"
)
ADVANCED_SETTING_LOG_SETTINGS = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Log Settings']"
)
NETWORK_SETTING_DOWNLOAD_BANDWIDTH = SimpleNamespace(
by=By.XPATH, selector="//panel[@name='Download Bandwidth']"
)
NETWORK_SETTING_UPLOAD_BANDWIDTH = SimpleNamespace(
by=By.XPATH, selector="//panel[@name='Upload Bandwidth']"
)
@staticmethod
def get_checkbox_option_selector(name):
selector = Settings.CHECKBOX_OPTION_ITEM.copy()
selector.update({"name": name})
if name == "languageDropdown":
selector.update({"type": "QComboBox"})
elif name in ("ignoredFilesButton", "logSettingsButton"):
selector.update({"type": "QPushButton"})
return selector
@staticmethod
def get_network_option_selector(name):
selector = Settings.NETWORK_OPTION_ITEM.copy()
selector.update({"name": name})
return selector
@staticmethod
def has_general_setting(setting):
if setting.lower() == "start on login":
locator = Settings.GENERAL_SETTING_START_ON_LOGIN
elif setting.lower() == "language":
locator = Settings.GENERAL_SETTING_LANGUAGE
else:
raise ValueError(f"Unknown general setting: {setting}")
return app().find_element(locator.by, locator.selector).is_displayed()
@staticmethod
def has_advanced_setting(setting):
if setting.lower() == "sync hidden files":
locator = Settings.ADVANCED_SETTING_SYNC_HIDDEN_FILES
elif setting.lower() == "edit ignored files":
locator = Settings.ADVANCED_SETTING_EDIT_IGNORED_FILES
elif setting.lower() == "log settings":
locator = Settings.ADVANCED_SETTING_LOG_SETTINGS
else:
raise ValueError(f"Unknown advanced setting: {setting}")
return app().find_element(locator.by, locator.selector).is_displayed()
@staticmethod
def has_network_setting(setting):
if setting.lower() == "download bandwidth":
locator = Settings.NETWORK_SETTING_DOWNLOAD_BANDWIDTH
elif setting.lower() == "upload bandwidth":
locator = Settings.NETWORK_SETTING_UPLOAD_BANDWIDTH
else:
raise ValueError(f"Unknown network setting: {setting}")
return app().find_element(locator.by, locator.selector).is_displayed()
@staticmethod
def open_about_dialog():
app().find_element(
Settings.ABOUT_BUTTON.by, Settings.ABOUT_BUTTON.selector
).click()
@staticmethod
def has_about_dialog():
return (
app()
.find_element(Settings.ABOUT_DIALOG.by, Settings.ABOUT_DIALOG.selector)
.is_displayed()
)
@staticmethod
def close_about_dialog():
app().find_element(
Settings.ABOUT_DIALOG_OK_BUTTON.by, Settings.ABOUT_DIALOG_OK_BUTTON.selector
).click()
@@ -0,0 +1,131 @@
from types import SimpleNamespace
from appium.webdriver.common.appiumby import AppiumBy as By
from selenium.common.exceptions import NoSuchElementException
from helpers.ConfigHelper import get_config
from helpers.AppHelper import app
class SyncConnection:
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
by=By.NAME, selector="Sync connections"
)
FOLDER_SYNC_CONNECTION_MENU_BUTTON = SimpleNamespace(
by=By.NAME,
selector="{sync_folder},Success,Local folder: {sync_path}{sync_folder}",
)
MENU_ITEM = SimpleNamespace(by=By.NAME, selector=None)
SELECTIVE_SYNC_APPLY_BUTTON = SimpleNamespace(by=None, selector=None)
CANCEL_FOLDER_SYNC_CONNECTION_DIALOG = SimpleNamespace(by=None, selector=None)
CONFIRM_FOLDER_SYNC_CONNECTION_REMOVE = SimpleNamespace(
by=By.NAME, selector="Remove Space"
)
PERMISSION_ERROR_LABEL = SimpleNamespace(by=None, selector=None)
@staticmethod
def get_current_account_connection():
connections = app().find_elements(
SyncConnection.ACCOUNT_CONNECTION_CONTAINER.by,
SyncConnection.ACCOUNT_CONNECTION_CONTAINER.selector,
)
for connection in connections:
# use the active connection
if connection.get_attribute("showing") == "true":
return connection
return None
@staticmethod
def open_menu(sync_folder=None):
if sync_folder is None:
sync_folder = get_config('syncConnectionName')
connection = SyncConnection.get_current_account_connection()
menu_button = connection.find_element(
SyncConnection.FOLDER_SYNC_CONNECTION_MENU_BUTTON.by,
SyncConnection.FOLDER_SYNC_CONNECTION_MENU_BUTTON.selector.format(
sync_folder=sync_folder,
sync_path=get_config('currentUserSyncPath'),
),
)
menu_button.native_click(button='right')
@staticmethod
def perform_action(action):
SyncConnection.open_menu()
app().find_element(SyncConnection.MENU_ITEM.by, action).click()
@staticmethod
def force_sync():
SyncConnection.perform_action("Force sync now")
@staticmethod
def pause_sync():
SyncConnection.perform_action("Pause sync")
@staticmethod
def resume_sync():
SyncConnection.perform_action("Resume sync")
@staticmethod
def has_menu_item(item):
return squish.waitForObjectItem(SyncConnection.MENU_ITEM, item)
@staticmethod
def menu_item_exists(menu_item):
obj = SyncConnection.MENU_ITEM.copy()
obj.update({"type": "QAction", "text": menu_item})
return object.exists(obj)
@staticmethod
def choose_what_to_sync():
SyncConnection.open_menu()
SyncConnection.perform_action("Choose what to sync")
@staticmethod
def has_sync_connection(sync_folder):
connection = SyncConnection.get_current_account_connection()
try:
connection.find_element(
SyncConnection.FOLDER_SYNC_CONNECTION_MENU_BUTTON.by,
SyncConnection.FOLDER_SYNC_CONNECTION_MENU_BUTTON.selector.format(
sync_folder=sync_folder,
sync_path=get_config('currentUserSyncPath'),
),
)
return True
except NoSuchElementException:
return False
@staticmethod
def remove_folder_sync_connection():
SyncConnection.perform_action("Remove Space")
@staticmethod
def cancel_folder_sync_connection_removal():
squish.clickButton(
squish.waitForObject(SyncConnection.CANCEL_FOLDER_SYNC_CONNECTION_DIALOG)
)
@staticmethod
def confirm_folder_sync_connection_removal():
app().find_element(
SyncConnection.CONFIRM_FOLDER_SYNC_CONNECTION_REMOVE.by,
SyncConnection.CONFIRM_FOLDER_SYNC_CONNECTION_REMOVE.selector,
).click()
@staticmethod
def wait_for_error_label(to_exist=True):
"""Wait for permission error label to appear or disappear"""
status = squish.waitFor(
lambda: object.exists(SyncConnection.PERMISSION_ERROR_LABEL) == to_exist,
get_config("maxSyncTimeout") * 1000,
)
if not status:
action = "appear" if to_exist else "disappear"
raise AssertionError(f"Permission error label did not {action}")
@staticmethod
def get_permission_error_message():
"""Get the permission error message text"""
SyncConnection.wait_for_error_label(True) # Wait for label to appear
return str(squish.waitForObject(SyncConnection.PERMISSION_ERROR_LABEL).text)
@@ -0,0 +1,283 @@
from types import SimpleNamespace
from appium.webdriver.common.appiumby import AppiumBy as By
from selenium.webdriver.common.keys import Keys
from helpers.SetupClientHelper import get_current_user_sync_path
from helpers.AppHelper import app
class SyncConnectionWizard:
CHOOSE_LOCAL_SYNC_FOLDER = SimpleNamespace(
by=By.ACCESSIBILITY_ID, selector="localFolderLineEdit"
)
BACK_BUTTON = SimpleNamespace(by=By.NAME, selector="< Back")
NEXT_BUTTON = SimpleNamespace(by=By.NAME, selector="Next >")
SELECTIVE_SYNC_ROOT_FOLDER = SimpleNamespace(by=None, selector=None)
ADD_SYNC_CONNECTION_BUTTON = SimpleNamespace(
by=By.XPATH, selector="//dialog[@name='Add Space']//*[@name='Add Space']"
)
REMOTE_FOLDER_TREE = SimpleNamespace(by=None, selector=None)
SELECTIVE_SYNC_TREE_HEADER = SimpleNamespace(by=None, selector=None)
CANCEL_FOLDER_SYNC_CONNECTION_WIZARD = SimpleNamespace(
by=By.NAME, selector="Cancel"
)
SPACES_LIST = SimpleNamespace(by=By.NAME, selector="Spaces list")
SPACE_NAME_SELECTOR = SimpleNamespace(by=By.NAME, selector="{space_name},")
CREATE_REMOTE_FOLDER_BUTTON = SimpleNamespace(by=None, selector=None)
CREATE_REMOTE_FOLDER_INPUT = SimpleNamespace(by=None, selector=None)
CREATE_REMOTE_FOLDER_CONFIRM_BUTTON = SimpleNamespace(by=None, selector=None)
REFRESH_BUTTON = SimpleNamespace(by=None, selector=None)
REMOTE_FOLDER_SELECTION_INPUT = SimpleNamespace(by=None, selector=None)
ADD_FOLDER_SYNC_BUTTON = SimpleNamespace(by=None, selector=None)
WARN_LABEL = SimpleNamespace(by=None, selector=None)
CHOOSE_WHAT_TO_SYNC_FOLDER_TREE = SimpleNamespace(by=None, selector=None)
@staticmethod
def set_sync_path_oc(sync_path):
if not sync_path:
sync_path = get_current_user_sync_path()
sync_path_input = app().find_element(
SyncConnectionWizard.CHOOSE_LOCAL_SYNC_FOLDER.by,
SyncConnectionWizard.CHOOSE_LOCAL_SYNC_FOLDER.selector,
)
sync_path_input.clear()
sync_path_input.send_keys(sync_path)
SyncConnectionWizard.next_step()
@staticmethod
def set_sync_path(sync_path=""):
SyncConnectionWizard.set_sync_path_oc(sync_path)
@staticmethod
def next_step():
next_button = app().find_element(
SyncConnectionWizard.NEXT_BUTTON.by,
SyncConnectionWizard.NEXT_BUTTON.selector,
)
if not next_button.is_enabled():
raise AssertionError("Next button is not enabled")
next_button.click()
@staticmethod
def back():
squish.clickButton(squish.waitForObject(SyncConnectionWizard.BACK_BUTTON))
@staticmethod
def select_remote_destination_folder(folder):
squish.mouseClick(
squish.waitForObjectItem(SyncConnectionWizard.REMOTE_FOLDER_TREE, folder)
)
SyncConnectionWizard.next_step()
@staticmethod
def deselect_all_remote_folders():
element = app().find_element(
SyncConnectionWizard.ADD_SYNC_CONNECTION_BUTTON.by,
SyncConnectionWizard.ADD_SYNC_CONNECTION_BUTTON.selector,
)
element.send_keys(Keys.ARROW_DOWN)
element.native_send_keys(Keys.SPACE) # uncheck the root folder
@staticmethod
def sort_by(header_text):
squish.mouseClick(
squish.waitForObject(
{
"container": SyncConnectionWizard.SELECTIVE_SYNC_TREE_HEADER,
"text": header_text,
"type": "HeaderViewItem",
"visible": True,
}
)
)
@staticmethod
def add_sync_connection():
app().find_element(
SyncConnectionWizard.ADD_SYNC_CONNECTION_BUTTON.by,
SyncConnectionWizard.ADD_SYNC_CONNECTION_BUTTON.selector,
).click()
@staticmethod
def get_item_name_from_row(row_index):
folder_row = {
"row": row_index,
"container": SyncConnectionWizard.SELECTIVE_SYNC_ROOT_FOLDER,
"type": "QModelIndex",
}
return str(squish.waitForObjectExists(folder_row).displayText)
@staticmethod
def is_root_folder_checked():
state = squish.waitForObject(SyncConnectionWizard.SELECTIVE_SYNC_ROOT_FOLDER)[
"checkState"
]
return state == "checked"
@staticmethod
def cancel_folder_sync_connection_wizard():
app().find_element(
SyncConnectionWizard.CANCEL_FOLDER_SYNC_CONNECTION_WIZARD.by,
SyncConnectionWizard.CANCEL_FOLDER_SYNC_CONNECTION_WIZARD.selector,
).click()
@staticmethod
def select_space(space_name):
spaces_list = app().find_element(
SyncConnectionWizard.SPACES_LIST.by,
SyncConnectionWizard.SPACES_LIST.selector,
)
space_item = spaces_list.find_element(
SyncConnectionWizard.SPACE_NAME_SELECTOR.by,
SyncConnectionWizard.SPACE_NAME_SELECTOR.selector.format(
space_name=space_name
),
)
# ISSUE: https://github.com/opencloud-eu/desktop/pull/879
# Cannot select space by click event
# Select space using keyboard events as a workaround
# TODO: Remove 'send_keys' and uncomment 'click' action
space_item.send_keys(Keys.ARROW_DOWN)
# space_item.click()
if space_item.get_attribute("selected") != "true":
raise AssertionError("Failed to select the space: " + space_name)
@staticmethod
def sync_space(space_name):
SyncConnectionWizard.set_sync_path(get_current_user_sync_path())
SyncConnectionWizard.select_space(space_name)
SyncConnectionWizard.next_step()
SyncConnectionWizard.add_sync_connection()
@staticmethod
def create_folder_in_remote_destination(folder_name):
squish.clickButton(
squish.waitForObject(SyncConnectionWizard.CREATE_REMOTE_FOLDER_BUTTON)
)
squish.type(
squish.waitForObject(SyncConnectionWizard.CREATE_REMOTE_FOLDER_INPUT),
folder_name,
)
squish.clickButton(
squish.waitForObject(
SyncConnectionWizard.CREATE_REMOTE_FOLDER_CONFIRM_BUTTON
)
)
@staticmethod
def refresh_remote():
squish.clickButton(squish.waitForObject(SyncConnectionWizard.REFRESH_BUTTON))
@staticmethod
def is_remote_folder_selected(folder_selector):
return squish.waitForObjectExists(folder_selector).selected
@staticmethod
def open_sync_connection_wizard():
squish.mouseClick(
squish.waitForObject(SyncConnectionWizard.ADD_FOLDER_SYNC_BUTTON)
)
@staticmethod
def get_local_sync_path():
return str(
squish.waitForObjectExists(
SyncConnectionWizard.CHOOSE_LOCAL_SYNC_FOLDER
).displayText
)
@staticmethod
def get_warn_label():
return str(squish.waitForObjectExists(SyncConnectionWizard.WARN_LABEL).text)
@staticmethod
def is_add_sync_folder_button_enabled():
return squish.waitForObjectExists(
SyncConnectionWizard.ADD_FOLDER_SYNC_BUTTON
).enabled
@staticmethod
def select_or_unselect_folders_to_sync(folders, select=True):
expected_state = "true" if select else "false"
if select:
SyncConnectionWizard.deselect_all_remote_folders()
for folder_path in folders:
parents = folder_path.strip("/").split("/")
target_folder = parents.pop()
parent_element = None
parent_position = 0
for parent in parents:
p_elements = app().find_elements(By.NAME, parent)
# select nested folders based on the position of the parent folder
for p_element in p_elements:
if (
p_element.get_attribute("checked") == 'true'
and p_element.rect["x"] > parent_position
):
parent_element = p_element
parent_position = p_element.rect["x"]
break
parent_element.native_double_click() # expand the folder
# retry once if the folder is not expanded
if parent_element.is_selected():
print('[WARN] Folder was not expanded, retrying with space key')
# expand using space key
parent_element.native_click()
parent_element.native_send_keys(Keys.SPACE)
if parent_element.is_selected():
raise AssertionError(f'Failed to expand folder: {parent}')
folder_element = None
target_folders = app().find_elements(By.NAME, target_folder)
# select the folder that is inside the current parent position
for folder in target_folders:
if folder.rect["x"] > parent_position:
folder_element = folder
break
is_checked = folder_element.get_attribute("checked")
# return early if the folder is already in the expected state.
if is_checked == expected_state:
return
folder_element.native_click()
if not folder_element.is_selected():
raise AssertionError(f"Failed to focus folder: {target_folder}")
folder_element.native_send_keys(Keys.SPACE) # toggle the folder selection
is_checked = folder_element.get_attribute("checked")
if is_checked != expected_state:
raise AssertionError(
f"Failed to {'select' if select else 'unselect'} folder: {folder_path}"
)
@staticmethod
def confirm_choose_what_to_sync_selection():
app().find_element(By.NAME, "OK").click()
@staticmethod
def __handle_folder_selection(folders, should_select, new_sync_connection_wizard):
SyncConnectionWizard.select_or_unselect_folders_to_sync(folders, should_select)
if new_sync_connection_wizard:
SyncConnectionWizard.add_sync_connection()
else:
SyncConnectionWizard.confirm_choose_what_to_sync_selection()
@staticmethod
def unselect_folders_to_sync(folders, new_sync_connection_wizard=False):
SyncConnectionWizard.__handle_folder_selection(
folders,
should_select=False,
new_sync_connection_wizard=new_sync_connection_wizard,
)
@staticmethod
def select_folders_to_sync(folders, new_sync_connection_wizard=False):
SyncConnectionWizard.__handle_folder_selection(
folders,
should_select=True,
new_sync_connection_wizard=new_sync_connection_wizard,
)
+181
View File
@@ -0,0 +1,181 @@
from types import SimpleNamespace
from urllib.parse import urlparse
from appium.webdriver.common.appiumby import AppiumBy as By
from selenium.common.exceptions import NoSuchElementException
from helpers.AppHelper import app
from helpers.ConfigHelper import get_config
from helpers.UserHelper import get_displayname_for_user
from helpers.SyncHelper import wait_for
class Toolbar:
TOOLBAR_ROW = SimpleNamespace(by=None, selector=None)
NAVIGATION_BAR = SimpleNamespace(
by=By.XPATH, selector="//*[@name='Navigation bar']/.."
)
ACCOUNT_TAB = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | {text}]")
ADD_ACCOUNT_BUTTON = SimpleNamespace(
by=By.CLASS_NAME, selector="[push button | Add Account]"
)
ACTIVITY_TAB = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | Activity]")
SETTINGS_TAB = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | Settings]")
QUIT_BUTTON = SimpleNamespace(by=By.CLASS_NAME, selector="[push button | Quit]")
CONFIRM_QUIT_BUTTON = SimpleNamespace(
by=By.NAME,
selector="Yes",
)
TOOLBAR_ITEMS = ["Add Account", "Activity", "Settings", "Quit"]
@staticmethod
def wait_toolbar_enabled():
toolbar = app().find_element(
Toolbar.NAVIGATION_BAR.by, Toolbar.NAVIGATION_BAR.selector
)
timeout = get_config('maxSyncTimeout') * 1000
enabled = wait_for(
lambda: toolbar.is_enabled(),
timeout,
)
if not enabled:
raise AssertionError(f"Toolbar is not enabled within {timeout} ms")
@staticmethod
def get_item_selector(item_name):
return {
"container": names.dialogStack_quickWidget_QQuickWidget,
"text": item_name,
"type": "Label",
"visible": True,
}
@staticmethod
def has_tab(tab_name):
if tab_name.lower() == "add account":
tab = Toolbar.ADD_ACCOUNT_BUTTON
elif tab_name.lower() == "activity":
tab = Toolbar.ACTIVITY_TAB
elif tab_name.lower() == "settings":
tab = Toolbar.SETTINGS_TAB
elif tab_name.lower() == "quit":
tab = Toolbar.QUIT_BUTTON
else:
raise ValueError(f"Unknown tab: {tab_name}")
return app().find_element(tab.by, tab.selector).is_displayed()
@staticmethod
def open_activity():
tab = app().find_element(Toolbar.ACTIVITY_TAB.by, Toolbar.ACTIVITY_TAB.selector)
# ISSUE: https://github.com/opencloud-eu/desktop/pull/879
# Cannot select navigation tab by click event
# Select the navigation tab using keyboard events as a workaround
# TODO: Remove the workaround and uncomment 'click' action
# tab.click()
tab.native_click()
if tab.get_attribute("checked") != "true":
raise AssertionError("Activity tab is not active")
@staticmethod
def open_new_account_setup():
app().find_element(
Toolbar.ADD_ACCOUNT_BUTTON.by,
Toolbar.ADD_ACCOUNT_BUTTON.selector,
).click()
@staticmethod
def open_account(username):
account_tab = Toolbar.get_account(username)
# ISSUE: https://github.com/opencloud-eu/desktop/pull/879
# Cannot activate account tab by click event
# Select the account tab using keyboard events as a workaround
# TODO: Remove the workaround and uncomment 'click' action
# account_tab.click()
account_tab.native_click()
# confirm account is active
if account_tab.get_attribute("checked") != "true":
raise AssertionError(f"Account is not active: {username}")
@staticmethod
def get_displayed_account_text(displayname, host):
return str(
squish.waitForObjectExists(
Toolbar.get_item_selector(displayname + "\n" + host)
).text
)
@staticmethod
def open_settings_tab():
tab = app().find_element(Toolbar.SETTINGS_TAB.by, Toolbar.SETTINGS_TAB.selector)
# ISSUE: https://github.com/opencloud-eu/desktop/pull/879
# Cannot select navigation tab by click event
# Select the navigation tab using keyboard events as a workaround
# TODO: Remove the workaround and uncomment 'click' action
# tab.click()
tab.native_click()
if tab.get_attribute("checked") != "true":
raise AssertionError("Settings tab is not active")
@staticmethod
def quit_qsfera():
app().find_element(Toolbar.QUIT_BUTTON.by, Toolbar.QUIT_BUTTON.selector).click()
app().find_element(
Toolbar.CONFIRM_QUIT_BUTTON.by, Toolbar.CONFIRM_QUIT_BUTTON.selector
).click()
@staticmethod
def get_accounts():
accounts = {}
selectors = {}
children_obj = object.children(squish.waitForObjectExists(Toolbar.TOOLBAR_ROW))
account_idx = 1
for obj in children_obj:
if hasattr(obj, "accountState"):
account_info = {
"displayname": str(obj.accountState.account.davDisplayName),
"hostname": str(obj.accountState.account.hostName),
"initials": str(obj.accountState.account.initials),
"current": obj.checked,
}
account_locator = Toolbar.ACCOUNT_TAB.copy()
if account_idx > 1:
account_locator.update({"occurrence": account_idx})
account_locator.update({"text": account_info["hostname"]})
accounts[account_info["displayname"]] = account_info
selectors[account_info["displayname"]] = obj
account_idx += 1
return accounts, selectors
@staticmethod
def get_account(username):
display_name = get_displayname_for_user(username)
server_host = urlparse(get_config('localBackendUrl')).netloc
account_label = f"{display_name}@{server_host}"
account = None
try:
account = app().find_element(
Toolbar.ACCOUNT_TAB.by,
Toolbar.ACCOUNT_TAB.selector.format(text=account_label),
)
except NoSuchElementException:
pass
return account
@staticmethod
def get_active_account():
accounts, selectors = Toolbar.get_accounts()
for account, info in accounts.items():
if info["current"]:
return info, selectors[account]
return None, None
@staticmethod
def account_has_focus(username):
account = Toolbar.get_account(username)
return account.get_attribute("checked") == "true"
@staticmethod
def account_exists(username):
account = Toolbar.get_account(username)
return account is not None
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,239 @@
## Preparation
- create a release or test plan issue in GitHub
- copy each test section of this test plan into a separate comment in the issue
- add links to every section in the main comment
```
* [ ] 1. Install, Setup and Configuration @tester1
-> https://github.com/opencloud-eu/desktop/issues/xxxx#issuecomment-xxxxxxxxx
* [ ] 2. Folders
-> ...
* [ ] 3. Files
-> ...
* [ ] 4. Move files and folders
-> ...
* [ ] 5. Edit Files
-> ...
* [ ] 6. Delete Files and Folders
-> ...
* [ ] 7. Sync process
-> ...
* [ ] 8. Spaces and Sharing
-> ...
* [ ] 9. Pause and resume sync
-> ...
* [ ] 10. Advanced configuration
-> ...
* [ ] 11. Selective sync
-> ...
* [ ] 12. Overlay icons
-> ...
* [ ] 13. Context menu
-> ...
* [ ] 14. VFS
-> ...
```
- If not stated otherwise each test should be run on every platform win/linux/mac. Record the result as per platform.
- "Enable logging to temporary folder" and "Log Http traffic" (tab 'Settings', button 'Log Settings') to have log-files available if needed to report an issue.
- Make sure all automated UI tests run successfully in CI and run them locally on Windows.
- all tests that are automated are marked with a :robot: in the 'Result' column (for the corresponding platform) and the name of the test in the 'Related Comment' column
- If multiple people are working on the tests at the same time, mark the tests everyone is working on with the GitHub name of the tester.
- Add the test result to the 'Result' column. For success: :heavy_check_mark:, failure: :x: (link the reported #issue to 'Related Comment')
## Testplan
### 1. Install, Setup and Configuration
Try everything here once with Keycloak and once with Lico (the single binary builtin IdP).
To easily run a Keycloak setup, use the [deployment examples](https://github.com/opencloud-eu/opencloud/tree/main/deployments/examples/opencloud_full).
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|---------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | Update Installation | 1. Install a previous version<br>2. Update to the new version | | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 2 | Install the new version | Delete any previous version<br><br> **Windows:** <br> - Install the desktop client using .exe installer.<br><br> **Mac:** <br> - Install the desktop client using .pkg installer <br><br> **Ubuntu or Debian with GNOME desktop:** <br> - Run the client as AppImage. <br><br> **Fedora with GNOME desktop:** <br> - Run the client as AppImage. <br><br> **All platforms:** <br> - Check the version from the Settings tab -> About | installed version is correct | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 3 | Verify that you can enter a server address (self signed cert) | 1. Launch desktop client<br>2. Enter a server address<br>3. Click on Next<br>4. If it is the first time you should accept the certificate | | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_addAccount |
| 4 | Valid Login | 1. Log in with the correct username and password | Login successful | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_addAccount |
| 5 | Invalid Login | 1. Try to log in with wrong username or password | Error message `Logon failed. Please verify your credentials and try again.` is shown | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 6 | Skip sync folder | Skip sync folder configuration during setup | Setup is completed, no folder is synced, but can be added later | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 7 | Configure sync folder | Configure sync folder configuration during setup | Setup is completed, folder is synced | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_addAccount |
| 8 | Remove an account | 1. Add an account to desktop client<br>2. Upload files and folders via WebUI<br>3. Wait for sync to complete<br>4. Remove the account | The account is removed from the Desktop client but the files and folder exist on the file system | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_removeAccountConnection |
| 9 | re-add an account | 1. Add an account to desktop client<br>2. Upload files and folders via WebUI<br>3. Wait for sync to complete<br>4. Remove the account<br>5. Add the same account again | The account is added, all files and folder are synced | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_addAccount |
### 2. Folders
- 'Go to local sync folder and create a folder' means: "Open folder" on client dot ... menu, create a new folder in file browser
- 'Verify on the server' means: the folder is sent from client to server / check either with web browser or another client
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|-------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | create a folder | 1. Add an account to desktop client<br>2. Open local sync folder<br>3. Create a folder<br>4. Wait for sync to complete | The folder is available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 2 | folder with long name | 1. Create a folder with a long name (~100 characters)<br>2. Wait for sync to complete | The folder is available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 3 | folder with special characters | 1. Create a folder with special characters and emojis (e.g. `$%ñ&💥🫨❤️‍🔥`) in the local sync folder<br>2. Wait for sync to complete | The folder is available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 4 | folder with many subfolders | 1. Inside the sync root, create a folder with 5 empty subfolders and 5 subfolders containing files<br>2. Wait for sync to complete | All 10 subfolders are available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 5 | sync many folders at once | 1. Create 400 folders outside the sync root<br>2. Move those folders into the sync root<br>3. Wait for sync to complete | All 400 folders are available on the server | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 6 | copy of a folder | 1. Open local sync folder<br>2. Create a folder with some files in it<br>3. Copy and paste that folder<br>4. Wait for sync to complete<br>5. Check the folders and the content of file inside it | Both original and copied folders are available and contains the file on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 7 | subfolder with long name | 1. Open local sync folder<br>2. Create a folder<br>3. Create a subfolder with long name (~220 characters)<br>4. Wait for sync to complete | Folder and subfolder are available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 8 | pre-existing folders | 1. Quit the desktop client<br>2. Goto the local sync folder<br>3. Create folders at several levels<br>4. Open the desktop client<br>5. Wait for sync to complete | All folders are available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 9 | .zip/.rar files with elaborate internal folder structures | 1. Create a zip file with many internal folders and files<br>2. Copy that zip file into the sync root<br>3. Unzip that zip file inside the sync root | All extracted files and folders are available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 10 | Invalid system names | 1. On the server, create folders `CON`, `COM1` and `test%` and files `PRN` and `foo%`<br>2. Add that account to the desktop client | - MacOS client syncs down `CON`, `COM1` and `PRN` but not `test%` and `foo%`<br>- Windows client syncs down `test%` and `foo%` but not `CON`, `COM1` and `PRN`<br>- Linux client syncs down all | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 11 | Long path on Windows | 1. Make sure LongPathsEnabled in Windows registry is 'off' (see https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd) <br>2. On the server, create a file (~30 chars) inside 6 subfolders (each ~50 chars) to get a path length > 260 chars (> MAX_PATH)<br>3. Start desktop client<br>4. Open local sync folder and enter the subfolders<br>5. Create a new file | 1. All subfolders and the file are available in the local sync folder<br>2. New file is synced to the server | :construction: Win | |
### 3. Files
Note: "Via Web" means check files on server in the web browser
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | create a file | 1. Add an account to desktop client<br>2. Open local sync folder<br>3. Create a file | - observe a BLUE progress icon followed by a GREEN icon in the file explorer<br>- the file is available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 2 | Add various types of files | 1. Open local sync folder<br>2. Create/upload various types of files `Microsoft Word`, `Microsoft Excel`, `Microsoft Powerpoint`, `JPG`, `PNG`, `JPEG`, `PDF`, `MP3`, `MP4`, etc | - the Sync files tab shows all the files as synced<br>- all files are available on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 3 | File with long name can be synced | 1. Open local sync folder<br>2. Create a file with long name: `thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIsAVeryLongFileNameToCheckThatItWorks-thisIs.txt` | Sync is successful | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 4 | copy/drag&drops multiple files at a time | 1. Copy some files to the local sync folder<br>2. Drag&Drop some files to the local sync folder | - user can see the completed GREEN icon overlay on all types of files<br>- the Sync files tab shows all files as synced | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 5 | Sync files (from server and desktop client) at the same time | 1. Add a (big) file inside of the local sync folder, and in the same time, upload another file through the webUI in the same folder (make sure that both files are uploaded at the same time) | Both files are synced correctly. Check via Web UI and the Desktop Client | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 6 | Same name files but with different extensions | 1. Open local sync folder<br>2. Create two files with same name but different extensions | Both files sync correctly | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 7 | File with spaces in the name | 1. Open local sync folder<br>2. Create, copy or move a file (having space in its name) in the local sync folder | File syncs correctly | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 8 | Create and delete a file with special characters in the name | 1. Open local sync folder<br>2. Create/upload a file with special characters in its name (e.g. **~`!@#$^&()-_=+{[}];',$%ñ&💥🫨❤️‍🔥**)<br>3. Wait for sync to complete<br>4. Delete the file from the local sync folder | Look via the Web and make sure that the file got deleted | :construction: Win<br>:construction: macOS<br>:robot: Linux | tst_deleteFilesFolders |
| 9 | Upload a 50MB file | 1. Create a folder inside the local sync folder<br>2. Copy a large file (50MB) in this folder | The file is synced to the server | :construction: Win<br>:construction: macOS<br>:robot: Linux | |
| 10 | Upload a 3GB file | 1. On the server, upload a large 3GB file<br>2. Add that account to desktop client | File is synced to the desktop client | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 11 | Upload multiple files (with a sum of 1000MB) | 1. Open local sync folder<br>2. Upload a folder containing 1000 files (1MB each) | All files are synced to the server | :construction: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 12 | Upload 500MB + 500MB files | 1. Open temp folder<br>2. Create two folders containing 500 files each(1MB per file)<br>3. Move those folders from temp folder to sync folder<br>4. Wait for folders to be synced | All folders and files are synced to the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 13 | Upload a 1024MB file | 1. Open local sync folder<br>2. Upload a 1GB file | The file is synced to the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
| 14 | quota limit | 1. Open local sync folder<br>2. Upload the necessary large files to fill up the quota | Warning: "Space quota exceeded. Please contact the Administrator of this space." | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
### 4. Move files and folders
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------|-------------------------------------------------------------|-------------------------------|
| 1 | Move from sub-levels to root | 1. Move couple of files and folders from different sub-levels to the sync root<br>2. Wait for sync to complete<br>3. Check the contents of files and folders | The contents are correct (on the server) | :construction: Win<br>:construction: macOS<br>:robot: Linux | tst_moveFilesFolders |
| 2 | Move a folder and file down to sub-folder | 1. Move a folder and file from sync root to a 5 level deep sub-folder<br>2. Wait for sync to complete<br>3. Check the content of file and folder exist | The content of the file is correct and folder exists (on the server) | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_moveFilesFolders |
| 3 | Move a folder and file up from sub-folder to the root | 1. Move a folder and file from a 5 level deep folder to the sync root<br>2. Wait for sync to complete<br>3. Check the content of file and folder exist | The content of the file is correct and folder exists (on the server) | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_moveFilesFolders |
| 4 | Move files from one folder to another | 1. Move some files from `Folder1` to `Folder2`<br>2. Wait for sync to complete<br>3. Check both folders contents | Both folders have the correct content (on the server) | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_moveFilesFolders |
### 5. Edit Files
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | Edit a .txt file | 1. Create a .txt file in the local sync folder<br>2. Add some text<br>3. Wait for it to sync<br>4. Modify the .txt file and add more content<br>5. Wait for it to sync | The file at the server had the same content after the sync is completed | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_editFiles |
| 2 | Edit a .docx file | 1. Create a .docx file in the local sync folder<br>2. Add some text<br>3. Wait for it to sync<br>4. Modify the .docx file and add more content<br>5. Wait for it to sync<br>6. Modify the .docx file and add more content<br>7. Wait for it to sync | The file at the server had the same content after the sync is completed | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_editFiles |
| 3 | Edit a .xlsx file | 1. Create a .xlsx file in the local sync folder<br>2. Add some text<br>3. Wait for it to sync<br>4. Modify the .xlsx file and add more content<br>5. Wait for it to sync<br>6. Modify the .xlsx file and add more content<br>7. Wait for it to sync | The file at the server had the same content after the sync is completed | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_editFiles |
| 4 | Replace a .pdf file | 1. Create a .pdf file in the local sync folder<br>2. Replace it with a same name pdf file having different content<br>3. Wait for it to sync<br>4. Modify the .pdf file and add more content<br>5. Wait for it to sync<br>6. Modify the .pdf file and add more content<br>7. Wait for it to sync | The file at the server had the same content after the sync is completed | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_editFiles |
| 5 | Edit a file while the folder is renamed | 1. Add an account to desktop client that has a folder containing a file in it<br>2. From the file explorer, open that file and edit it<br>3. From the web, rename the folder<br>4. Sync with the oc-worker<br>5. Do not refresh the browser and download the file edited | The file on the server has the same content | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 6 | Rename file and folder at the same time | 1. Add an account to desktop client that has a folder containing a file in it<br>2. From the web, rename that folder and at the same time from the file explorer, rename a file contained in that folder | File and folder should be renamed both in the server and locally | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
### 6. Delete Files and Folders
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------|--------------------------------------------------------------------|-----------------------------------------------------------------------|
| 1 | Delete a file | 1. On the server, create a file<br>2. Add that account to the desktop client<br>3. Open the local sync folder<br>4. Delete that file | The file is deleted on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_deleteFilesFolders |
| 2 | Delete a file with long name | 1. On the server, create a file with long name<br>2. Add that account to the desktop client<br>3. Open the local sync folder<br>4. Delete that file | The file is deleted on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_deleteFilesFolders (Maximum length of filename is 228 chraracter) |
| 3 | Delete a folder | 1. On the server, create a folder<br>2. Add that account to the desktop client<br>3. Open the local sync folder<br>4. Delete that folder | The folder is deleted on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_deleteFilesFolders |
| 4 | Delete a folder with long name | 1. On the server, create a folder with long name<br>2. Add that account to the desktop client<br>3. Open the local sync folder<br>4. Delete that folder | The folder is deleted on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_deleteFilesFolders |
| 5 | Delete multiple files | 1. On the server, create some files<br>2. Add that account to the desktop client<br>3. Open the local sync folder<br>4. Delete some files | The files get deleted on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_deleteFilesFolders |
| 6 | Delete a large file (2048MB) | 1. On the server, create a large file (2048MB)<br>2. Add that account to the desktop client<br>3. Open the local sync folder<br>4. Delete that file | The file gets deleted on the server | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
### 7. Sync process
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | Move a file while in sync | 1. Move couple of files (`File1` and `File2`) with different contents to the root sync folder<br>2. Let them sync<br>3. Move other files to the root sync folder and while in sync, delete `File1` and rename `File2` to`File1` | The content of file is correct | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 2 | Edit a file while in sync | 1. Create a .txt file with some content<br>2. Let it sync<br>3. Rename the file and while in sync, edit the content of the .txt file | The content of file is correct | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 3 | Delete folders while in sync | 1. Create folders tree folders+subfolders (e.g folder1, folder2, folder3 and some .txt files in this folder)<br>2. Delete some folders while in sync | Folders get deleted on the server | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 4 | Delete folders while sync with two clients | 1. Create a tree of folders+subfolders (e.g folder1, folder2, folder3 and some .txt files in this folder)<br>2. Delete some folders and while in sync, sync with another client at the same time | On the server, make sure that the folders got deleted and the remaining folders sync correctly | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
### 8. Spaces and Sharing
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|--------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | Viewer can view files in Space | 1. On the server, create a space, upload a file in it and add a user with Viewer role<br>2. As a space member (Viewer), add that space to the desktop client<br>3. Open the local sync folder and open that file | The file can be opened | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_spaces |
| 2 | Viewer cannot edit files in Space | 1. On the server, create a space, upload a file in it and add a user with Viewer role<br>2. As a space member (Viewer), add that space to the desktop client<br>3. Open the local sync folder<br>4. Edit that file and save it | Changes are not synced. Locally a conflicted copy is created | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_spaces |
| 3 | Editor can rename files in Space | 1. On the server, create a space, upload a file in it and add a user with Editor role<br>2. As a space member (Editor), add that space to the desktop client<br>3. Open the local sync folder<br>4. Rename that file | The files is renamed and synced | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_spaces |
| 4 | Manager can add new folders in Space | 1. On the server, create a space, upload a file in it and add a user with Manager role<br>2. As a space member (Manager), add that space to the desktop client<br>3. Open the local sync folder<br>4. Create a new folder | New folder is synced | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_spaces |
| 5 | Remove a project space sync connection | 1. On the server, create a space, upload a file in it and add a user with Manager role<br>2. As a space member (Manager), add that space to the desktop client<br>3. Wait till all files and folder are synced<br>4. Remove the space from the desktop client | Space is deleted from the list, all files and folders still exist in the local sync folder | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_spaces |
| 6 | sync a received shared folder `can view(secure)` | 1. On the server, create a folder<br>2. upload some files into it<br>3. Share the folder with an other user with `can view(secure)` permissions<br>4. As the share receiver, add the "Shares" space to the desktop client<br>5. create folders locally in the received share<br>6. copy files into the received local folder | Folder is shown, but no files are synced in ether direction, error message shown by the client | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 7 | sync a received shared folder `can view` | 1. On the server, create a folder<br>2. upload some files into it<br>3. Share the folder with an other user with `can view` permissions<br>4. As the share receiver, add the "Shares" space to the desktop client<br>5. create folders locally in the received share<br>6. copy files into the received local folder | Folder and files are downloaded, but no folder/files are uploaded to the server, error message shown by the client | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_spaces |
| 8 | sync a received shared folder `can upload` | 1. On the server, create a folder<br>2. upload some files into it<br>3. Share the folder with an other user with `can upload` permissions<br>4. As the share receiver, add the "Shares" space to the desktop client<br>5. create folders locally in the received share<br>6. copy files into the received local folder<br>7. edit a local file | Folder and files are synced in both directions, but editing results in a new conflict file and the change is not uploaded | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 9 | sync a received shared folder `can edit` | 1. On the server, create a folder<br>2. upload some files into it<br>3. Share the folder with an other user with `can edit` permissions<br>4. As the share receiver, add the "Shares" space to the desktop client<br>5. create folders locally in the received share<br>6. copy files into the received local folder<br>7. edit a local file | Folder and files are synced in both directions | :construction: Win<br>:construction: macOS<br>:robot: Linux | tst-spaces |
| 10 | remove a share | 1. On the server, create a folder<br>2. upload some files into it<br>3. Share the folder with an other user with `can edit` permissions<br>4. As the share receiver, add the "Shares" space to the desktop client<br>5. wait till the folder is synced locally<br>6. Delete the share on the server | Folder and files disappear from the local sync folder | :construction: Win<br>:construction: macOS<br>:robot: Linux | |
| 11 | remove a share while offline | 1. On the server, create a folder<br>2. upload some files into it<br>3. Share the folder with an other user with `can edit` permissions<br>4. As the share receiver, add the "Shares" space to the desktop client<br>5. wait till the folder is synced locally<br>6. Disconnect from the server e.g. by disconnecting from the internet<br>7. Add a new file to the shared folder locally<br>8. Delete the share on the server<br>9. reestablish the connection to the server | Files or folder are deleted locally | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 12 | Viewer creates folder in a project space | 1. On the server, create a project space<br>2. Add an user in that space with `Can view` permission role.<br>3. As a space member (Viewer), add that space to the desktop client<br>4. Open the local sync folder and create a folder | Folder is blacklisted | :construction: Win<br>:construction: macOS<br>:robot: Linux | |
### 9. Pause and resume sync
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|---------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | Sync after reestablishing the connection | 1. Add an account to the desktop client<br>2. Disconnect from the server e.g. by disconnecting from the internet<br>3. Copy several files and folders into the sync folder<br>4. reestablish the connection to the server<br>5. Wait for sync | The files and folder are synced to the server | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 2 | Merge folder content after reestablishing the connection | 1. Add an account to the desktop client<br>2. Disconnect from the server e.g. by disconnecting from the internet<br>3. Upload a folder with some files to the server e.g. by the webUI or an other client<br>4. In the local sync folder, create a folder with the same name but different files<br>5. reestablish the connection to the server<br>6. Wait for sync | All files and folder are synced to the server and to the local sync folder | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 3 | Deletion after reestablishing the connection | 1. Add an account to the desktop client<br>2. Disconnect from the server e.g. by disconnecting from the internet<br>3. Delete a folder from the local sync folder<br>4. Connect the Internet<br>5. Wait for sync | The folder is deleted from the server | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 4 | manually pause/resume a sync | 1. Add an account to the desktop client<br>2. Upload big files to the server into a project space & personal space via WebUI<br>3. Copy other big files into the local sync folder of the project space & personal space<br>4. Pause the sync while files are uploaded/downloaded<br>5. Resume the sync. | All files are folders are synced correctly in both directions | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 5 | Force a sync while an Upload to a different space is running | 1. Add an account to the desktop client<br>2. Copy small files into the local sync folder of a project space<br>3. Copy big files into the local sync folder of the personal space<br>4. Wait till the project space sync is finished and personal space sync still runs<br>5. Force a sync of the project space. | All files are folders are synced correctly in both directions | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 6 | Force a sync while Download from a different space is running | 1. Add an account to the desktop client<br>2. Upload small files into a project space using the webUI<br>3. Upload big files into a personal space using the webUI<br>4. Wait till the project space sync is finished and personal space sync still runs<br>5. Force a sync of the project space. | All files are folders are synced correctly in both directions | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 7 | Conflicting file | 1. Add an account to the desktop client<br>2. save a file in the local sync folder<br>3. Disconnect from the server e.g. by disconnecting from the internet<br>4. Edit the same file locally and on the server<br>4. reestablish the connection to the server<br>5. Wait for sync | Conflict file are created locally and on the server | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
### 10. Advanced configuration
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|------------------------------------------------------|-------------------------------|
| 1 | Advanced configuration | 1. Start the desktop client and fill in the server details<br>2. Check the advanced configuration checkbox | `Download everything` option is selected | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_addAccount |
| 2 | Configure synchronization manually | 1. Start the desktop client and fill in the server details<br>2. Check the advanced configuration checkbox<br>3. choose `Configure synchronization manually`<br>4. Connect the account<br>5. Choose a space and a local folder | The space is synced into the chosen folder | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_addAccount |
| 3 | Configure synchronization manually, a space | 1. Start the desktop client and fill in the server details<br>2. Check the advanced configuration checkbox<br>3. choose `Configure synchronization manually`<br>4. Connect the account<br>5. Choose "Cancel" in the next screen | - No local sync folder is created<br>- The setting window is opened and the account is registered | :robot: Win<br>:construction: macOS<br>:robot: Linux | tst_syncing |
### 11. Selective sync
> [!NOTE]
> Selective sync is not available on Windows due to VFS implemented by default.
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | sync only one folder | 1. Upload some files and folders to the server<br>2. add an account to the desktop client with manual sync configuration<br>3. Choose the personal space to be synced<br>4. choose a local folder<br>5. Select only one folder to be synced and add the connection | Only one folder is synced | :construction: macOS<br>:robot: Linux | tst_syncing |
| 2 | unselected subfolders | 1. Upload a folder that has many subfolders to the server<br>2. Connect the desktop client and sync the personal space<br>From the `...` button for the space select "Chose what to sync" window, select the folder that has many subfolders<br>3. Extend that folder and unselect some subfolders<br>3. Click "OK" | The parent folder is synced but not the unselected subfolders | :construction: macOS<br>:robot: Linux | tst_syncing |
| 3 | Folder without subfolder in the list | 1. From the `Deselect remote folders...` window, click on the `>` for a folder that does not have subfolders | the `>` disappears | :construction: macOS<br>:construction: Linux | |
| 4 | sync files both ways for selected folder | 1. From the `Deselect remote folders...` window, select a folder to sync and add the connection<br>2. Upload some files via webUI into that folder<br>3. Copy some other files into the corresponding local folder<br>4. Wait for sync | Files are synced both ways | :construction: macOS<br>:construction: Linux | |
| 5 | sync files for unselected folder | 1. From the `Deselect remote folders...` window, unselect a folder and add connection<br>2. From the server, upload some files in that unselected folder | The folder and files are not available in the sync folder<br>Previously synced folders are deleted | :construction: macOS<br>:robot: Linux | tst-syncing |
| 6 | sync of files in root folder | 1. From the `Deselect remote folders...` window, unselect all the folders<br>2. Add the connection | files that are in the root folder are synced | :construction: macOS<br>:construction: Linux | |
| 7 | sorting of folders | 1. In the `Deselect remote folders...` window, sort the folders by name and size | Sorting works | :construction: macOS<br>:robot: Linux | tst_syncing |
### 12. Overlay icons
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|--------------------|-------------------------------|
| 1 | overlay icons | 1. Launch the Desktop client <br> 2. Open the file explorer | Check qsfera overlay icons are present. | :construction: Win | |
| 2 | offline state | 1. Disconnect from the server e.g. by disconnecting from the internet<br>2. Launch the Desktop client<br>3. Check the overlay icons in the local sync folder | The overlay icons are not shown | :construction: Win | |
| 3 | paused sync | 1. Launch the Desktop client<br>2. Check the overlay icons<br>3. Pause the sync<br>4. Check the overlay icons | The overlay icons disappear | :construction: Win | |
| 4 | everything synced | 1. Open the local sync folder<br>2. Add a folder having multiple nested files and folders<br>3. Check the overlay icons | The green check is shown in all the folders/files | :construction: Win | |
| 5 | sync in progress | 1. Open the local sync folder<br>2. Add some files and folders<br>3. Check the overlay icons | The files/folder that are waiting to sync have the blue icons | :construction: Win | |
| 6 | warning during sync | 1. Open the local sync folder<br>3. Add a problematic file (path longer than 255 characters)<br>3. Check the overlay icons | The files that are not synced due to a problem have the yellow warning triangle icons | :construction: Win | |
| 7 | fatal error during sync | 1. Open the local sync folder<br>3. Create a sync error<br>3. Check the overlay icons | The files/folders that are not synced due to a fatal problem have the red error icons | :construction: Win | |
### 13. Context menu
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|-------------------------------|
| 1 | Sharing | 1. Open the context menu of a synced file <br>2. Click on "Share ..." | A browser window opens and takes the user to the sharing page of the file | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 2 | Private Link | 1. Open the context menu of a synced file <br>2. Click on "Copy private Link to clipboard"<br>3. Paste the link from the clipboard into a browser window | The browser takes the user to the file | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 3 | Show in web browser | 1. Open the context menu of a synced markdown file <br>2. Click on "Show in web browser" | A browser window opens and takes the user to the file, opened in the markdown editor | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
| 4 | file versions | 1. Open the context menu of a synced markdown file <br>2. Click on "Show file versions in web browser" | A browser window opens and takes the user to the file, with the side bar opened, showind the versions | :construction: Win<br>:construction: macOS<br>:construction: Linux | |
### 14. VFS
> [!NOTE]
> **“Always keep on this device”** = youve pinned the file locally, so it stays on your device at all times and wont be evicted automatically. <br>
> **“Available on this device”** = the file is locally present now, but may be removed automatically to free up space. <br>
> **“Hydrated (full) file”** = the files full data is present locally (downloaded or created), and you can convert it into a placeholder (dehydrated) by using the Free up space action; additionally, the system could later turn it into a placeholder automatically if space is needed. <br>
> **“Dehydrated (placeholder) file”** = the file appears locally but only as a lightweight placeholder with minimal footprint; actual data isnt downloaded until accessed. <br>
> The following image demonstrates how the full pinned, full and placeholder file states are shown in File Explorer:
> ```
> 1. Full Pinned.txt = Always keep on this device
> 2. Full.txt = Available on this device (Hydrated)
> 3. Placeholder.txt = Placeholder (Dehydrated)
>```
> ![Overlay Icons](images/overlay-icons.png)
| ID | Test Case | Steps to reproduce | Expected Result | Result | Related Comment (Squish-test) |
|----|-----------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------|-------------------------------|
| 1 | Add account and connect to server | 1. Create two files with some content inside a folder in the server <br>2. Add an account to the desktop client <br>3. Open local sync folder and check status of all resources (2 files and their parent folder) | All resources (2 files and their parent folder) are virtual (Status: dehydrated) | :construction: Win | |
| 2 | Open a file | 1. Create a file with some content in the server <br>2. Add an account to the desktop client <br>3. Open local sync folder and open that file <br>4. Create a new file locally in the local sync folder, edit, save and exit it | Both files are physically available (Status: hydrated) | :construction: Win | |
| 3 | Free up space | 1. Create several new files in a new folder inside local sync folder <br>2. Right click on local folder, click on `Free up space` | Folder along with files are dehydrated. Icon changed to cloud | :construction: Win | |
| 4 | Always keep | 1. Create a file with content inside a local sync folder <br>2. Right click on that file and click on "Always keep on this device" | File is physically available, same as `Always keep on this device` | :construction: Win | |
| 5 | Locked file | 1. Create a file with content inside a local sync folder <br>Open it in LibreOffice(LO)(Opening a resource in LO makes it locked), edit and save but don't exit <br>2. 'Free up space' in Explorer on the locked file &rarr; no error message, client shows "i" (Status: spinning icon), Activity tells that file is in use <br>3. Exit libreoffice | File is dehydrated correctly (Status: placeholder) after few seconds | :construction: Win | |
| 6 | Move file and directory locally | 1. Create a file and a directory (with some files) inside the local sync root (some hydrated, some dehydrated) <br>2. Move (Cut + Paste) the file and the directory to a different folder inside the sync root | Both hydrated and dehydrated items are moved accordingly on the server (i.e., the same structure appears on the server) | :construction: Win | |
| 7 | Move file and directory outside sync root | 1. Create a folder inside the sync root (which may be hydrated or dehydrated) <br>2. Move this folder to a location outside the sync root | The folder is removed on the server as well and no residual placeholder remains locally | :construction: Win | |
| 8 | Move file/directory on the server | 1. Within the server (via web UI) move a file and a directory (including some dehydrated files) from one location to another | The local sync root reflects the move: both hydrated and dehydrated files show up in the new location and removed from the old location | :construction: Win | |
| 9 | Remove directory while the file is still open locally | 1. Create a directory inside local sync folder; create a file inside and open it in LibreOffice (LO) (file locked) <br>2. On the server, delete/remove the directory <br>3. While LO is still open, edit and save the file <br>4. Close LO | After closing LO, the directory and file are recreated locally (and synced to server) | :construction: Win | |
| 10 | File name clash due to same name (but different case) in folder | 1. On the server: create a folder **FolderA**.<br>2. Inside FolderA create two files with identical names (e.g., **NewFile** and **Newfile**) <br>3. On the client: monitor sync and observe warning message: “file name clash…” appears for some seconds.<br>4. In the local sync folder: open FolderA and check status of both files and the folder.<br>5. Move FolderA from the sync-root to another location outside the sync-root | The client first displays the “file name clash” warning and lists the clash in Activity tab under “Not synced”.<br>The folder FolderA and the file(s) show a placeholder/cloud icon status. <br>After moving FolderA outside the sync-root, the file that had previously been synced will be deleted locally and from the server, and the file that had not synced will now be synced and present inside FolderA both on the server and locally. | :construction: Win | |
+22
View File
@@ -0,0 +1,22 @@
requests==2.32.*; python_version >= "3.10"
PyGObject==3.42.*; python_version >= "3.10" and sys_platform == 'linux'
psutil==5.9.*; python_version >= "3.10"
black==24.3.*; python_version >= "3.10"
pylint==3.2.*; python_version >= "3.10"
pywin32==305; python_version >= "3.10" and sys_platform == 'win32'
pyside6==6.9.*; python_version >= "3.10"
pypdf==6.5.*; python_version >= "3.10"
python-docx==1.2.*; python_version >= "3.10"
python-pptx==1.0.*; python_version >= "3.10"
openpyxl==3.1.*; python_version >= "3.10"
pyperclip==1.11.*; python_version >= "3.10"
playwright==1.58.*; python_version >= "3.10"
behave==1.3.*; python_version >= "3.10"
Appium-Python-Client==5.3.*; python_version >= "3.10"
Flask==3.0.*; python_version >= "3.10" and sys_platform == 'linux'
numpy==1.26.*; python_version >= "3.10" and sys_platform == 'linux'
sure==2.0.*; python_version >= "3.10"
pyautogui==0.9.*; python_version >= "3.10"
behave-html-pretty-formatter==1.16.*; python_version >= "3.10"
mss==9.*; python_version >= "3.10"
imageio-ffmpeg==0.6.*; python_version >= "3.10"
+10
View File
@@ -0,0 +1,10 @@
from behave import register_type
from parse import with_pattern
@with_pattern(r"file|folder")
def resource_type(text):
return text
register_type(ResourceType=resource_type)
+292
View File
@@ -0,0 +1,292 @@
import shutil
import os
from behave import given as Given, when as When, then as Then
from sure import expect, ensure
from pageObjects.AccountConnectionWizard import AccountConnectionWizard
from pageObjects.SyncConnectionWizard import SyncConnectionWizard
from pageObjects.AccountSetting import AccountSetting
from pageObjects.Toolbar import Toolbar
from pageObjects.EnterPassword import EnterPassword
from helpers.SetupClientHelper import (
start_client,
setup_client,
substitute_inline_codes,
get_client_details,
generate_account_config,
get_resource_path,
)
from helpers.SyncHelper import (
wait_for_initial_sync_to_complete,
listen_sync_status_for_item,
)
from helpers.UserHelper import get_displayname_for_user, get_password_for_user
from helpers.ConfigHelper import get_config
from helpers.TableParser import table_rows_hash
from helpers.AppHelper import close_and_kill_app
@Given('the user has started the client')
def step(context):
start_client()
@When('the user adds the following user credentials:')
def step(context):
account_details = get_client_details(context)
set_config('syncConnectionName', get_displayname_for_user(account_details['user']))
AccountConnectionWizard.add_user_credentials(
account_details['user'], account_details['password']
)
@Then('"{username}" account should be added')
def step(context, username):
username = substitute_inline_codes(username)
expect(Toolbar.account_exists(username)).to.be.true
@Then('"{username}" account should not be displayed')
def step(context, username):
username = substitute_inline_codes(username)
expect(Toolbar.account_exists(username)).to.be.false
@Given('user "{username}" has set up a client with default settings')
def step(context, username):
password = get_password_for_user(username)
setup_client(username)
enter_password = EnterPassword()
enter_password.accept_certificate()
enter_password.login_after_setup(username, password)
# wait for files to sync
wait_for_initial_sync_to_complete(get_resource_path('/', username))
Toolbar.wait_toolbar_enabled()
@Given('the user has set up the following accounts with default settings:')
def step(context):
users = []
for row in context.table:
users.append(row[0])
sync_paths = generate_account_config(users)
start_client()
# accept certificate for each user
for idx, _ in enumerate(users):
enter_password = EnterPassword()
enter_password.accept_certificate()
for idx, _ in enumerate(sync_paths.values()):
# login from last dialog
enter_password = EnterPassword()
username = enter_password.get_username()
password = get_password_for_user(username)
listen_sync_status_for_item(sync_paths[username])
enter_password.login_after_setup(username, password)
# wait for files to sync
wait_for_initial_sync_to_complete(sync_paths[username])
Toolbar.wait_toolbar_enabled()
@When('the user starts the client')
def step(context):
start_client()
@When('the user opens the add-account dialog')
def step(context):
Toolbar.open_new_account_setup()
@When('the user adds the following account:')
def step(context):
data = table_rows_hash(context.table)
account_details = get_client_details(data)
AccountConnectionWizard.add_account(account_details)
# # wait for files to sync
wait_for_initial_sync_to_complete(get_resource_path('/', account_details['user']))
Toolbar.wait_toolbar_enabled()
@Given('the user has entered the following account information:')
def step(context):
data = table_rows_hash(context.table)
account_details = get_client_details(data)
AccountConnectionWizard.add_account_information(account_details)
@When('the user "{username}" logs out using the client-UI')
def step(context, username):
AccountSetting.logout()
@Then('user "{username}" should be signed out')
def step(context, username):
user_signed_out = AccountSetting.is_user_signed_out()
with ensure('User "{0}" should be signed out, but is still signed in', username):
user_signed_out.should.be.true
@Given('user "{username}" has logged out from the client-UI')
def step(context, username):
AccountSetting.logout()
if not AccountSetting.is_user_signed_out():
raise LookupError(f'Failed to logout user {username}')
@When('user "{username}" logs in using the client-UI')
def step(context, username):
AccountSetting.login()
password = get_password_for_user(username)
enter_password = EnterPassword()
enter_password.relogin(username, password)
# wait for files to sync
wait_for_initial_sync_to_complete(get_resource_path('/', username))
Toolbar.wait_toolbar_enabled()
@When('user "|any|" opens login dialog')
def step(context, _):
AccountSetting.login()
@Then('user "{username}" should be connected to the server')
def step(context, username):
AccountSetting.wait_until_account_is_connected()
@When('the user removes the connection for user "{username}"')
def step(context, username):
username = substitute_inline_codes(username)
AccountSetting.remove_connection_for_user(username)
@Then('connection wizard should be visible')
def step(context):
test.compare(
AccountConnectionWizard.is_new_connection_window_visible(),
True,
'Connection window is visible',
)
@When('the user accepts the certificate')
def step(context):
AccountConnectionWizard.accept_certificate()
@When('the user adds the server "|any|"')
def step(context, server):
server_url = substitute_inline_codes(server)
AccountConnectionWizard.add_server(server_url)
@When('the user selects manual sync folder option in advanced section')
def step(context):
AccountConnectionWizard.select_manual_sync_folder_option()
AccountConnectionWizard.next_step()
@Then('credentials wizard should be visible')
def step(context):
test.compare(
AccountConnectionWizard.is_credential_window_visible(),
True,
'Credentials wizard is visible',
)
@When('the user selects download everything option in advanced section')
def step(context):
AccountConnectionWizard.select_download_everything_option()
AccountConnectionWizard.next_step()
@When('the user opens the advanced configuration')
def step(context):
AccountConnectionWizard.select_advanced_config()
@Then('the user should be able to choose the local download directory')
def step(context):
test.compare(True, AccountConnectionWizard.can_change_local_sync_dir())
@Then('the download everything option should be selected by default for Linux')
def step(context):
if is_linux():
test.compare(
True,
AccountConnectionWizard.is_sync_everything_option_checked(),
'Sync everything option is checked',
)
@When(r'^the user presses the "([^"]*)" key(?:s)?', regexp=True)
def step(context, key):
AccountSetting.press_key(key)
@Then('the log dialog should be opened')
def step(context):
test.compare(True, AccountSetting.is_log_dialog_visible(), 'Log dialog is opened')
@When('the user cancels the sync connection wizard')
def step(context):
SyncConnectionWizard.cancel_folder_sync_connection_wizard()
@When('the user quits the client')
def step(context):
Toolbar.quit_qsfera()
close_and_kill_app()
@Then('"{username}" account should be opened')
def step(context, username):
username = substitute_inline_codes(username)
expect(Toolbar.account_has_focus(username)).to.be.true
@Then(
r'the default local sync path should contain "([^"]*)" in the (configuration|sync connection) wizard',
regexp=True,
)
def step(context, sync_path, wizard):
sync_path = substitute_inline_codes(sync_path)
actual_sync_path = ''
if wizard == 'configuration':
actual_sync_path = AccountConnectionWizard.get_local_sync_path()
else:
actual_sync_path = SyncConnectionWizard.get_local_sync_path()
test.compare(
actual_sync_path,
convert_path_separators_for_os(sync_path),
'Compare sync path contains the expected path',
)
@Then('the warning "|any|" should appear in the sync connection wizard')
def step(context, warn_message):
actual_message = SyncConnectionWizard.get_warn_label()
test.compare(
True,
warn_message in actual_message,
'Contains warning message',
)
@Given('the user has removed the connection for user "{username}"')
def step(context, username):
username = substitute_inline_codes(username)
AccountSetting.remove_connection_for_user(username)
AccountSetting.wait_until_account_is_removed(username)
shutil.rmtree(os.path.join(get_config("clientRootSyncPath"), username))
+458
View File
@@ -0,0 +1,458 @@
import os
import re
import builtins
import shutil
import zipfile
from os.path import isfile, join, isdir, exists
from behave import when as When, then as Then
from sure import ensure
from helpers.SetupClientHelper import get_resource_path, get_temp_resource_path
from helpers.SyncHelper import (
wait_for_client_to_be_ready,
listen_sync_status_for_item,
wait_for,
)
from helpers.ConfigHelper import get_config, is_windows
from helpers.FilesHelper import (
build_conflicted_regex,
sanitize_path,
can_read,
can_write,
read_file_content,
get_size_in_bytes,
prefix_path_namespace,
remember_path,
convert_path_separators_for_os,
get_file_for_upload,
)
def folder_exists(folder_path, timeout=1000):
return wait_for(
lambda: isdir(sanitize_path(folder_path)),
timeout,
)
def file_exists(file_path, timeout=1000):
return wait_for(
lambda: isfile(sanitize_path(file_path)),
timeout,
)
# To create folders in a temporary directory, we set is_temp_folder True
# And if is_temp_folder is True, the create_folder function create folders in tempFolderPath
def create_folder(foldername, username=None, is_temp_folder=False):
if is_temp_folder:
folder_path = join(get_config('tempFolderPath'), foldername)
else:
folder_path = get_resource_path(foldername, username)
os.makedirs(prefix_path_namespace(convert_path_separators_for_os(folder_path)))
def rename_file_folder(source, destination):
source = get_resource_path(source)
destination = get_resource_path(destination)
os.rename(source, destination)
def create_file_with_size(filename, filesize, is_temp_folder=False):
if is_temp_folder:
file = join(get_config('tempFolderPath'), filename)
else:
file = get_resource_path(filename)
with open(prefix_path_namespace(file), 'wb') as f:
f.seek(get_size_in_bytes(filesize) - 1)
f.write(b'\0')
def write_file(resource, content):
with open(prefix_path_namespace(resource), 'w', encoding='utf-8') as f:
f.write(content)
def wait_and_write_file(path, content):
wait_for_client_to_be_ready()
listen_sync_status_for_item(get_resource_path(path), 'FILE')
write_file(path, content)
def wait_and_try_to_write_file(resource, content):
wait_for_client_to_be_ready()
listen_sync_status_for_item(get_resource_path(resource), 'FILE')
try:
write_file(resource, content)
except:
pass
def create_zip(resources, zip_file_name, cwd=''):
os.chdir(cwd)
with zipfile.ZipFile(zip_file_name, 'w') as zipped_file:
for resource in resources:
zipped_file.write(resource)
def extract_zip(zip_file_path, destination_dir):
with zipfile.ZipFile(zip_file_path, 'r') as zip_file:
zip_file.extractall(destination_dir)
def add_copy_suffix(resource_path, resource_type):
suffix = ' (Copy)'
if resource_type == 'file':
source_dir = resource_path.rsplit('.', 1)
return source_dir[0] + suffix + '.' + source_dir[-1]
return resource_path + suffix
def copy_resource(resource_type, source, destination, from_files_for_upload=False):
if from_files_for_upload:
source = get_file_for_upload(source)
else:
source = get_resource_path(source)
destination = get_resource_path(destination)
if source == destination and destination != '/':
destination = add_copy_suffix(source, resource_type)
wait_for_client_to_be_ready()
listen_sync_status_for_item(destination, resource_type)
if resource_type == 'folder':
return shutil.copytree(source, destination)
return shutil.copy2(source, destination)
def move_resource(username, resource_type, source, destination, is_temp_folder=False):
if not is_temp_folder:
source = get_resource_path(source, username)
if destination == '/':
destination = ''
destination = get_resource_path(destination, username)
wait_for_client_to_be_ready()
listen_sync_status_for_item(destination, resource_type)
shutil.move(source, destination)
def deleteResource(resource, resource_type):
wait_for_client_to_be_ready()
listen_sync_status_for_item(resource, resource_type)
resource_path = sanitize_path(get_resource_path(resource))
if resource_type == 'file':
os.remove(resource_path)
else:
shutil.rmtree(resource_path)
@When(
'user "{username}" creates a file "{filename}" with the following content inside the sync folder'
)
def step(context, username, filename):
file = get_resource_path(filename, username)
wait_and_write_file(convert_path_separators_for_os(file), context.text)
@When('user "{username}" creates a folder "{foldername}" inside the sync folder')
def step(context, username, foldername):
wait_for_client_to_be_ready()
create_folder(foldername, username)
@Given('user "{username}" has created a folder "{foldername}" inside the sync folder')
def step(context, username, foldername):
create_folder(foldername, username)
@When(
'user "{user}" creates a file "{filename}" with size "{filesize}" inside the sync folder'
)
def step(context, user, filename, filesize):
create_file_with_size(filename, filesize)
@When(r'the user copies (file|folder) "([^"]*)" into folder "([^"]*)"', regexp=True)
def step(context, resource_type, resource_name, destination_dir):
copy_resource(resource_type, resource_name, destination_dir, False)
@When(
'the user copies {resource_type:ResourceType} "{resource_name}" into the same directory'
)
def step(context, resource_type, resource_name):
copy_resource(resource_type, resource_name, resource_name, False)
@When('the user renames a file "{source}" to "{destination}"')
@When('the user renames a folder "{source}" to "{destination}"')
def step(context, source, destination):
wait_for_client_to_be_ready()
rename_file_folder(source, destination)
@Then(
'the file "{file_path}" should exist on the file system with the following content'
)
def step(context, file_path):
expected = context.text
file_path = get_resource_path(file_path)
with open(file_path, 'r', encoding='utf-8') as f:
contents = f.read()
with ensure(
'{0} expected to exist with content "{1}" but has content "{2}"',
file_path,
expected,
contents,
):
contents.should.equal(expected)
@Then('the {resource_type:ResourceType} "{resource}" should exist on the file system')
def step(context, resource_type, resource):
resource_path = get_resource_path(resource)
resource_exists = False
timeout = get_config('maxSyncTimeout') * 1000
if resource_type == 'file':
resource_exists = file_exists(resource_path, timeout)
else:
resource_exists = folder_exists(resource_path, timeout)
with ensure(
'{0} "{1}" should exist, but it does not',
resource_type.capitalize(),
resource,
):
resource_exists.should.be.true
@Then(
'the {resource_type:ResourceType} "{resource}" should not exist on the file system'
)
def step(context, resource_type, resource):
resource_path = get_resource_path(resource)
with ensure(
'{0} "{1}" should not exist, but it does',
resource_type.capitalize(),
resource,
):
exists(resource_path).should.be.false
@Given('the user has changed the content of local file "|any|" to:')
def step(context, filename):
file_content = '\n'.join(context.multiLineText)
wait_and_write_file(get_resource_path(filename), file_content)
@Then(
'a conflict file for "|any|" should exist on the file system with the following content'
)
def step(context, filename):
expected = '\n'.join(context.multiLineText)
onlyfiles = [
f for f in os.listdir(get_resource_path()) if isfile(get_resource_path(f))
]
found = False
pattern = re.compile(build_conflicted_regex(filename))
for file in onlyfiles:
if pattern.match(file):
with open(get_resource_path(file), 'r', encoding='utf-8') as f:
if f.read() == expected:
found = True
break
if not found:
raise AssertionError('Conflict file not found with given name')
@When('the user overwrites the file "{resource}" with content "{content}"')
def step(context, resource, content):
resource = get_resource_path(resource)
wait_and_write_file(resource, content)
@When('the user tries to overwrite the file "|any|" with content "|any|"')
def step(context, resource, content):
resource = get_resource_path(resource)
wait_and_try_to_write_file(resource, content)
@When('user "|any|" tries to overwrite the file "|any|" with content "|any|"')
def step(context, user, resource, content):
resource = get_resource_path(resource, user)
wait_and_try_to_write_file(resource, content)
@When('the user deletes the {resource_type:ResourceType} "{resource_name}"')
def step(context, resource_type, resource_name):
deleteResource(resource_name, resource_type)
@When('user "|any|" creates the following files inside the sync folder:')
def step(context, username):
for row in context.table[1:]:
file = get_resource_path(row[0], username)
wait_and_write_file(file, '')
@Given('the user has created a folder "|any|" in temp folder')
def step(context, folder_name):
create_folder(folder_name, is_temp_folder=True)
@Given(
'the user has created "|any|" files each of size "|any|" bytes inside folder "|any|" in temp folder'
)
def step(context, file_number, file_size, folder_name):
current_sync_path = get_temp_resource_path(folder_name)
if folder_exists(current_sync_path):
file_size = builtins.int(file_size)
for i in range(0, builtins.int(file_number)):
file_name = f'file{i}.txt'
create_file_with_size(join(current_sync_path, file_name), file_size, True)
else:
raise FileNotFoundError(
f"Folder '{folder_name}' does not exist in the temp folder"
)
@When(
r'user "([^"]*)" reads the content of file "([^"]*)"',
regexp=True,
)
def step(context, username, file):
file_path = get_resource_path(file, username)
with open(file_path, 'r') as f:
f.read()
@When(
r'user "([^"]*)" moves (folder|file) "([^"]*)" from the temp folder into the sync folder',
regexp=True,
)
def step(context, username, resource_type, resource_name):
source_dir = join(get_config('tempFolderPath'), resource_name)
move_resource(username, resource_type, source_dir, '/', True)
@When(
r'user "([^"]*)" moves (folder|file) "([^"]*)" to the temp folder',
regexp=True,
)
def step(context, username, resource_type, resource_name):
destination = join(get_config('tempFolderPath'), resource_name)
move_resource(username, resource_type, resource_name, destination)
@When(
'user "{username}" moves {resource_type:ResourceType} "{source}" to "{destination}" in the sync folder'
)
def step(context, username, resource_type, source, destination):
move_resource(username, resource_type, source, destination)
@Then('user "{user}" should be able to open the file "{file_name}" on the file system')
def step(context, user, file_name):
file_path = get_resource_path(file_name, user)
with ensure(
'File should be readable but user "{0}" cannot read file "{1}"',
user,
file_name,
):
can_read(file_path).should.be.true
@Then(
'as "{user}" the file "{file_name}" should have content "{content}" on the file system'
)
def step(context, user, file_name, content):
file_path = get_resource_path(file_name, user)
file_content = read_file_content(file_path)
with ensure(
'File "{0}" should have content "{1}" but got "{2}"',
file_name,
content,
file_content,
):
content.should.equal(file_content)
@Then('user "|any|" should not be able to edit the file "|any|" on the file system')
def step(context, user, file_name):
file_path = get_resource_path(file_name, user)
test.compare(not can_write(file_path), True, 'File should not be writable')
@Given(
'the user has created a zip file "|any|" with the following resources in the temp folder'
)
def step(context, zip_file_name):
resource_list = []
for row in context.table[1:]:
resource_list.append(row[0])
resource = join(get_config('tempFolderPath'), row[0])
if row[1] == 'folder':
os.makedirs(resource)
elif row[1] == 'file':
content = ''
if len(row) > 2 and row[2]:
content = row[2]
write_file(resource, content)
create_zip(resource_list, zip_file_name, get_config('tempFolderPath'))
@When('user "|any|" unzips the zip file "|any|" inside the sync root')
def step(context, username, zip_file_name):
destination_dir = get_resource_path('/', username)
zip_file_path = join(destination_dir, zip_file_name)
extract_zip(zip_file_path, destination_dir)
@When('user "|any|" copies file "|any|" to temp folder')
def step(context, username, source):
wait_for_client_to_be_ready()
source_dir = get_resource_path(source, username)
destination_dir = get_temp_resource_path(source)
shutil.copy2(source_dir, destination_dir)
@Given('the user has created folder "|any|" in the default home path')
def step(context, folder_name):
folder_path = join(get_config('home_dir'), folder_name)
os.makedirs(prefix_path_namespace(folder_path))
remember_path(folder_path)
# when account is added, folder with suffix will be created
remember_path(f'{folder_path} (2)')
@Given(
r'the user has copied file "([^"]*)" from outside the sync folder to "([^"]*)" in the sync folder',
regexp=True,
)
def step(context, resource_name, destination):
copy_resource('file', resource_name, destination, True)
@When(
r'the user copies file "([^"]*)" from outside the sync folder to "([^"]*)" in the sync folder',
regexp=True,
)
def step(context, resource_name, destination):
copy_resource('file', resource_name, destination, True)
@When('the user deletes the following files')
def step(context):
wait_for_client_to_be_ready()
for row in context.table[1:]:
filename = row[0]
deleteResource(filename, 'file')
@Given('user "|any|" has created a file "|any|" with size "|any|" in the sync folder')
def step(context, _, filename, filesize):
create_file_with_size(filename, filesize)
+148
View File
@@ -0,0 +1,148 @@
from behave import given as Given, then as Then
from sure import ensure
from helpers.api import provisioning, webdav_helper as webdav
from helpers.TableParser import table_rows_hash
@Given('user "{user}" has been created in the server with default attributes')
def step(context, user):
provisioning.create_user(user)
@Then(
'as "{user_name}" {resource_type:ResourceType} "{resource_name}" should not exist in the server'
)
def step(context, user_name, resource_type, resource_name):
resource_exists = webdav.resource_exists(user_name, resource_name)
with ensure(
'{0} "{1}" should not exist, but it does',
resource_type.capitalize(),
resource_name,
):
resource_exists.should.be.false
@Then(
'as "{user_name}" {resource_type:ResourceType} "{resource_name}" should exist in the server'
)
def step(context, user_name, resource_type, resource_name):
resource_exists = webdav.resource_exists(user_name, resource_name)
with ensure(
'{0} "{1}" should exist, but it does not',
resource_type.capitalize(),
resource_name,
):
resource_exists.should.be.true
@Then(
'as "{user_name}" the file "{file_name}" should have the content "{content}" in the server'
)
def step(context, user_name, file_name, content):
text_content = webdav.get_file_content(user_name, file_name)
with ensure(
'{0} should have content "{1}" but found "{2}"',
file_name,
content,
text_content,
):
text_content.should.equal(content)
@Then(
r'as user "([^"].*)" folder "([^"].*)" should contain "([^"].*)" items in the server',
regexp=True,
)
def step(context, user_name, folder_name, items_number):
total_items = webdav.get_folder_items_count(user_name, folder_name)
test.compare(
total_items, items_number, f'Folder should contain {items_number} items'
)
@Given('user "{user}" has created folder "{folder_name}" in the server')
def step(context, user, folder_name):
webdav.create_folder(user, folder_name)
@Given(
'user "{user}" has uploaded file with content "{file_content}" to "{file_name}" in the server'
)
def step(context, user, file_content, file_name):
webdav.create_file(user, file_name, file_content)
@When('the user clicks on the settings tab')
def step(context):
Toolbar.open_settings_tab()
@When('user "{user}" uploads file with content "{file_content}" to "{file_name}" in the server')
def step(context, user, file_content, file_name):
webdav.create_file(user, file_name, file_content)
@When('user "{user}" deletes the folder "{folder_name}" in the server')
def step(context, user, folder_name):
webdav.delete_resource(user, folder_name)
@Given('user "{user}" has uploaded file "{file_name}" to "{destination}" in the server')
def step(context, user, file_name, destination):
webdav.upload_file(user, file_name, destination)
@Then(
'as "|any|" the content of file "|any|" in the server should match the content of local file "|any|"'
)
def step(context, user_name, server_file_name, local_file_name):
raw_server_content = webdav.get_file_content(user_name, server_file_name)
with tempfile.NamedTemporaryFile(suffix=Path(server_file_name).suffix) as tmp_file:
if isinstance(raw_server_content, str):
tmp_file.write(raw_server_content.encode('utf-8'))
else:
tmp_file.write(raw_server_content)
server_content = get_document_content(tmp_file.name)
local_content = get_document_content(get_file_for_upload(local_file_name))
test.compare(
server_content,
local_content,
f"Server file '{server_file_name}' differs from local file '{local_file_name}'",
)
@Then(
r'as "([^"].*)" following files should not exist in the server',
regexp=True,
)
def step(context, user_name):
for row in context.table[1:]:
resource_name = row[0]
test.compare(
webdav.resource_exists(user_name, resource_name),
False,
f"Resource '{resource_name}' should not exist, but does",
)
@Given('user "|any|" has uploaded the following files to the server')
def step(context, user):
for row in context.table[1:]:
file_name = row[0]
file_content = row[1]
webdav.create_file(user, file_name, file_content)
@Given('user "{user}" has sent the following resource share invitation:')
def step(context, user):
resource_details = table_rows_hash(context.table)
webdav.send_resource_share_invitation(
user,
resource_details['resource'],
resource_details['sharee'],
resource_details['permissionsRole'],
)
+80
View File
@@ -0,0 +1,80 @@
from sure import ensure
from pageObjects.EnterPassword import EnterPassword
from pageObjects.Toolbar import Toolbar
from helpers.UserHelper import get_password_for_user
from helpers.SetupClientHelper import setup_client, get_resource_path
from helpers.SyncHelper import wait_for_initial_sync_to_complete
from helpers.SpaceHelper import (
create_space,
create_space_folder,
create_space_file,
add_user_to_space,
get_file_content,
resource_exists,
)
from helpers.ConfigHelper import get_config, set_config
@Given('the administrator has created a space "{space_name}"')
def step(context, space_name):
create_space(space_name)
@Given('the administrator has created a folder "{folder_name}" in space "{space_name}"')
def step(context, folder_name, space_name):
create_space_folder(space_name, folder_name)
@Given(
'the administrator has uploaded a file "{file_name}" with content "{content}" inside space "{space_name}"'
)
def step(context, file_name, content, space_name):
create_space_file(space_name, file_name, content)
@Given(
'the administrator has added user "{user}" to space "{space_name}" with role "{role}"'
)
def step(context, user, space_name, role):
add_user_to_space(user, space_name, role)
@Given('user "{user}" has set up a client with space "{space_name}"')
def step(context, user, space_name):
set_config('syncConnectionName', space_name)
password = get_password_for_user(user)
setup_client(user, space_name)
enter_password = EnterPassword()
enter_password.accept_certificate()
enter_password.login_after_setup(user, password)
# wait for files to sync
wait_for_initial_sync_to_complete(get_resource_path('/', user, space_name))
Toolbar.wait_toolbar_enabled()
@Then(
'as "{user}" the file "{file_name}" in the space "{space_name}" should have content "{content}" in the server'
)
def step(context, user, file_name, space_name, content):
downloaded_content = get_file_content(space_name, file_name, user)
with ensure(
'File "{0}" in space "{1}" should have content "{2}" but got "{3}"',
file_name,
space_name,
content,
downloaded_content,
):
content.should.equal(downloaded_content)
@Then(
'as "{user}" the space "{space_name}" should have file "{resource_name}" in the server'
)
@Then(
'as "{user}" the space "{space_name}" should have folder "{resource_name}" in the server'
)
def step(context, user, space_name, resource_name):
exists = resource_exists(space_name, resource_name, user)
with ensure('Resource "{0}" should exist but it does not', resource_name):
exists.should.be.true
+377
View File
@@ -0,0 +1,377 @@
from behave import when as When, then as Then
from sure import ensure
from pageObjects.SyncConnectionWizard import SyncConnectionWizard
from pageObjects.Toolbar import Toolbar
from pageObjects.Activity import Activity
from pageObjects.SyncConnection import SyncConnection
from pageObjects.Settings import Settings
from helpers.ConfigHelper import set_config
from helpers.SyncHelper import (
wait_for_resource_to_sync,
wait_for_resource_to_have_sync_error,
)
from helpers.SetupClientHelper import (
get_temp_resource_path,
set_current_user_sync_path,
substitute_inline_codes,
get_resource_path,
)
from helpers.FilesHelper import convert_path_separators_for_os
from helpers.TableParser import table_hashes, table_raw
@Given('the user has paused the file sync')
def step(context):
SyncConnection.pause_sync()
@When('the user resumes the file sync on the client')
def step(context):
SyncConnection.resume_sync()
@When('the user force syncs the files')
def step(context):
SyncConnection.force_sync()
@When('the user waits for the files to sync')
def step(context):
wait_for_resource_to_sync(get_resource_path('/'), force_sync=True)
@When('the user waits for {resource_type:ResourceType} "{resource}" to be synced')
def step(context, resource_type, resource):
resource = get_resource_path(resource)
wait_for_resource_to_sync(convert_path_separators_for_os(resource), resource_type)
Toolbar.wait_toolbar_enabled()
@When(r'the user waits for (file|folder) "([^"]*)" to have sync error', regexp=True)
def step(context, resource_type, resource):
resource = get_resource_path(resource)
wait_for_resource_to_have_sync_error(resource, resource_type)
@When(
r'user "([^"]*)" waits for (file|folder) "([^"]*)" to have sync error', regexp=True
)
def step(context, username, resource_type, resource):
resource = get_resource_path(resource, username)
wait_for_resource_to_have_sync_error(resource, resource_type)
@Then('the "|any|" button should be available')
def step(context, item):
SyncConnection.open_menu()
SyncConnection.has_menu_item(item)
@Then('the "|any|" button should not be available')
def step(context, item):
SyncConnection.open_menu()
test.compare(
SyncConnection.menu_item_exists(item),
False,
f'Menu item "{item}" does not exist.',
)
@When('the user opens the activity tab')
def step(context):
Toolbar.open_activity()
@When('the user opens the settings tab')
def step(context):
Toolbar.open_settings_tab()
@Then('the table of conflict warnings should include file "|any|"')
def step(context, filename):
Activity.check_file_exist(filename)
@Then('the file "|any|" should be blacklisted')
def step(context, filename):
test.compare(
True, Activity.is_resource_blacklisted(filename), 'File is Blacklisted'
)
@Then('the file "|any|" should be ignored')
def step(context, filename):
test.compare(True, Activity.is_resource_ignored(filename), 'File is Ignored')
@Then('the file "|any|" should be excluded')
def step(context, filename):
test.compare(True, Activity.is_resource_excluded(filename), 'File is Excluded')
@When('the user selects "{tab_name}" tab in the activity')
def step(context, tab_name):
Activity.click_tab(tab_name)
@Then('the toolbar should have the following tabs:')
def step(context):
tabs = table_raw(context.table)
for tab_name in tabs:
tab_name = tab_name[0]
with ensure('Tab not found: {0}', tab_name):
Toolbar.has_tab(tab_name).should.be.true
@When('the user selects the following folders to sync:')
def step(context):
folders = []
for row in context.table:
folders.append(row[0])
SyncConnectionWizard.select_folders_to_sync(
folders, new_sync_connection_wizard=True
)
@When('the user sorts the folder list by "|any|"')
def step(context, header_text):
if (header_text := header_text.capitalize()) in ['Size', 'Name']:
SyncConnectionWizard.sort_by(header_text)
else:
raise ValueError("Sorting by '" + header_text + "' is not supported.")
@Then('the sync all checkbox should be checked')
def step(context):
test.compare(
SyncConnectionWizard.is_root_folder_checked(),
True,
'Sync all checkbox is checked',
)
@Then('the folders should be in the following order:')
def step(context):
row_index = 0
for row in context.table[1:]:
expected_folder = row[0]
actual_folder = SyncConnectionWizard.get_item_name_from_row(row_index)
test.compare(actual_folder, expected_folder)
row_index += 1
@When('the user selects "{space_name}" space in sync connection wizard')
def step(context, space_name):
SyncConnectionWizard.select_space(space_name)
SyncConnectionWizard.next_step()
set_config('syncConnectionName', space_name)
@When('the user sets the sync path in sync connection wizard')
def step(context):
SyncConnectionWizard.set_sync_path()
@When(
'the user sets the temp folder "|any|" as local sync path in sync connection wizard'
)
def step(context, folder_name):
sync_path = get_temp_resource_path(folder_name)
SyncConnectionWizard.set_sync_path(sync_path)
set_current_user_sync_path(sync_path)
@When('the user syncs the "{space_name}" space')
def step(context, space_name):
SyncConnectionWizard.sync_space(space_name)
@Then('the settings tab should have the following options in the general section:')
def step(context):
settings = table_raw(context.table)
for setting in settings:
setting = setting[0]
with ensure('General setting not found: {0}', setting):
Settings.has_general_setting(setting).should.be.true
@Then('the settings tab should have the following options in the advanced section:')
def step(context):
settings = table_raw(context.table)
for setting in settings:
setting = setting[0]
with ensure('Advanced setting not found: {0}', setting):
Settings.has_advanced_setting(setting).should.be.true
@Then('the settings tab should have the following options in the network section:')
def step(context):
settings = table_raw(context.table)
for setting in settings:
setting = setting[0]
with ensure('Network setting not found: {0}', setting):
Settings.has_network_setting(setting).should.be.true
@When('the user opens the about dialog')
def step(context):
Settings.open_about_dialog()
@Then('the about dialog should be opened')
def step(context):
with ensure('About dialog is not opened.'):
Settings.has_about_dialog().should.be.true
@When('the user closes the about dialog')
def step(context):
Settings.close_about_dialog()
@When('the user adds the folder sync connection')
def step(context):
SyncConnectionWizard.add_sync_connection()
@When('user unselects all the remote folders')
def step(context):
SyncConnectionWizard.deselect_all_remote_folders()
@Then('for user "{user}" sync folder "{sync_folder}" should not be displayed')
def step(context, user, sync_folder):
Toolbar.open_account(user)
has_sync_connection = SyncConnection.has_sync_connection(sync_folder)
with ensure(
'There should not be "{0}" folder sync connection, but found.', sync_folder
):
has_sync_connection.should.be.false
@When('the user navigates back in the sync connection wizard')
def step(context):
SyncConnectionWizard.back()
@When('the user removes the folder sync connection')
def step(context):
SyncConnection.remove_folder_sync_connection()
SyncConnection.confirm_folder_sync_connection_removal()
@Then('the file "{file_name}" should have status "{status}" in the activity tab')
def step(context, file_name, status):
Activity.has_sync_status(file_name, status)
@When('the user opens the sync connection wizard')
def step(context):
SyncConnectionWizard.open_sync_connection_wizard()
@Then('the button to open sync connection wizard should be disabled')
def step(context):
test.compare(
False,
SyncConnectionWizard.is_add_sync_folder_button_enabled(),
'Button to open sync connection wizard should be disabled',
)
@When('the user checks the activities of account "{account}"')
def step(context, account):
account = substitute_inline_codes(account)
Activity.select_synced_filter(account)
@Then('the following activities should be displayed in synced table')
def step(context):
activities = table_hashes(context.table)
for activity in activities:
activity["account"] = substitute_inline_codes(activity["account"])
has_activity = Activity.has_activity(
activity["resource"], activity["action"], activity["account"]
)
with ensure(
'Activity should exist: {0} | {1} | {2}',
activity["resource"],
activity["action"],
activity["account"],
):
has_activity.should.be.true
@Then('the following activities should not be displayed in synced table')
def step(context):
activities = table_hashes(context.table)
for activity in activities:
activity["account"] = substitute_inline_codes(activity["account"])
has_activity = Activity.has_activity(
activity["resource"], activity["action"], activity["account"]
)
with ensure(
'Activity should not exist: {0} | {1} | {2}',
activity["resource"],
activity["action"],
activity["account"],
):
has_activity.should.be.false
@Then(
r'the following activities (should|should not) be displayed in not synced table',
regexp=True,
)
def step(context, should_or_should_not):
expected = should_or_should_not == "should"
for row in context.table[1:]:
resource = row[0]
status = row[1]
account = substitute_inline_codes(row[2])
test.compare(
Activity.check_not_synced_table(resource, status, account),
expected,
'Resource should be displayed in the not synced table',
)
@When('the user unchecks the "|any|" filter')
def step(context, filter_option):
Activity.select_not_synced_filter(filter_option)
@Then('the following error message should appear in the client')
def step(context):
expected_error_message = '\n'.join(context.multiLineText)
actual_error_message = SyncConnection.get_permission_error_message()
# wait for error message to disappear
SyncConnection.wait_for_error_label(False)
test.compare(
actual_error_message,
expected_error_message,
f'Expected error message: "{expected_error_message}" but got: "{actual_error_message}"',
)
@Given('the user has waited for "|any|" seconds')
def step(context, wait_for):
squish.snooze(float(wait_for))
@When(
'the user unselects the following folders to sync in "Choose what to sync" window:'
)
def step(context):
SyncConnection.choose_what_to_sync()
folders = []
for row in context.table:
folders.append(row[0])
SyncConnectionWizard.unselect_folders_to_sync(
folders, new_sync_connection_wizard=False
)
+21
View File
@@ -0,0 +1,21 @@
from helpers.SetupClientHelper import get_resource_path
from helpers.SyncHelper import perform_file_explorer_vfs_action
from helpers.VFSFileHelper import is_placeholder_resource, is_file_downloaded
@Then('the placeholder file "|any|" should exist on the file system')
def step(context, file_name):
resource_path = get_resource_path(file_name)
test.compare(is_placeholder_resource(resource_path), True, f"File is a placeholder")
@Then('the file "|any|" should be downloaded')
def step(context, file_name):
resource_path = get_resource_path(file_name)
test.compare(is_file_downloaded(resource_path), True, f"File is downloaded")
@When(r'user "([^"]*)" marks (?:file|folder) "([^"]*)" as "(Free up space|Always keep on this device)" from the file explorer', regexp=True)
def step(context, user, resource, action):
resource_path = get_resource_path(resource, user)
perform_file_explorer_vfs_action(resource_path, action)
@@ -0,0 +1,20 @@
{
"server": "https://opencloud:9200",
"theme": "https://opencloud:9200/themes/opencloud/theme.json",
"openIdConnect": {
"metadata_url": "https://opencloud:9200/.well-known/openid-configuration",
"authority": "https://opencloud:9200",
"client_id": "web",
"response_type": "code"
},
"apps": [
"files",
"text-editor",
"preview",
"pdf-viewer",
"search",
"admin-settings",
"ocm",
"app-store"
]
}
@@ -0,0 +1,35 @@
#!/bin/bash
REPORT_PATH="$PUBLIC_BUCKET/desktop/testlogs/$CI_PIPELINE_NUMBER/$MATRIX_NAME/reports"
REPORT_URL="$MC_HOST/$REPORT_PATH"
echo ""
echo "--- GUI Test Reports ---"
echo "Test Report: $REPORT_URL/report.html"
echo "Client Log: $REPORT_URL/qsfera.log"
echo "AT_SPI Driver Log: $REPORT_URL/atspi_webdriver.log"
screenshots=$(mc find s3/$REPORT_PATH/screenshots/ 2>/dev/null || true)
if [[ -n "$screenshots" ]]; then
echo "Screenshots:"
for f in $screenshots; do
# remove 's3/' prefix
f=${f/s3\//}
echo " - $MC_HOST/$f"
done
else
echo "No screenshots found."
fi
recordings=$(mc find s3/$REPORT_PATH/recordings/ 2>/dev/null || true)
if [[ -n "$recordings" ]]; then
echo ""
echo "Recordings:"
for f in $recordings; do
# remove 's3/' prefix
f=${f/s3\//}
echo " - $MC_HOST/$f"
done
else
echo "No recordings found."
fi
@@ -0,0 +1,38 @@
#!/bin/bash
set -e
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/../ && pwd)"
WEBDRIVER_DIR="$TEST_DIR/__webdriver"
mkdir -p "$WEBDRIVER_DIR"
DRIVER_FILE="atspi-webdriver.py"
DRIVER_URL="https://raw.githubusercontent.com/KDE/selenium-webdriver-at-spi"
# shellcheck disable=SC1091
. "$TEST_DIR/.woodpecker.env"
if [ -z "$ATSPI_WEBDRIVER_VERSION" ]; then
ATSPI_WEBDRIVER_VERSION="master"
fi
if [ ! -f "$WEBDRIVER_DIR/$DRIVER_FILE" ]; then
curl -sSL --fail "$DRIVER_URL/$ATSPI_WEBDRIVER_VERSION/selenium-webdriver-at-spi.py" -o "$WEBDRIVER_DIR/$DRIVER_FILE"
fi
if [ ! -f "$WEBDRIVER_DIR/app_roles.py" ]; then
curl -sSL --fail "$DRIVER_URL/$ATSPI_WEBDRIVER_VERSION/app_roles.py" -o "$WEBDRIVER_DIR/app_roles.py"
fi
if [ -z "$WEBDRIVER_HOST" ]; then
WEBDRIVER_HOST="0.0.0.0"
fi
if [ -z "$WEBDRIVER_PORT" ]; then
WEBDRIVER_PORT="4723"
fi
# run webdriver server
export FLASK_ENV=production
export FLASK_APP="$WEBDRIVER_DIR/$DRIVER_FILE"
flask run --host="$WEBDRIVER_HOST" --port="$WEBDRIVER_PORT" --no-reload
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
touch .woodpecker.env
PY_REQUIREMENTS_PATH="test/gui/requirements.txt"
# get playwright version from requirements.txt
get_playwright_version() {
if [[ ! -f "$PY_REQUIREMENTS_PATH" ]]; then
echo "Error: file not found: $PY_REQUIREMENTS_PATH"
fi
playwright_version=$(grep 'playwright==' "$PY_REQUIREMENTS_PATH" | cut -d'=' -f3 | cut -d'.' -f1-2)
playwright_version=${playwright_version//[^0-9.]/}
if [[ -z "$playwright_version" ]]; then
echo "Error: Playwright package not found in requirements.txt" >&2
exit 78
fi
echo "$playwright_version"
}
# Function to check if the cache exists for the given commit ID
check_browsers_cache() {
playwright_version=$(get_playwright_version)
playwright_cache=$(mc find s3/$CACHE_BUCKET/desktop/browsers-cache/$playwright_version/playwright-browsers.tar.gz 2>&1 | grep 'Object does not exist')
if [[ "$playwright_cache" != "" ]]
then
echo "Browsers cache for playwright v$playwright_version not found in cache."
ENV="BROWSER_CACHE_FOUND=false\n"
else
echo "Browsers cache for playwright v$playwright_version found in cache."
ENV="BROWSER_CACHE_FOUND=true\n"
fi
}
get_requirementstxt_hash() {
requirements_sha=$(sha1sum $PY_REQUIREMENTS_PATH | cut -d" " -f1)
echo "$requirements_sha"
}
check_python_cache() {
requirements_sha=$(get_requirementstxt_hash)
python_cache=$(mc find s3/$CACHE_BUCKET/desktop/python-cache/$requirements_sha/python-cache.tar.gz 2>&1 | grep 'Object does not exist')
if [[ "$python_cache" != "" ]]
then
echo "Python cache for '$requirements_sha' hash not found in cache."
ENV="PYTHON_CACHE_FOUND=false\n"
else
echo "Python cache for '$requirements_sha' hash found in cache."
ENV="PYTHON_CACHE_FOUND=true\n"
fi
}
if [[ "$1" == "" ]]; then
echo "Usage: $0 [COMMAND]"
echo "Commands:"
echo -e " get_playwright_version \t get the playwright version from requirements.txt"
echo -e " get_requirementstxt_hash \t get the hash of the current requirements.txt"
echo -e " check_browsers_cache \t check if the browsers cache exists for the given playwright version"
echo -e " check_python_cache \t check if a cache for the current requirements.txt exists"
exit 1
fi
$1
echo -e $ENV >> .woodpecker.env
+3
View File
@@ -0,0 +1,3 @@
[General]
AUT/qsfera = "/woodpecker/desktop/build/bin"
AUTPMTimeout = "15000"