diff --git a/.woodpecker.env b/.woodpecker.env deleted file mode 100644 index 809087a496..0000000000 --- a/.woodpecker.env +++ /dev/null @@ -1,3 +0,0 @@ -# The version of OpenCloud to use in pipelines -OPENCLOUD_COMMITID=0016085933663e30256d0cc521f16844d84a2f71 -OPENCLOUD_BRANCH=main diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index 04cc468973..c2ac8200ae 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -1,6 +1,6 @@ ---- variables: - - &squish_image 'opencloudeu/squish@sha256:6eaecc218044020f49f24fd29b6bdc052e8170699a762687b10398b353e5fcda' + - &alpine_image 'alpine:3.22.4' + - &build_image 'opencloudeu/desktop-client-build:ubuntu-24.04-qt6.10' - &minio_image 'minio/mc:RELEASE.2021-10-07T04-19-58Z' - &minio_environment AWS_ACCESS_KEY_ID: @@ -9,28 +9,21 @@ variables: from_secret: cache_s3_secret_key CACHE_BUCKET: from_secret: cache_s3_bucket - MC_HOST: "https://s3.ci.opencloud.eu" - -steps: - - name: fix-permissions - image: owncloud/ubuntu:20.04 - commands: - - chmod o+w /woodpecker/desktop/ -R - - name: build-client-for-ui-tests - image: *squish_image - commands: - - mkdir -p /woodpecker/desktop/build - - cd /woodpecker/desktop/build - - cmake %s -S .. -GNinja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ - - ninja - - name: upload-desktop-client-cache - commands: - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -a -r /woodpecker/desktop/build/bin s3/$CACHE_BUCKET/desktop-build/${CI_COMMIT_SHA}/ - - mc ls --recursive s3/$CACHE_BUCKET/desktop-build - environment: - <<: *minio_environment - image: *minio_image + MC_HOST: 'https://s3.ci.opencloud.eu' + - &trigger_path + include: + - test/gui/** + - src/** + - cmake/** + - THEME.cmake + - VERSION.cmake + - CMakeLists.txt + - OPENCLOUD.cmake + - .woodpecker/** + exclude: + - .woodpecker/ready-release-go.yaml + - .woodpecker/translation.yaml + - test/gui/manual-test-plan when: - branch: @@ -39,12 +32,40 @@ when: event: - push - manual + path: + <<: *trigger_path - event: pull_request + path: + <<: *trigger_path evaluate: | !(CI_COMMIT_SOURCE_BRANCH matches "next-release/(main|stable-*)" && CI_COMMIT_AUTHOR == "openclouders") - event: tag - event: cron cron: nightly* + workspace: base: /woodpecker/ path: desktop + +steps: + - name: fix-permissions + image: *alpine_image + commands: + - chmod o+w $CI_WORKSPACE/ -R + + - name: build-client-for-ui-tests + image: *build_image + commands: + - mkdir -p build + - cd build + - cmake -GNinja -DBUILD_TESTING=OFF -DCMAKE_BUILD_TYPE=Debug -S .. + - ninja + + - name: upload-desktop-client-cache + image: *minio_image + environment: + <<: *minio_environment + commands: + - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY + - mc cp -a -r $CI_WORKSPACE/build/bin s3/$CACHE_BUCKET/desktop/client-build/${CI_COMMIT_SHA}/ + - mc ls --recursive s3/$CACHE_BUCKET/desktop/client-build diff --git a/.woodpecker/cache-opencloud.yaml b/.woodpecker/cache-opencloud.yaml deleted file mode 100644 index 3f8eafa5e6..0000000000 --- a/.woodpecker/cache-opencloud.yaml +++ /dev/null @@ -1,70 +0,0 @@ ---- -variables: - - &minio_image 'minio/mc:RELEASE.2021-10-07T04-19-58Z' - - &minio_environment - AWS_ACCESS_KEY_ID: - from_secret: cache_s3_access_key - AWS_SECRET_ACCESS_KEY: - from_secret: cache_s3_secret_key - CACHE_BUCKET: - from_secret: cache_s3_bucket - MC_HOST: "https://s3.ci.opencloud.eu" - -skip_clone: true -steps: - - commands: - - curl -o .woodpecker.env https://raw.githubusercontent.com/opencloud-eu/desktop/$CI_COMMIT_SHA/.woodpecker.env - - curl -o script.sh https://raw.githubusercontent.com/opencloud-eu/desktop/$CI_COMMIT_SHA/test/gui/woodpecker/script.sh - - . ./.woodpecker.env - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc ls --recursive s3/$CACHE_BUCKET/opencloud-build - - bash script.sh check_opencloud_cache - environment: - <<: *minio_environment - image: *minio_image - name: check-for-existing-cache - - commands: - - . ./.woodpecker.env - - if $OPENCLOUD_CACHE_FOUND; then exit 0; fi - - git clone -b $OPENCLOUD_BRANCH --single-branch https://github.com/opencloud-eu/opencloud.git repo_opencloud - - cd repo_opencloud - - git checkout $OPENCLOUD_COMMITID - image: docker.io/golang:1.24 - name: clone-opencloud - - commands: - - . ./.woodpecker.env - - if $OPENCLOUD_CACHE_FOUND; then exit 0; fi - - cd repo_opencloud - - for i in $(seq 3); do make node-generate-prod && break || sleep 1; done - image: owncloudci/nodejs:20 - name: generate-opencloud - - commands: - - . ./.woodpecker.env - - if $OPENCLOUD_CACHE_FOUND; then exit 0; fi - - cd repo_opencloud - - for i in $(seq 3); do make -C opencloud build && break || sleep 1; done - image: docker.io/golang:1.24 - name: build-opencloud - - commands: - - . ./.woodpecker.env - - if $OPENCLOUD_CACHE_FOUND; then exit 0; fi - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -a repo_opencloud/opencloud/bin/opencloud s3/$CACHE_BUCKET/opencloud-build/$OPENCLOUD_COMMITID/ - - mc ls --recursive s3/$CACHE_BUCKET/opencloud-build - environment: - <<: *minio_environment - image: *minio_image - name: upload-opencloud-cache -when: - - branch: - - main - - stable-* - event: - - push - - manual - - event: pull_request - evaluate: | - !(CI_COMMIT_SOURCE_BRANCH matches "next-release/(main|stable-*)" && CI_COMMIT_AUTHOR == "openclouders") - - event: tag - - event: cron - cron: nightly* diff --git a/.woodpecker/cache-pnpm.yaml b/.woodpecker/cache-pnpm.yaml deleted file mode 100644 index 48a665c816..0000000000 --- a/.woodpecker/cache-pnpm.yaml +++ /dev/null @@ -1,68 +0,0 @@ ---- -variables: - - &minio_image 'minio/mc:RELEASE.2021-10-07T04-19-58Z' - - &minio_environment - AWS_ACCESS_KEY_ID: - from_secret: cache_s3_access_key - AWS_SECRET_ACCESS_KEY: - from_secret: cache_s3_secret_key - CACHE_BUCKET: - from_secret: cache_s3_bucket - MC_HOST: "https://s3.ci.opencloud.eu" - -steps: - - commands: - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc ls --recursive s3/$CACHE_BUCKET/web - - bash test/gui/woodpecker/script.sh check_browsers_cache - environment: - <<: *minio_environment - image: *minio_image - name: check-browsers-cache - - commands: - - . ./.woodpecker.env - - if $BROWSER_CACHE_FOUND; then exit 0; fi - - cd test/gui/ - - npm i -s -g -f "$(jq -r ".packageManager" < webUI/package.json)" - - pnpm config set store-dir ./.pnpm-store - - make pnpm-install - image: owncloudci/nodejs:20 - name: pnpm-install - - commands: - - . ./.woodpecker.env - - if $BROWSER_CACHE_FOUND; then exit 0; fi - - cd test/gui/ - - make pnpm-install-chromium - - cd webUI - - tar -czvf /woodpecker/desktop/playwright-browsers.tar.gz .playwright - environment: - PLAYWRIGHT_BROWSERS_PATH: .playwright - image: owncloudci/nodejs:20 - name: install-browsers - - commands: - - . ./.woodpecker.env - - if $BROWSER_CACHE_FOUND; then exit 0; fi - - playwright_version=$(bash test/gui/woodpecker/script.sh get_playwright_version) - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -r -a /woodpecker/desktop/playwright-browsers.tar.gz s3/$CACHE_BUCKET/web/browsers-cache/$playwright_version/ - - mc ls --recursive s3/$CACHE_BUCKET/web - environment: - <<: *minio_environment - image: *minio_image - name: upload-browsers-cache -when: - - branch: - - main - - stable-* - event: - - push - - manual - - event: tag - - event: pull_request - evaluate: | - !(CI_COMMIT_SOURCE_BRANCH matches "next-release/(main|stable-*)" && CI_COMMIT_AUTHOR == "openclouders") - - event: cron - cron: nightly* -workspace: - base: /woodpecker/ - path: desktop diff --git a/.woodpecker/cache-python.yaml b/.woodpecker/cache-python.yaml index 9056b3b180..9073e0b5aa 100644 --- a/.woodpecker/cache-python.yaml +++ b/.woodpecker/cache-python.yaml @@ -1,6 +1,5 @@ ---- variables: - - &squish_image 'opencloudeu/squish@sha256:6eaecc218044020f49f24fd29b6bdc052e8170699a762687b10398b353e5fcda' + - &build_image 'opencloudeu/desktop-client-build:ubuntu-24.04-qt6.10' - &minio_image 'minio/mc:RELEASE.2021-10-07T04-19-58Z' - &minio_environment AWS_ACCESS_KEY_ID: @@ -9,38 +8,21 @@ variables: from_secret: cache_s3_secret_key CACHE_BUCKET: from_secret: cache_s3_bucket - MC_HOST: "https://s3.ci.opencloud.eu" - -steps: - - name: check-python-cache - commands: - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc ls s3/$CACHE_BUCKET/desktop-build - - bash test/gui/woodpecker/script.sh check_python_cache - environment: - <<: *minio_environment - image: *minio_image - - name: install-python-modules - commands: - - . ./.woodpecker.env - - if $PYTHON_CACHE_FOUND; then exit 0; fi - - make -C test/gui/ pip-install - - python3.10 -m pip list -v - - requirements_sha=$(sha1sum test/gui/requirements.txt | cut -d" " -f1) - - tar -czvf /woodpecker/desktop/python-cache-$requirements_sha.tar.gz lib/python3.10/site-packages - image: *squish_image - environment: - PYTHONUSERBASE: /woodpecker/desktop - - name: upload-python-cache - commands: - - . ./.woodpecker.env - - if $PYTHON_CACHE_FOUND; then exit 0; fi - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -r -a /woodpecker/desktop/python-cache*.tar.gz s3/$CACHE_BUCKET/desktop-build/ - - mc ls s3/$CACHE_BUCKET/desktop-build - environment: - <<: *minio_environment - image: *minio_image + MC_HOST: 'https://s3.ci.opencloud.eu' + - &trigger_path + include: + - test/gui/** + - src/** + - cmake/** + - THEME.cmake + - VERSION.cmake + - CMakeLists.txt + - OPENCLOUD.cmake + - .woodpecker/** + exclude: + - .woodpecker/ready-release-go.yaml + - .woodpecker/translation.yaml + - test/gui/manual-test-plan when: - branch: @@ -49,12 +31,74 @@ when: event: - push - manual + path: + <<: *trigger_path - event: tag - event: pull_request + path: + <<: *trigger_path evaluate: | !(CI_COMMIT_SOURCE_BRANCH matches "next-release/(main|stable-*)" && CI_COMMIT_AUTHOR == "openclouders") - event: cron cron: nightly* + workspace: base: /woodpecker/ path: desktop + +steps: + - name: check-caches + image: *minio_image + environment: + <<: *minio_environment + commands: + - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY + - bash test/gui/woodpecker/script.sh check_python_cache + - bash test/gui/woodpecker/script.sh check_browsers_cache + + - name: install-python-modules + image: *build_image + commands: + - . ./.woodpecker.env + - if $PYTHON_CACHE_FOUND; then exit 0; fi + - cd test/gui/ + - python3 -m venv .venv --system-site-packages + - . .venv/bin/activate + - make pip-install + - tar -czf python-cache.tar.gz .venv + + - name: install-browsers + image: *build_image + environment: + PLAYWRIGHT_BROWSERS_PATH: /woodpecker/desktop/test/gui/.playwright + commands: + - . ./.woodpecker.env + - if $BROWSER_CACHE_FOUND; then exit 0; fi + - cd test/gui/ + - python3 -m venv .venv --system-site-packages + - . .venv/bin/activate + - grep "^playwright" requirements.txt | pip install -r /dev/stdin + - make install-chromium + - tar -czf playwright-browsers.tar.gz .playwright + + - name: upload-browsers-cache + image: *minio_image + environment: + <<: *minio_environment + commands: + - . ./.woodpecker.env + - if $BROWSER_CACHE_FOUND; then exit 0; fi + - playwright_version=$(bash test/gui/woodpecker/script.sh get_playwright_version) + - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY + - mc cp -r -a $CI_WORKSPACE/test/gui/playwright-browsers.tar.gz s3/$CACHE_BUCKET/desktop/browsers-cache/$playwright_version/ + + - name: upload-python-cache + image: *minio_image + environment: + <<: *minio_environment + commands: + - . ./.woodpecker.env + - if $PYTHON_CACHE_FOUND; then exit 0; fi + - requirements_sha=$(bash test/gui/woodpecker/script.sh get_requirementstxt_hash) + - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY + - mc cp -r -a $CI_WORKSPACE/test/gui/python-cache*.tar.gz s3/$CACHE_BUCKET/desktop/python-cache/$requirements_sha/ diff --git a/.woodpecker/notification.yaml b/.woodpecker/notification.yaml index e660034a38..63ccd29bfc 100644 --- a/.woodpecker/notification.yaml +++ b/.woodpecker/notification.yaml @@ -6,19 +6,29 @@ variables: - &ci_woodpecker_url from_secret: oc_ci_url -depends_on: [build, ui-tests] -runs_on: [ success, failure ] when: - - branch: - - main - - stable-* - event: + - event: - push - manual + branch: + - main + - stable-* + status: + - success + - failure - event: pull_request - - event: tag + status: + - failure - event: cron cron: nightly* + status: + - success + - failure + +depends_on: + - build + - ui-tests + skip_clone: true steps: @@ -41,7 +51,6 @@ steps: CI_WOODPECKER_URL: *ci_woodpecker_url CI_REPO_ID: *current_repo_id CI_WOODPECKER_TOKEN: no-auth-needed-on-this-repo - commands: - git clone --single-branch --branch $QA_REPO_BRANCH $QA_REPO /tmp/qa - cd /tmp/qa/scripts/matrix-notification/ diff --git a/.woodpecker/purge-cache.yaml b/.woodpecker/purge-cache.yaml index a93dd07cda..18e9fc72bb 100644 --- a/.woodpecker/purge-cache.yaml +++ b/.woodpecker/purge-cache.yaml @@ -1,51 +1,64 @@ ---- variables: - - &minio_image "minio/mc:RELEASE.2021-10-07T04-19-58Z" + - &minio_image 'minio/mc:RELEASE.2021-10-07T04-19-58Z' - &minio_environment AWS_ACCESS_KEY_ID: from_secret: cache_s3_access_key AWS_SECRET_ACCESS_KEY: from_secret: cache_s3_secret_key - MC_HOST: "https://s3.ci.opencloud.eu" + MC_HOST: 'https://s3.ci.opencloud.eu' CACHE_BUCKET: from_secret: cache_s3_bucket PUBLIC_BUCKET: public when: - - event: [push, manual] + - event: + - push + - manual branch: ${CI_REPO_DEFAULT_BRANCH} + status: + - success + - failure - event: pull_request evaluate: | !(CI_COMMIT_SOURCE_BRANCH matches "next-release/(main|stable-*)" && CI_COMMIT_AUTHOR == "openclouders") + status: + - success + - failure - event: cron cron: nightly* + status: + - success + - failure + +depends_on: + - ui-tests skip_clone: true matrix: include: - JOB_NAME: purge-desktop-build - PURGE_PATH: desktop-build/ + PURGE_PATH: desktop/client-build/ TTL: 1d + - JOB_NAME: purge-python-cache + PURGE_PATH: desktop/python-cache/ + TTL: 14d - JOB_NAME: purge-browsers-cache - PURGE_PATH: web/browsers-cache/ + PURGE_PATH: desktop/browsers-cache/ TTL: 14d - - JOB_NAME: purge-opencloud - PURGE_PATH: opencloud-build/ - TTL: 1d - JOB_NAME: purge-logs - PURGE_PATH: desktop + PURGE_PATH: desktop/testlogs/ USE_PUBLIC_BUCKET: true TTL: 7d steps: - name: ${JOB_NAME} image: *minio_image + environment: + <<: *minio_environment commands: - mc alias set s3 "$MC_HOST" "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" - if [ "$USE_PUBLIC_BUCKET" = "true" ]; then CACHE_BUCKET=$PUBLIC_BUCKET; fi - to_delete=$(mc find "s3/$CACHE_BUCKET/${PURGE_PATH}" --older-than "${TTL}") - if [ "$to_delete" = "" ]; then exit 0; fi - mc rm $to_delete - environment: - <<: *minio_environment diff --git a/.woodpecker/ready-release-go.yaml b/.woodpecker/ready-release-go.yaml index b4c5abfa13..de39dc491c 100644 --- a/.woodpecker/ready-release-go.yaml +++ b/.woodpecker/ready-release-go.yaml @@ -3,7 +3,7 @@ when: branch: stable-3.0 steps: - release-helper: + - name: release-helper image: woodpeckerci/plugin-ready-release-go:latest settings: git_email: devops@opencloud.eu diff --git a/.woodpecker/translation.yaml b/.woodpecker/translation.yaml index e0e276a4e9..6b44414195 100644 --- a/.woodpecker/translation.yaml +++ b/.woodpecker/translation.yaml @@ -1,15 +1,17 @@ ---- -when: - - event: cron - cron: translation-sync - variables: - &ubuntu_image 'ubuntu' - &git_action_plugin 'quay.io/thegeeklab/wp-git-action:2' +when: + - event: cron + cron: translation-sync + steps: - name: translation-update image: *ubuntu_image + environment: + TX_TOKEN: + from_secret: tx_token commands: - apt update - ln -fs /usr/share/zoneinfo/Europe/Berlin /etc/localtime @@ -23,9 +25,6 @@ steps: - rm -rf /tmp/tx - tx pull --force --all - rm tx LICENSE - environment: - TX_TOKEN: - from_secret: tx_token - name: translation-push image: *git_action_plugin diff --git a/.woodpecker/ui-tests.yaml b/.woodpecker/ui-tests.yaml index f45ad7a1d5..84fa9ea6f4 100644 --- a/.woodpecker/ui-tests.yaml +++ b/.woodpecker/ui-tests.yaml @@ -1,8 +1,8 @@ ---- variables: - - &squish_image 'opencloudeu/squish@sha256:6eaecc218044020f49f24fd29b6bdc052e8170699a762687b10398b353e5fcda' + - &opencloud_image 'quay.io/opencloudeu/opencloud-rolling:latest' + - &build_image 'opencloudeu/desktop-client-build:ubuntu-24.04-qt6.10' - &minio_image 'minio/mc:RELEASE.2021-10-07T04-19-58Z' - - &ubuntu_image 'owncloud/ubuntu:20.04' + - &alpine_image 'alpine:3.22.4' - &minio_environment AWS_ACCESS_KEY_ID: from_secret: cache_s3_access_key @@ -13,59 +13,72 @@ variables: MC_HOST: 'https://s3.ci.opencloud.eu' PUBLIC_BUCKET: public +when: + - branch: + - main + - stable-* + event: + - push + - manual + - event: pull_request + - event: tag + - event: cron + cron: nightly* + depends_on: - - cache-opencloud - - cache-pnpm - build + - cache-python + +workspace: + base: /woodpecker + path: desktop + +matrix: + include: + - MATRIX_NAME: gui-tests-1 + SUITES: 'features/activity features/add-account features/delete-files-folders features/edit-files features/login-logout features/move-files-folders' + - MATRIX_NAME: gui-tests-2 + SUITES: 'features/remove-account-connection features/spaces features/sync-resources features/tabs-settings features/vfs' + steps: - - commands: - - cd test/gui/ - - npm i -s -g -f "$(jq -r ".packageManager" < webUI/package.json)" - - pnpm config set store-dir ./.pnpm-store - - make pnpm-install - image: owncloudci/nodejs:20 - name: pnpm-install - name: restore-python-cache + image: *minio_image + environment: + <<: *minio_environment commands: - requirements_sha=$(sha1sum test/gui/requirements.txt | cut -d" " -f1) - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -a s3/$CACHE_BUCKET/desktop-build/python-cache-$requirements_sha.tar.gz /woodpecker/desktop - environment: - <<: *minio_environment + - mc cp -a s3/$CACHE_BUCKET/desktop/python-cache/$requirements_sha/python-cache.tar.gz $CI_WORKSPACE + + - name: restore-browsers-cache image: *minio_image - - commands: - - tar -xvf python-cache-*.tar.gz -C . - - make -C test/gui/ pip-install - - python3.10 -m pip list -v - image: *squish_image environment: - PYTHONUSERBASE: /woodpecker/desktop/ - name: install-python-modules - - commands: + <<: *minio_environment + commands: - playwright_version=$(bash test/gui/woodpecker/script.sh get_playwright_version) - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -r -a s3/$CACHE_BUCKET/web/browsers-cache/$playwright_version/playwright-browsers.tar.gz /woodpecker/desktop - environment: - <<: *minio_environment - image: *minio_image - name: restore-browsers-cache - - commands: - - tar -xvf /woodpecker/desktop/playwright-browsers.tar.gz -C . + - mc cp -r -a s3/$CACHE_BUCKET/desktop/browsers-cache/$playwright_version/playwright-browsers.tar.gz $CI_WORKSPACE + + - name: unzip-browsers-cache image: owncloud/ubuntu:20.04 - name: unzip-browsers-cache - - commands: - - . ./.woodpecker.env - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -r -a s3/$CACHE_BUCKET/opencloud-build/$OPENCLOUD_COMMITID/opencloud /woodpecker/desktop + commands: + - cd test/gui + - tar -xf $CI_WORKSPACE/playwright-browsers.tar.gz -C ./ + + - name: install-python-modules + image: *build_image environment: - <<: *minio_environment - image: *minio_image - name: restore-opencloud-cache - - commands: - - mkdir -p /srv/app/tmp/opencloud/opencloud/data/ - - mkdir -p /srv/app/tmp/opencloud/storage/users/ - - ./opencloud init - - ./opencloud server + PLAYWRIGHT_BROWSERS_PATH: /woodpecker/desktop/test/gui/.playwright + commands: + - cd test/gui + - tar -xf $CI_WORKSPACE/python-cache.tar.gz -C ./ + - python3 -m venv .venv --system-site-packages + - . .venv/bin/activate + - make install + - python3 -m pip list -v + + - name: opencloud + image: *opencloud_image detach: true environment: FRONTEND_SEARCH_MIN_LENGTH: '2' @@ -80,106 +93,67 @@ steps: OC_URL: https://opencloud:9200 PROXY_ENABLE_BASIC_AUTH: true WEB_UI_CONFIG_FILE: /woodpecker/desktop/test/gui/woodpecker/config-opencloud.json - image: docker.io/golang:1.24 - name: opencloud - - commands: - - timeout 300 bash -c 'while [ $(curl -sk -uadmin:admin https://opencloud:9200/graph/v1.0/users/admin -w %{http_code} -o /dev/null) != 200 ]; do sleep 1; done' - image: owncloudci/alpine:latest - name: wait-for-opencloud - - name: restore-desktop-client commands: - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -a -r s3/$CACHE_BUCKET/desktop-build/${CI_COMMIT_SHA}/ /woodpecker/desktop/build - - ls -lh /woodpecker/desktop/build/bin - environment: - <<: *minio_environment - image: *minio_image + - opencloud init + - opencloud server - - name: create-extra-directories - image: *ubuntu_image + - name: wait-for-opencloud + image: owncloudci/alpine:latest commands: - - mkdir /woodpecker/desktop/test/gui/guiReportUpload/screenshots -p - - mkdir /woodpecker/desktop/test/gui/tmp -p - - mkdir /woodpecker/desktop/test/gui/clientLog -p - - chmod 777 /woodpecker/desktop/test/gui/ -R + - timeout 300 bash -c 'while [ $(curl -sk -uadmin:admin https://opencloud:9200/graph/v1.0/users/admin -w %{http_code} -o /dev/null) != 200 ]; do sleep 1; done' - - name: squish-server-logs - image: *ubuntu_image - detach: true + - name: restore-desktop-client + image: *minio_image + environment: + <<: *minio_environment commands: - - touch /woodpecker/desktop/test/gui/guiReportUpload/serverlog.log - - chmod 777 /woodpecker/desktop/test/gui/guiReportUpload/serverlog.log - - tail -f /woodpecker/desktop/test/gui/guiReportUpload/serverlog.log + - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY + - mc cp -a -r s3/$CACHE_BUCKET/desktop/client-build/${CI_COMMIT_SHA}/ $CI_WORKSPACE/build + - ls -lh $CI_WORKSPACE/build/bin - - name: UI-tests - image: *squish_image + - name: gui-tests + image: *build_image environment: - PYTHONUSERBASE: /woodpecker/desktop/ - TMPDIR: /woodpecker/desktop/test/gui/tmp/ - CLIENT_LOG_DIR: /woodpecker/desktop/test/gui/clientLog/ - PLAYWRIGHT_BROWSERS_PATH: /woodpecker/desktop/.playwright - SQUISH_LICENSE_SERVER: - from_secret: squish_license_key - SQUISH_LICENSE_SERVER_API: squish.jankari.tech:49346 - SQUISH_LICENSE_SERVER_API_TOKEN: phzq4o1tJIVebL1kgSTAeKqZ5AoIDJfci + # system cache environment variables + PYTHONUSERBASE: /woodpecker/desktop/test/gui/.venv + PLAYWRIGHT_BROWSERS_PATH: /woodpecker/desktop/test/gui/.playwright + # webdriver environment variables + WEBDRIVER_HOST: 0.0.0.0 + WEBDRIVER_PORT: 4723 + WEBDRIVER_RUNNER: /woodpecker/desktop/test/gui/woodpecker/run_atspi_webdriver.sh + # test environment variables + APP_PATH: /woodpecker/desktop/build/bin/opencloud BACKEND_HOST: https://opencloud:9200 - SECURE_BACKEND_HOST: https://opencloud:9200 - GUI_TEST_REPORT_DIR: /woodpecker/desktop/test/gui/guiReportUpload - SERVER_INI: /woodpecker/desktop/test/gui/woodpecker/server.ini - SQUISH_PARAMETERS: --testsuite /woodpecker/desktop/test/gui --reportgen html,/woodpecker/desktop/test/gui/guiReportUpload --envvar QT_LOGGING_RULES=sync.httplogger=true;gui.socketapi=false --tags ~@skip --tags ~@skipOnLinux + GUI_TEST_REPORT_DIR: /woodpecker/desktop/test/gui/reports + BEHAVE_TEST_DIR: /woodpecker/desktop/test/gui + # Cannot handle this tags format inside a container: --tags='@smoke and not @skip' + BEHAVE_PARAMETERS: '--tags=~@skip ${SUITES}' - - name: crash-log - image: *ubuntu_image - when: - - status: [success, failure] - commands: - - cat /woodpecker/desktop/test/gui/tmp/OpenCloud-crash.log 2>/dev/null || exit 0 + # - name: crash-log + # image: *alpine_image + # when: + # - status: + # - success + # - failure + # commands: + # - cat test/gui/tmp/OpenCloud-crash.log 2>/dev/null || exit 0 - name: upload-test-reports - environment: - <<: *minio_environment image: *minio_image when: - - status: [failure] + - status: failure + environment: + <<: *minio_environment commands: - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - mc cp -a -r /woodpecker/desktop/test/gui/clientLog/* s3/$PUBLIC_BUCKET/desktop/$CI_PIPELINE_NUMBER/logs/ - - mc cp -a -r /woodpecker/desktop/test/gui/guiReportUpload s3/$PUBLIC_BUCKET/desktop/$CI_PIPELINE_NUMBER + - mc cp -a -r $CI_WORKSPACE/test/gui/reports s3/$PUBLIC_BUCKET/desktop/testlogs/$CI_PIPELINE_NUMBER/$MATRIX_NAME - name: gui-test-reports - environment: - <<: *minio_environment image: *minio_image when: - - status: [failure] - commands: - - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - bash test/gui/woodpecker/gui_test_reports.sh - - - name: client-log + - status: failure environment: <<: *minio_environment - image: *minio_image - when: - - status: [failure] commands: - mc alias set s3 $MC_HOST $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY - - cd /woodpecker/desktop/test/gui/clientLog/ - - echo "To download the logs, access the following links:" - - logs=$(mc find s3/$PUBLIC_BUCKET/desktop/$CI_PIPELINE_NUMBER/logs/) - - "for f in $logs; do echo \"$MC_HOST/$f \n \" | cut -d '/' -f1-3,5-99; done" - -when: - - branch: - - main - - stable-* - event: - - push - - manual - - event: pull_request - - event: tag - - event: cron - cron: nightly* -workspace: - base: /woodpecker - path: desktop + - bash $CI_WORKSPACE/test/gui/woodpecker/gui_test_reports.sh diff --git a/test/gui/.gitignore b/test/gui/.gitignore index 7648ceb913..1dc267c1e5 100644 --- a/test/gui/.gitignore +++ b/test/gui/.gitignore @@ -1,2 +1,5 @@ -shared/scripts/custom_lib +custom_lib/ reports +venv/ +__webdriver/ +reports/ \ No newline at end of file diff --git a/test/gui/.woodpecker.env b/test/gui/.woodpecker.env new file mode 100644 index 0000000000..aa7d7ed52d --- /dev/null +++ b/test/gui/.woodpecker.env @@ -0,0 +1 @@ +ATSPI_WEBDRIVER_VERSION="6682aeb734730c50949f0fda69827b6c5b50dbc7" \ No newline at end of file diff --git a/test/gui/Makefile b/test/gui/Makefile index 97435514ad..6c00d87e67 100644 --- a/test/gui/Makefile +++ b/test/gui/Makefile @@ -1,29 +1,25 @@ PYTHON_LINT_PATHS:="./**/*.py" .PHONY: install -install: pnpm-install pnpm-install-chromium pip-install +install: pip-install install-chromium -.PHONY: pnpm-install -pnpm-install: - cd webUI && pnpm install - -.PHONY: pnpm-install-chromium -pnpm-install-chromium: - cd webUI && pnpm exec playwright install chromium +.PHONY: install-chromium +install-chromium: + playwright install chromium .PHONY: pip-install pip-install: - python3.10 -m pip install -r requirements.txt + python3 -m pip install -r requirements.txt .PHONY: python-lint python-lint: black --check --diff . - python3.10 -m pylint --rcfile ./.pylintrc $(PYTHON_LINT_PATHS) + python3 -m pylint --rcfile ./.pylintrc $(PYTHON_LINT_PATHS) .PHONY: python-lint-fix python-lint-fix: black . - python3.10 -m pylint --rcfile ./.pylintrc $(PYTHON_LINT_PATHS) + python3 -m pylint --rcfile ./.pylintrc $(PYTHON_LINT_PATHS) .PHONY: gherkin-lint gherkin-lint: diff --git a/test/gui/behave.ini b/test/gui/behave.ini new file mode 100644 index 0000000000..7c8819720c --- /dev/null +++ b/test/gui/behave.ini @@ -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 diff --git a/test/gui/config.sample.ini b/test/gui/config.sample.ini index 3d970bd9d0..504e88001d 100644 --- a/test/gui/config.sample.ini +++ b/test/gui/config.sample.ini @@ -1,11 +1,8 @@ [DEFAULT] +APP_PATH= BACKEND_HOST= CLIENT_ROOT_SYNC_PATH= -MAX_SYNC_TIMEOUT= -MIN_SYNC_TIMEOUT= -LOWEST_SYNC_TIMEOUT= -CLIENT_LOG_FILE= -CLIENT_LOG_DIR= +SYNC_TIMEOUT= TEMP_FOLDER_PATH= GUI_TEST_REPORT_DIR= RECORD_VIDEO_ON_FAILURE=false diff --git a/test/gui/environment.py b/test/gui/environment.py new file mode 100644 index 0000000000..d1b936be1e --- /dev/null +++ b/test/gui/environment.py @@ -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() diff --git a/test/gui/envs.txt b/test/gui/envs.txt deleted file mode 100644 index 1ba398efa8..0000000000 --- a/test/gui/envs.txt +++ /dev/null @@ -1 +0,0 @@ -XDG_CONFIG_HOME=/tmp/opencloudtest/.config diff --git a/test/gui/tst_activity/test.feature b/test/gui/features/activity/activity.feature similarity index 83% rename from test/gui/tst_activity/test.feature rename to test/gui/features/activity/activity.feature index 97e8a0d07d..233797de12 100644 --- a/test/gui/tst_activity/test.feature +++ b/test/gui/features/activity/activity.feature @@ -3,20 +3,25 @@ Feature: filter activity for 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 clicks on the activity tab + 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) @@ -27,7 +32,7 @@ Feature: filter activity for user | files | | /.htaccess | | /Folder1/a\\a.txt | - And the user clicks on the activity tab + 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 @@ -36,14 +41,15 @@ Feature: filter activity for user | resource | status | account | | Folder1/a\\a.txt | Blacklisted | Alice Hansen@%local_server_hostname% | - @skipOnLinux + + @skipOnLinux @skip 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 clicks on the activity tab + 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 diff --git a/test/gui/tst_addAccount/test.feature b/test/gui/features/add-account/account.feature similarity index 89% rename from test/gui/tst_addAccount/test.feature rename to test/gui/features/add-account/account.feature index 13d9a6e56a..5294cfab5b 100644 --- a/test/gui/tst_addAccount/test.feature +++ b/test/gui/features/add-account/account.feature @@ -6,7 +6,7 @@ Feature: adding accounts Background: Given user "Alice" has been created in the server with default attributes - + @skip Scenario: Check default options in advanced configuration Given the user has started the client And the user has entered the following account information: @@ -17,16 +17,16 @@ Feature: adding accounts 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 the account with displayname "Alice Hansen" should be displayed - + 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 @@ -35,9 +35,8 @@ Feature: adding accounts | server | %local_server% | | user | Brian | | password | AaBb2Cc3Dd4 | - Then "Brian Murphy" account should be opened - And the account with displayname "Alice Hansen" should be displayed - And the account with displayname "Brian Murphy" should be displayed + Then "Brian" account should be opened + And "Alice" account should be added Scenario: Adding account with self signed certificate for the first time @@ -48,9 +47,9 @@ Feature: adding accounts When the user adds the following account: | user | Alice | | password | 1234 | - Then "Alice Hansen" account should be opened - + 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 @@ -62,7 +61,7 @@ Feature: adding accounts And the user syncs the "Personal" space Then the folder "simple-folder" should exist on the file system - + @skip Scenario: Check for suffix when sync path exists Given the user has created folder "OpenCloud" in the default home path And the user has started the client @@ -76,7 +75,7 @@ Feature: adding accounts When the user selects download everything option in advanced section Then the button to open sync connection wizard should be disabled - @skipOnWindows @issue-435 + @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 @@ -87,6 +86,6 @@ Feature: adding accounts | server | %local_server% | | user | Alice | | password | 1234 | - Then the account with displayname "Alice Hansen" should be displayed + 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 diff --git a/test/gui/tst_deleteFilesFolders/test.feature b/test/gui/features/delete-files-folders/delete.feature similarity index 82% rename from test/gui/tst_deleteFilesFolders/test.feature rename to test/gui/features/delete-files-folders/delete.feature index 1924261f4c..79cb1e3aa7 100644 --- a/test/gui/tst_deleteFilesFolders/test.feature +++ b/test/gui/features/delete-files-folders/delete.feature @@ -6,32 +6,32 @@ Feature: deleting files and folders Background: Given user "Alice" has been created in the server with default attributes - @issue-9439 + @issue-9439 @smoke Scenario Outline: Delete a file Given user "Alice" has uploaded file with content "openCloud test text file 0" to "" in the server And user "Alice" has set up a client with default settings When the user deletes the file "" - And the user waits for the files to sync + And the user waits for file "" to be synced Then as "Alice" file "" should not exist in the server Examples: | fileName | | textfile0.txt | | textfile0-with-name-more-than-20-characters | - | ~`!@#$^&()-_=+{[}];',textfile.txt | + | ~`!@#$^&()-_=+{[}];',textfile.txt | - @issue-9439 + @issue-9439 @smoke Scenario Outline: Delete a folder Given user "Alice" has created folder "" in the server And user "Alice" has set up a client with default settings When the user deletes the folder "" - And the user waits for the files to sync + And the user waits for folder "" to be synced Then as "Alice" file "" 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 @@ -65,13 +65,16 @@ Feature: deleting files and folders | textfile1.txt | And as "Alice" file "textfile2.txt" should exist in the server - @issue-435 - Scenario: Create and delete a file with special characters in the filename + + 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 "~`!@#$^&()-_=+{[}];',$%ñ&💥🫨❤️‍🔥.txt" with the following content inside the sync folder + When user "Alice" creates a file "" with the following content inside the sync folder """ special characters """ - And the user deletes the file "~`!@#$^&()-_=+{[}];',$%ñ&💥🫨❤️‍🔥.txt" + And the user deletes the file "" And the user waits for the files to sync - Then as "Alice" file "~`!@#$^&()-_=+{[}];',$%ñ&💥🫨❤️‍🔥.txt" should not exist in the server + Then as "Alice" file "" should not exist in the server + Examples: + | fileName | + | ~`!@#$^&()-_=+{[}];',$%ñ&💥🫨❤️‍🔥.txt | diff --git a/test/gui/tst_editFiles/test.feature b/test/gui/features/edit-files/edit.feature similarity index 54% rename from test/gui/tst_editFiles/test.feature rename to test/gui/features/edit-files/edit.feature index a735cbc97f..cba497ac23 100644 --- a/test/gui/tst_editFiles/test.feature +++ b/test/gui/features/edit-files/edit.feature @@ -6,7 +6,7 @@ Feature: edit files 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 @@ -14,8 +14,8 @@ Feature: edit files 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 - - Scenario: Modify original content of a file + @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 """ @@ -27,18 +27,16 @@ Feature: edit files 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 "" from outside the sync folder to "/" in the sync folder - When the user copies file "" from outside the sync folder to "/" in the sync folder - And the user waits for file "" to be synced - And the user copies file "" from outside the sync folder to "/" in the sync folder - And the user waits for file "" to be synced - And the user copies file "" from outside the sync folder to "/" in the sync folder - And the user waits for file "" to be synced - Then as "Alice" the content of file "" in the server should match the content of local file "" - 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 | + 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 "" from outside the sync folder to "/" in the sync folder + When the user copies file "" from outside the sync folder to "/" in the sync folder + And the user copies file "" from outside the sync folder to "/" in the sync folder + And the user copies file "" from outside the sync folder to "/" in the sync folder + And the user waits for the files to sync + Then as "Alice" the content of file "" in the server should match the content of local file "" + 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 | diff --git a/test/gui/tst_loginLogout/test.feature b/test/gui/features/login-logout/login-logout.feature similarity index 97% rename from test/gui/tst_loginLogout/test.feature rename to test/gui/features/login-logout/login-logout.feature index 2f694b32ad..1b08bd854f 100644 --- a/test/gui/tst_loginLogout/test.feature +++ b/test/gui/features/login-logout/login-logout.feature @@ -6,13 +6,13 @@ Feature: Logout users 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 diff --git a/test/gui/tst_moveFilesFolders/test.feature b/test/gui/features/move-files-folders/moveFilesFolders.feature similarity index 93% rename from test/gui/tst_moveFilesFolders/test.feature rename to test/gui/features/move-files-folders/moveFilesFolders.feature index 5ec2a8aece..4d24d3a651 100644 --- a/test/gui/tst_moveFilesFolders/test.feature +++ b/test/gui/features/move-files-folders/moveFilesFolders.feature @@ -41,7 +41,7 @@ Feature: move file and folder 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 @@ -53,7 +53,7 @@ Feature: move file and folder 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 @@ -67,7 +67,7 @@ Feature: move file and folder And as "Alice" file "folder1/file2.txt" should not exist in the server - @issue-435 + 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 @@ -80,11 +80,11 @@ Feature: move file and folder 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 - @issue-435 + 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 "Folder1" 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 "Folder1" in the sync folder - And the user waits for file "Folder1/newfile.txt" to be synced - Then as "Alice" file "Folder1/newfile.txt" should exist in the server + And user "Alice" has created a folder "NewFolder" inside the sync folder + And the user 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 diff --git a/test/gui/tst_removeAccountConnection/test.feature b/test/gui/features/remove-account-connection/removeAccountConnection.feature similarity index 88% rename from test/gui/tst_removeAccountConnection/test.feature rename to test/gui/features/remove-account-connection/removeAccountConnection.feature index 45e0af9676..3003bf68f3 100644 --- a/test/gui/tst_removeAccountConnection/test.feature +++ b/test/gui/features/remove-account-connection/removeAccountConnection.feature @@ -3,16 +3,17 @@ Feature: remove account connection 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 the account with displayname "Brian Murphy" should not be displayed - But the account with displayname "Alice Hansen" should be displayed + Then "Brian" account should not be displayed + But "Alice" account should be added Scenario: remove the only account connection diff --git a/test/gui/tst_spaces/test.feature b/test/gui/features/spaces/spaces.feature similarity index 94% rename from test/gui/tst_spaces/test.feature rename to test/gui/features/spaces/spaces.feature index 29f2d58ab0..b28c4e7782 100644 --- a/test/gui/tst_spaces/test.feature +++ b/test/gui/features/spaces/spaces.feature @@ -7,7 +7,7 @@ Feature: Project spaces 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" @@ -16,7 +16,7 @@ Feature: Project spaces 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 - + @skip 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" @@ -25,7 +25,7 @@ Feature: Project spaces 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" @@ -35,7 +35,7 @@ Feature: Project spaces 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" @@ -44,11 +44,12 @@ Feature: Project spaces test content """ And user "Alice" creates a folder "localFolder" inside the sync folder - And the user waits for the files to sync + 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" @@ -58,16 +59,16 @@ Feature: Project spaces 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 the sync folder list should be empty + Then for user "Alice" sync folder "Project101" should not be displayed But the file "testfile.txt" should exist on the file system - @issue-435 + @skip 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" @@ -76,13 +77,13 @@ Feature: Project spaces """ simple-folder: Not allowed because you don't have permission to add subfolders to that folder """ - When the user clicks on the activity tab + 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% | - @issue-435 + @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 diff --git a/test/gui/tst_syncing/test.feature b/test/gui/features/sync-resources/syncResources.feature similarity index 91% rename from test/gui/tst_syncing/test.feature rename to test/gui/features/sync-resources/syncResources.feature index ea4251592e..8316037850 100644 --- a/test/gui/tst_syncing/test.feature +++ b/test/gui/features/sync-resources/syncResources.feature @@ -6,7 +6,7 @@ Feature: Syncing files Background: Given user "Alice" has been created in the server with default attributes - @smokeTest @issue-9281 + @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 @@ -14,12 +14,12 @@ Feature: Syncing files test content """ And the user waits for file "lorem-for-upload.txt" to be synced - And the user clicks on the activity tab + 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 @@ -33,7 +33,7 @@ Feature: Syncing files And the folder "simple-folder" should exist on the file system And the folder "large-folder" should exist on the file system - @issue-9733 + @issue-9733 @skip 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 @@ -45,7 +45,7 @@ Feature: Syncing files 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 clicks on the activity tab + 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 @@ -57,6 +57,7 @@ Feature: Syncing files client content """ + @skipOnWindows @skip 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 @@ -81,6 +82,7 @@ Feature: Syncing files 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 @@ -107,11 +109,8 @@ Feature: Syncing files 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 - When the user deletes the folder "simple-folder" - And the user waits for the files to sync - Then as "Alice" folder "simple-folder" should not exist in the server - @issue-9733 + @issue-9733 @skipOnWindows @skip 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 @@ -155,7 +154,7 @@ Feature: Syncing files | 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 inside the sync folder @@ -166,7 +165,7 @@ Feature: Syncing files | "myFolder" | | "really long folder name with some spaces and special char such as $%ñ&" | - @skipOnWindows + @skipOnWindows @skip 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 inside the sync folder @@ -176,18 +175,18 @@ Feature: Syncing files | foldername | | "folder with space at end " | - @skipOnLinux + @skipOnLinux @skip 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 clicks on the activity tab + 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 @@ -221,7 +220,7 @@ Feature: Syncing files """ test content """ - And the user waits for the files to sync + 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 @@ -233,7 +232,7 @@ Feature: Syncing files 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 @@ -241,14 +240,14 @@ Feature: Syncing files """ test content """ - And the user copies the folder "original" to "copied" - And the user waits for folder "copied" to be synced + 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 "copied" should exist in the server - And as "Alice" the file "copied/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 + @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 @@ -257,7 +256,7 @@ Feature: Syncing files 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 @@ -267,7 +266,7 @@ Feature: Syncing files And as "Alice" folder "Folder1/subFolder1" should exist in the server And as "Alice" folder "Folder1/subFolder1/subFolder2" should exist in the server - @skipOnWindows + @skipOnWindows @skip 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 @@ -275,12 +274,12 @@ Feature: Syncing files """ test content """ - And the user clicks on the activity tab + 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 - + @skip Scenario Outline: Sync long nested folder Given user "Alice" has created folder "" in the server And user "Alice" has set up a client with default settings @@ -297,7 +296,7 @@ Feature: Syncing files | foldername | | An empty folder which name is obviously more than 59 characters | - @skipOnWindows + @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 @@ -313,7 +312,7 @@ Feature: Syncing files And as "Alice" file "/PRN" should exist in the server And as "Alice" file "/foo%" should exist in the server - @skipOnLinux + @skipOnLinux @skip 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 @@ -325,7 +324,7 @@ Feature: Syncing files 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 @@ -349,7 +348,7 @@ Feature: Syncing files 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 - + @skip 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: @@ -374,7 +373,7 @@ Feature: Syncing files 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 "" with the following content inside the sync folder @@ -387,14 +386,14 @@ Feature: Syncing files | 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 - + @skip 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 @@ -404,7 +403,7 @@ Feature: Syncing files 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 - @issue-435 + @skip 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 @@ -426,7 +425,7 @@ Feature: Syncing files 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: @@ -435,10 +434,11 @@ Feature: Syncing files | password | 1234 | When the user selects manual sync folder option in advanced section And the user cancels the sync connection wizard - Then the account with displayname "Alice Hansen" should be displayed - And the sync folder list should be empty - + 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 + @skip 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 | @@ -456,6 +456,7 @@ Feature: Syncing files And as "Alice" the file "file2.txt" should have the content "Test file2" in the server + @skipOnWindows @skip 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 @@ -486,23 +487,23 @@ Feature: Syncing files 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 - + @skip 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 + @issue-11814 @skip 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 the sync folder list should be empty + 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 - + @skip 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 @@ -526,14 +527,14 @@ Feature: Syncing files """ 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 clicks on the activity tab + 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% | - @skipOnWindows @issue-435 + @skip 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 "" with the following content inside the sync folder @@ -546,7 +547,7 @@ Feature: Syncing files | filename | | 𒁰𒁱𒁲𒁳𒁴𒁵𒁶𒁷𒁸𒁹𒁺𒁻𒁼𒁾𒁿𒁰𒁱𒁲𒁳𒁴𒁵𒁶𒁷𒁸𒁹𒁺𒁻𒁼𒁾𒁿𒁰𒁱𒁲𒁳𒁴𒁵𒁶𒁷𒁸𒁹𒁺abôǣฎพฒฆ๘ตกกผพฒณญไใๅำ๊๒๔๗๘รศฬอฮ.txt | - @issue-435 + @skip 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 @@ -560,13 +561,14 @@ Feature: Syncing files 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 - @issue-435 + @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 @@ -575,8 +577,34 @@ Feature: Syncing files 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 - But the folder "test-folder/sub-folder2" should not 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 @skip + 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 + diff --git a/test/gui/tst_checkAlltabs/test.feature b/test/gui/features/tabs-settings/tabsSettings.feature similarity index 93% rename from test/gui/tst_checkAlltabs/test.feature rename to test/gui/features/tabs-settings/tabsSettings.feature index e5c6e5f6a7..378ec8947c 100644 --- a/test/gui/tst_checkAlltabs/test.feature +++ b/test/gui/features/tabs-settings/tabsSettings.feature @@ -3,7 +3,7 @@ Feature: Visually check all tabs 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 @@ -13,11 +13,11 @@ Feature: Visually check all tabs | 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 clicks on the settings tab + 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: @@ -25,7 +25,6 @@ Feature: Visually check all tabs | Edit ignored files | | Log settings | And the settings tab should have the following options in the network section: - | Proxy Settings | | Download Bandwidth | | Upload Bandwidth | When the user opens the about dialog diff --git a/test/gui/features/vfs/vfs.feature b/test/gui/features/vfs/vfs.feature new file mode 100644 index 0000000000..a7c078383a --- /dev/null +++ b/test/gui/features/vfs/vfs.feature @@ -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 + + @skip + 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 + + @skip + 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 + + @skip + 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 + + @skip + 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 + + @skip + 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 diff --git a/test/gui/shared/files-for-upload/simple.docx b/test/gui/files-for-upload/simple.docx similarity index 100% rename from test/gui/shared/files-for-upload/simple.docx rename to test/gui/files-for-upload/simple.docx diff --git a/test/gui/shared/files-for-upload/simple.pdf b/test/gui/files-for-upload/simple.pdf similarity index 100% rename from test/gui/shared/files-for-upload/simple.pdf rename to test/gui/files-for-upload/simple.pdf diff --git a/test/gui/shared/files-for-upload/simple.pptx b/test/gui/files-for-upload/simple.pptx similarity index 100% rename from test/gui/shared/files-for-upload/simple.pptx rename to test/gui/files-for-upload/simple.pptx diff --git a/test/gui/shared/files-for-upload/simple.xlsx b/test/gui/files-for-upload/simple.xlsx similarity index 100% rename from test/gui/shared/files-for-upload/simple.xlsx rename to test/gui/files-for-upload/simple.xlsx diff --git a/test/gui/shared/files-for-upload/simple1.docx b/test/gui/files-for-upload/simple1.docx similarity index 100% rename from test/gui/shared/files-for-upload/simple1.docx rename to test/gui/files-for-upload/simple1.docx diff --git a/test/gui/shared/files-for-upload/simple1.pdf b/test/gui/files-for-upload/simple1.pdf similarity index 100% rename from test/gui/shared/files-for-upload/simple1.pdf rename to test/gui/files-for-upload/simple1.pdf diff --git a/test/gui/shared/files-for-upload/simple1.xlsx b/test/gui/files-for-upload/simple1.xlsx similarity index 100% rename from test/gui/shared/files-for-upload/simple1.xlsx rename to test/gui/files-for-upload/simple1.xlsx diff --git a/test/gui/shared/files-for-upload/simple2.docx b/test/gui/files-for-upload/simple2.docx similarity index 100% rename from test/gui/shared/files-for-upload/simple2.docx rename to test/gui/files-for-upload/simple2.docx diff --git a/test/gui/shared/files-for-upload/simple2.pdf b/test/gui/files-for-upload/simple2.pdf similarity index 100% rename from test/gui/shared/files-for-upload/simple2.pdf rename to test/gui/files-for-upload/simple2.pdf diff --git a/test/gui/shared/files-for-upload/simple2.xlsx b/test/gui/files-for-upload/simple2.xlsx similarity index 100% rename from test/gui/shared/files-for-upload/simple2.xlsx rename to test/gui/files-for-upload/simple2.xlsx diff --git a/test/gui/shared/files-for-upload/simple3.docx b/test/gui/files-for-upload/simple3.docx similarity index 100% rename from test/gui/shared/files-for-upload/simple3.docx rename to test/gui/files-for-upload/simple3.docx diff --git a/test/gui/shared/files-for-upload/simple3.pdf b/test/gui/files-for-upload/simple3.pdf similarity index 100% rename from test/gui/shared/files-for-upload/simple3.pdf rename to test/gui/files-for-upload/simple3.pdf diff --git a/test/gui/shared/files-for-upload/simple3.xlsx b/test/gui/files-for-upload/simple3.xlsx similarity index 100% rename from test/gui/shared/files-for-upload/simple3.xlsx rename to test/gui/files-for-upload/simple3.xlsx diff --git a/test/gui/shared/files-for-upload/test_video.mp4 b/test/gui/files-for-upload/test_video.mp4 similarity index 100% rename from test/gui/shared/files-for-upload/test_video.mp4 rename to test/gui/files-for-upload/test_video.mp4 diff --git a/test/gui/shared/files-for-upload/testavatar.jpeg b/test/gui/files-for-upload/testavatar.jpeg similarity index 100% rename from test/gui/shared/files-for-upload/testavatar.jpeg rename to test/gui/files-for-upload/testavatar.jpeg diff --git a/test/gui/shared/files-for-upload/testavatar.jpg b/test/gui/files-for-upload/testavatar.jpg similarity index 100% rename from test/gui/shared/files-for-upload/testavatar.jpg rename to test/gui/files-for-upload/testavatar.jpg diff --git a/test/gui/shared/files-for-upload/testavatar.png b/test/gui/files-for-upload/testavatar.png similarity index 100% rename from test/gui/shared/files-for-upload/testavatar.png rename to test/gui/files-for-upload/testavatar.png diff --git a/test/gui/shared/files-for-upload/testimage.mp3 b/test/gui/files-for-upload/testimage.mp3 similarity index 100% rename from test/gui/shared/files-for-upload/testimage.mp3 rename to test/gui/files-for-upload/testimage.mp3 diff --git a/test/gui/helpers/AppHelper.py b/test/gui/helpers/AppHelper.py new file mode 100644 index 0000000000..821ad4062e --- /dev/null +++ b/test/gui/helpers/AppHelper.py @@ -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,'OpenCloud Desktop')]") + .location + ) + return window['x'], window['y'] diff --git a/test/gui/shared/scripts/helpers/ConfigHelper.py b/test/gui/helpers/ConfigHelper.py similarity index 58% rename from test/gui/shared/scripts/helpers/ConfigHelper.py rename to test/gui/helpers/ConfigHelper.py index af602387ce..995ab93389 100644 --- a/test/gui/shared/scripts/helpers/ConfigHelper.py +++ b/test/gui/helpers/ConfigHelper.py @@ -1,33 +1,15 @@ 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 - - -def read_env_file(): - envs = {} - script_path = os.path.dirname(os.path.realpath(__file__)) - env_path = os.path.abspath(os.path.join(script_path, '..', '..', '..', 'envs.txt')) - with open(env_path, 'rt', encoding='UTF-8') as f: - for line in f: - if not line.strip(): - continue - if line.startswith('#'): - continue - key, value = line.split('=', 1) - envs[key] = value.strip() - return envs - - -def get_config_from_env_file(env): - envs = read_env_file() - if env in envs: - return envs[env] - raise KeyError(f'Environment "{env}" not found in envs.txt') +APP_CONFIG_FILE = "opencloud.cfg" +CUMULATIVE_APP_LOG_FILE = "opencloud.log" +CURRENT_APP_LOG_FILE = "app.log" def is_windows(): @@ -39,7 +21,7 @@ def is_linux(): def get_win_user_home(): - return os.environ.get('UserProfile') + return os.environ.get('USERPROFILE', '') def get_client_root_path(): @@ -48,12 +30,20 @@ def get_client_root_path(): return os.path.join(gettempdir(), 'opencloudtest') +def get_config_home_linux(): + return os.path.join(tempfile.gettempdir(), 'opencloudtest', '.config') + + +def get_config_home_win(): + return os.path.join( + get_win_user_home(), 'AppData', 'Local', 'Temp', 'opencloudtest', '.config' + ) + + def get_config_home(): if is_windows(): - # There is no way to set custom config path in windows - # TODO: set to different path if option is available - return os.path.join(get_win_user_home(), 'AppData', 'Roaming', 'OpenCloud') - return os.path.join(get_config_from_env_file('XDG_CONFIG_HOME'), 'OpenCloud') + return get_config_home_win() + return get_config_home_linux() def get_default_home_dir(): @@ -62,57 +52,60 @@ def get_default_home_dir(): 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', - 'clientLogFile': 'CLIENT_LOG_FILE', - 'clientLogDir': 'CLIENT_LOG_DIR', + 'sync_timeout': 'SYNC_TIMEOUT', 'clientRootSyncPath': 'CLIENT_ROOT_SYNC_PATH', 'tempFolderPath': 'TEMP_FOLDER_PATH', 'guiTestReportDir': 'GUI_TEST_REPORT_DIR', - 'record_video_on_failure': 'RECORD_VIDEO_ON_FAILURE' + 'record_video_on_failure': 'RECORD_VIDEO_ON_FAILURE', } DEFAULT_PATH_CONFIG = { - 'custom_lib': os.path.abspath('../shared/scripts/custom_lib'), + '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, - 'clientLogFile': '', - 'clientLogDir': '', + 'sync_timeout': 60, + 'max_timeout': 60, + 'min_timeout': 5, + 'lowest_timeout': 1, 'clientRootSyncPath': get_client_root_path(), + 'clientConfigFile': os.path.join(get_config_home(), "OpenCloud", APP_CONFIG_FILE), + 'guiTestReportDir': os.path.join(CURRENT_DIR.parent, 'reports'), 'tempFolderPath': os.path.join(get_client_root_path(), 'temp'), - 'clientConfigDir': get_config_home(), - 'clientConfigFile': os.path.join(get_config_home(), "opencloud.cfg"), - 'guiTestReportDir': os.path.abspath('../reports'), 'record_video_on_failure': False, - 'files_for_upload': os.path.join(CURRENT_DIR.parent.parent, 'files-for-upload'), - 'syncConnectionName': 'Personal' + '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' + 'Editor': 'fb6c3e19-e378-47e5-b277-9732f9de6e21', } CONFIG.update(DEFAULT_PATH_CONFIG) READONLY_CONFIG = list(CONFIG_ENV_MAP.keys()) + list(DEFAULT_PATH_CONFIG.keys()) -SCENARIO_CONFIGS = {} - def read_cfg_file(cfg_path): cfg = ConfigParser() @@ -129,10 +122,7 @@ def read_cfg_file(cfg_path): def init_config(): # try reading configs from config.ini try: - script_path = os.path.dirname(os.path.realpath(__file__)) - cfg_path = os.path.abspath( - os.path.join(script_path, '..', '..', '..', 'config.ini') - ) + cfg_path = os.path.abspath(os.path.join(CURRENT_DIR.parent, 'config.ini')) read_cfg_file(cfg_path) except: pass @@ -147,7 +137,7 @@ def init_config(): # Set the default values if empty for key, value in CONFIG.items(): - if key in ('maxSyncTimeout', 'minSyncTimeout'): + if key in ('sync_timeout', 'max_timeout', 'min_timeout', 'lowest_timeout'): CONFIG[key] = builtins.int(value) elif key == 'localBackendUrl': # make sure there is always one trailing slash @@ -155,7 +145,6 @@ def init_config(): elif key in ( 'clientRootSyncPath', 'tempFolderPath', - 'clientConfigDir', 'guiTestReportDir', ): # make sure there is always one trailing slash @@ -165,6 +154,25 @@ def init_config(): 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] @@ -173,12 +181,4 @@ def get_config(key): def set_config(key, value): if key in READONLY_CONFIG: raise KeyError(f'Cannot set read-only config: {key}') - # save the initial config value - if key not in SCENARIO_CONFIGS: - SCENARIO_CONFIGS[key] = CONFIG.get(key) CONFIG[key] = value - - -def clear_scenario_config(): - for key, value in SCENARIO_CONFIGS.items(): - CONFIG[key] = value diff --git a/test/gui/helpers/ElementHelper.py b/test/gui/helpers/ElementHelper.py new file mode 100644 index 0000000000..86839b37fb --- /dev/null +++ b/test/gui/helpers/ElementHelper.py @@ -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 diff --git a/test/gui/shared/scripts/helpers/FilesHelper.py b/test/gui/helpers/FilesHelper.py similarity index 61% rename from test/gui/shared/scripts/helpers/FilesHelper.py rename to test/gui/helpers/FilesHelper.py index a417dc0276..1e246fda69 100644 --- a/test/gui/shared/scripts/helpers/FilesHelper.py +++ b/test/gui/helpers/FilesHelper.py @@ -1,7 +1,11 @@ import os import re -import ctypes 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 @@ -12,12 +16,9 @@ def build_conflicted_regex(filename): namepart = filename.split(".")[0] extpart = filename.split(".")[1] # pylint: disable=anomalous-backslash-in-string - return "%s \(conflicted copy \d{4}-\d{2}-\d{2} \d{6}\)\.%s" % ( - namepart, - extpart, - ) + return rf"{namepart} \(conflicted copy \d{{4}}-\d{{2}}-\d{{2}} \d{{6}}\)\.{extpart}" # pylint: disable=anomalous-backslash-in-string - return "%s \(conflicted copy \d{4}-\d{2}-\d{2} \d{6}\)" % filename + return rf"{filename} \(conflicted copy \d{{4}}-\d{{2}}-\d{{2}} \d{{6}}\)" def sanitize_path(path): @@ -92,15 +93,6 @@ def get_size_in_bytes(size): raise ValueError("Invalid size: " + size) -def get_file_size_on_disk(resource_path): - file_size_high = ctypes.c_ulonglong(0) - if is_windows(): - return ctypes.windll.kernel32.GetCompressedFileSizeW( - ctypes.c_wchar_p(resource_path), ctypes.pointer(file_size_high) - ) - raise OSError("'get_file_size_on_disk' function is only supported for Windows OS.") - - def get_file_size(resource_path): return os.stat(resource_path).st_size @@ -136,5 +128,61 @@ def convert_path_separators_for_os(path): On other systems, returns the path unchanged. """ if is_windows(): - return path.replace('/', '\\') - return path \ No newline at end of file + 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 diff --git a/test/gui/shared/scripts/helpers/ReportHelper.py b/test/gui/helpers/ReportHelper.py similarity index 98% rename from test/gui/shared/scripts/helpers/ReportHelper.py rename to test/gui/helpers/ReportHelper.py index c7eff43a0b..f29e15c6f6 100644 --- a/test/gui/shared/scripts/helpers/ReportHelper.py +++ b/test/gui/helpers/ReportHelper.py @@ -2,8 +2,6 @@ import glob import shutil import test -import squish -import squishinfo from helpers.ConfigHelper import get_config from helpers.FilesHelper import prefix_path_namespace diff --git a/test/gui/helpers/ScreenRecorder.py b/test/gui/helpers/ScreenRecorder.py new file mode 100644 index 0000000000..1576986ff1 --- /dev/null +++ b/test/gui/helpers/ScreenRecorder.py @@ -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 diff --git a/test/gui/shared/scripts/helpers/SetupClientHelper.py b/test/gui/helpers/SetupClientHelper.py similarity index 76% rename from test/gui/shared/scripts/helpers/SetupClientHelper.py rename to test/gui/helpers/SetupClientHelper.py index d1c6e660f4..dc0967d8a4 100644 --- a/test/gui/shared/scripts/helpers/SetupClientHelper.py +++ b/test/gui/helpers/SetupClientHelper.py @@ -1,23 +1,19 @@ import uuid import os import subprocess +import test from urllib.parse import urlparse from os import makedirs from os.path import exists, join -import test -import psutil -import squish -import squishinfo 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, get_password_for_user -from helpers.ReportHelper import is_video_enabled +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): @@ -32,24 +28,23 @@ def substitute_inline_codes(value): return value -def get_client_details(context): +def get_client_details(table): client_details = { 'server': '', 'user': '', 'password': '', 'sync_folder': '', - 'oauth': False, } - for row in context.table[0:]: - row[1] = substitute_inline_codes(row[1]) - if row[0] == 'server': - client_details.update({'server': row[1]}) - elif row[0] == 'user': - client_details.update({'user': row[1]}) - elif row[0] == 'password': - client_details.update({'password': row[1]}) - elif row[0] == 'sync_folder': - client_details.update({'sync_folder': row[1]}) + 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 @@ -103,26 +98,7 @@ def get_current_user_sync_path(): def start_client(): - log_command_suffix = "" - logfile = get_config("clientLogFile") - logdir = get_config("clientLogDir") + "/" + squishinfo.testCaseName - if logfile != "": - log_command_suffix = f' --logfile {logfile}' - elif logdir != "": - log_command_suffix = f' --logdir {logdir}' - - squish.startApplication( - 'opencloud -s' - + f' {log_command_suffix}' - + ' --logdebug' - ) - if is_video_enabled(): - test.startVideoCapture() - else: - test.log( - f'Video recordings reached the maximum limit of {get_config("video_record_limit")}.' - + 'Skipping video recording...' - ) + create_app_session() def get_polling_interval(): @@ -134,6 +110,7 @@ def get_polling_interval(): 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) @@ -145,7 +122,7 @@ def generate_account_config(users, space='Personal'): for idx, username in enumerate(users): users_uuids[username] = QUuid.createUuid() settings.beginGroup("Accounts") - settings.beginWriteArray(str(idx+1),len(users)) + settings.beginWriteArray(str(idx + 1), len(users)) settings.setValue("capabilities", capabilities_variant) settings.setValue("default_sync_root", create_user_sync_path(username)) @@ -161,7 +138,7 @@ def generate_account_config(users, space='Personal'): settings.beginGroup("Folders") for idx, username in enumerate(users): sync_path = create_space_path(username, space) - settings.beginWriteArray(str(idx+1),len(users)) + settings.beginWriteArray(str(idx + 1), len(users)) if space == 'Personal': space_id = get_personal_space_id(username) @@ -177,18 +154,21 @@ def generate_account_config(users, space='Personal'): settings.setValue("localPath", sync_path) settings.setValue("paused", 'false') settings.setValue("priority", '50') - settings.setValue("virtualFilesMode", 'off') - settings.setValue("journalPath",".sync_journal.db") + 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) @@ -197,24 +177,6 @@ def setup_client(username, space='Personal'): listen_sync_status_for_item(sync_path) -def is_app_killed(pid): - try: - psutil.Process(pid) - return False - except psutil.NoSuchProcess: - return True - - -def wait_until_app_killed(pid=0): - timeout = 5 * 1000 - killed = squish.waitFor( - lambda: is_app_killed(pid), - timeout, - ) - if not killed: - test.log(f'Application was not terminated within {timeout} milliseconds') - - def generate_uuidv4(): return str(uuid.uuid4()) diff --git a/test/gui/shared/scripts/helpers/SpaceHelper.py b/test/gui/helpers/SpaceHelper.py similarity index 100% rename from test/gui/shared/scripts/helpers/SpaceHelper.py rename to test/gui/helpers/SpaceHelper.py diff --git a/test/gui/shared/scripts/helpers/StacktraceHelper.py b/test/gui/helpers/StacktraceHelper.py similarity index 83% rename from test/gui/shared/scripts/helpers/StacktraceHelper.py rename to test/gui/helpers/StacktraceHelper.py index a67c009ba2..d53e79eb15 100644 --- a/test/gui/shared/scripts/helpers/StacktraceHelper.py +++ b/test/gui/helpers/StacktraceHelper.py @@ -2,6 +2,7 @@ import subprocess import glob import re +import time from datetime import datetime from helpers.ConfigHelper import is_windows @@ -42,11 +43,20 @@ def parse_stacktrace(coredump_file): coredump_filename = os.path.basename(coredump_file) # example coredump file: core-1648445754-1001-11-!drone!src!build-GUI-tests!bin!opencloud patterns = coredump_filename.split('-') - app_binary = '-'.join(patterns[4:]).replace('!', '/') + app_binary = 'opencloud' + 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(datetime.fromtimestamp(float(patterns[1])))}') + message.append(f'Timestamp: {str(timestamp)}') message.append(f'Process ID: {patterns[2]}') message.append(f'Signal Number: {patterns[3]}') message.append('-------------------------------------------') diff --git a/test/gui/shared/scripts/helpers/SyncHelper.py b/test/gui/helpers/SyncHelper.py similarity index 69% rename from test/gui/shared/scripts/helpers/SyncHelper.py rename to test/gui/helpers/SyncHelper.py index cd6680b046..750aa14ff1 100644 --- a/test/gui/shared/scripts/helpers/SyncHelper.py +++ b/test/gui/helpers/SyncHelper.py @@ -1,12 +1,12 @@ import os import re -import sys -import test +import time import urllib.request -import squish +from pageObjects.SyncConnection import SyncConnection from helpers.ConfigHelper import get_config, is_linux, is_windows from helpers.FilesHelper import sanitize_path +from helpers.Utils import wait_for if is_windows(): from helpers.WinPipeHelper import WinPipeConnect as SocketConnect @@ -21,16 +21,26 @@ 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', - os.path.join(custom_lib, '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 - sys.path.append(custom_lib) - from custom_lib.syncstate import SocketConnect + from helpers.custom_lib.syncstate import SocketConnect # socket messages socket_messages = [] @@ -43,6 +53,8 @@ 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 @@ -57,34 +69,56 @@ 'initial': [ # when adding account via New Account wizard [ + SYNC_STATUS['NOP'], SYNC_STATUS['REGISTER'], SYNC_STATUS['UPDATE'], - SYNC_STATUS['UPDATE'], - SYNC_STATUS['UPDATE'], ], # when syncing empty account (hidden files are ignored) - [SYNC_STATUS['UPDATE'], SYNC_STATUS['OK']], + # [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']], - ], - 'root_synced': [ + # [SYNC_STATUS['SYNC'], SYNC_STATUS['OK']], + # initial root sync [ - SYNC_STATUS['SYNC'], - SYNC_STATUS['OK'], SYNC_STATUS['OK'], SYNC_STATUS['OK'], SYNC_STATUS['UPDATE'], ], + ], + 'root_synced': [ [ - SYNC_STATUS['SYNC'], - SYNC_STATUS['UPDATE'], - SYNC_STATUS['OK'], 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']], ], - 'single_synced': [SYNC_STATUS['SYNC'], SYNC_STATUS['OK']], 'error': [SYNC_STATUS['ERROR']], } @@ -161,11 +195,13 @@ def generate_sync_pattern_from_messages(messages): sync_messages = filter_sync_messages(messages) for message in sync_messages: - # E.g; from "STATUS:OK:/tmp/client-bdd/Alice/" + # 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(':(/|[A-Z]{1}:\\\\|[A-Z]{1}:\/).*', message): - (end, _) = match.span() + 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) @@ -194,7 +230,7 @@ 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('\\') + item = item.rstrip('\\').rstrip('/') socket_connect.sendCommand(f'RETRIEVE_{resource_type}_STATUS:{item}\n') @@ -205,36 +241,59 @@ def get_current_sync_status(resource, resource_type): return messages[-1] -def wait_for_resource_to_sync(resource, resource_type='FOLDER', patterns=None): +def wait_for_resource_to_sync( + resource, resource_type='FOLDER', patterns=None, force_sync=False +): listen_sync_status_for_item(resource, resource_type) - timeout = get_config('maxSyncTimeout') * 1000 + initial_timeout = 0 + timeout = get_config('sync_timeout') if patterns is None: patterns = get_synced_pattern(resource) - synced = squish.waitFor( + if force_sync: + initial_timeout = get_config('min_timeout') + # 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, + timeout - initial_timeout, ) + + messages = read_and_update_socket_messages() + messages = filter_messages_for_item(messages, resource) clear_socket_messages(resource) - if not synced: + 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']): - test.log( + 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' - ) + raise TimeoutError( + 'Timeout while waiting for sync to complete for ' + str(timeout) + ' seconds' + ) def wait_for_initial_sync_to_complete(path): @@ -242,6 +301,7 @@ def wait_for_initial_sync_to_complete(path): path, 'FOLDER', get_initial_sync_patterns(), + True, ) @@ -260,9 +320,10 @@ def has_sync_pattern(patterns, resource=None): 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 - squish.snooze(0.1) + time.sleep(0.1) return False @@ -288,9 +349,9 @@ def wait_for_resource_to_have_sync_status( listen_sync_status_for_item(resource, resource_type) if not timeout: - timeout = get_config('maxSyncTimeout') * 1000 + timeout = get_config('sync_timeout') - result = squish.waitFor( + result = wait_for( lambda: has_sync_status(resource, status), timeout, ) @@ -317,10 +378,31 @@ def wait_for_resource_to_have_sync_error(resource, resource_type): def wait_for_client_to_be_ready(): global WAITED_AFTER_SYNC if not WAITED_AFTER_SYNC: - squish.snooze(get_config('minSyncTimeout')) + time.sleep(get_config('min_timeout')) 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') diff --git a/test/gui/helpers/TableParser.py b/test/gui/helpers/TableParser.py new file mode 100644 index 0000000000..cf262f1499 --- /dev/null +++ b/test/gui/helpers/TableParser.py @@ -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 diff --git a/test/gui/shared/scripts/helpers/UserHelper.py b/test/gui/helpers/UserHelper.py similarity index 100% rename from test/gui/shared/scripts/helpers/UserHelper.py rename to test/gui/helpers/UserHelper.py diff --git a/test/gui/helpers/Utils.py b/test/gui/helpers/Utils.py new file mode 100644 index 0000000000..442f919d8d --- /dev/null +++ b/test/gui/helpers/Utils.py @@ -0,0 +1,11 @@ +from selenium.webdriver.support.ui import WebDriverWait +from selenium.common.exceptions import TimeoutException + + +def wait_for(condition, timeout=10, interval=0.5): + wait = WebDriverWait(None, timeout, poll_frequency=interval) + try: + wait.until(lambda _: condition()) + return True + except TimeoutException: + return False diff --git a/test/gui/helpers/VFSFileHelper.py b/test/gui/helpers/VFSFileHelper.py new file mode 100644 index 0000000000..8a106240ec --- /dev/null +++ b/test/gui/helpers/VFSFileHelper.py @@ -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) diff --git a/test/gui/helpers/WebUIHelper.py b/test/gui/helpers/WebUIHelper.py new file mode 100644 index 0000000000..eafbe25383 --- /dev/null +++ b/test/gui/helpers/WebUIHelper.py @@ -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() diff --git a/test/gui/shared/scripts/helpers/WinPipeHelper.py b/test/gui/helpers/WinPipeHelper.py similarity index 100% rename from test/gui/shared/scripts/helpers/WinPipeHelper.py rename to test/gui/helpers/WinPipeHelper.py diff --git a/test/gui/shared/scripts/helpers/api/http_helper.py b/test/gui/helpers/api/http_helper.py similarity index 97% rename from test/gui/shared/scripts/helpers/api/http_helper.py rename to test/gui/helpers/api/http_helper.py index 515bf9c805..348b2bf315 100644 --- a/test/gui/shared/scripts/helpers/api/http_helper.py +++ b/test/gui/helpers/api/http_helper.py @@ -20,7 +20,7 @@ def send_request(url, method, body=None, headers=None, user=None, password=None) verify=False, # in seconds # e.g.: 60 - timeout=get_config("maxSyncTimeout"), + timeout=get_config("max_timeout"), ) diff --git a/test/gui/shared/scripts/helpers/api/provisioning.py b/test/gui/helpers/api/provisioning.py similarity index 99% rename from test/gui/shared/scripts/helpers/api/provisioning.py rename to test/gui/helpers/api/provisioning.py index 2f78d2df23..133d9db39d 100644 --- a/test/gui/shared/scripts/helpers/api/provisioning.py +++ b/test/gui/helpers/api/provisioning.py @@ -1,9 +1,11 @@ +import json +from PySide6.QtCore import QJsonDocument + +import helpers.api.http_helper as request from helpers.ConfigHelper import get_config from helpers import UserHelper -import helpers.api.http_helper as request from helpers.api.utils import url_join -import json -from PySide6.QtCore import QJsonDocument + created_groups = {} created_users = {} diff --git a/test/gui/shared/scripts/helpers/api/utils.py b/test/gui/helpers/api/utils.py similarity index 100% rename from test/gui/shared/scripts/helpers/api/utils.py rename to test/gui/helpers/api/utils.py diff --git a/test/gui/shared/scripts/helpers/api/webdav_helper.py b/test/gui/helpers/api/webdav_helper.py similarity index 100% rename from test/gui/shared/scripts/helpers/api/webdav_helper.py rename to test/gui/helpers/api/webdav_helper.py diff --git a/test/gui/helpers/keys/keys_map.py b/test/gui/helpers/keys/keys_map.py new file mode 100644 index 0000000000..459dbe3576 --- /dev/null +++ b/test/gui/helpers/keys/keys_map.py @@ -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 diff --git a/test/gui/shared/scripts/pageObjects/AccountConnectionWizard.py b/test/gui/pageObjects/AccountConnectionWizard.py similarity index 51% rename from test/gui/shared/scripts/pageObjects/AccountConnectionWizard.py rename to test/gui/pageObjects/AccountConnectionWizard.py index 3395969c9f..f4fb1e5640 100644 --- a/test/gui/shared/scripts/pageObjects/AccountConnectionWizard.py +++ b/test/gui/pageObjects/AccountConnectionWizard.py @@ -1,9 +1,6 @@ -import test -import names -import squish import os - -from pageObjects.EnterPassword import EnterPassword +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 @@ -13,103 +10,66 @@ set_current_user_sync_path, ) from helpers.SyncHelper import listen_sync_status_for_item +from helpers.AppHelper import app class AccountConnectionWizard: - SERVER_ADDRESS_BOX = { - "container": names.setupWizardWindow_contentWidget_QStackedWidget, - "name": "urlLineEdit", - "type": "QLineEdit", - "visible": 1, - } - NEXT_BUTTON = { - "container": names.settings_dialogStack_QStackedWidget, - "name": "nextButton", - "type": "QPushButton", - "visible": 1, - } - CONFIRM_INSECURE_CONNECTION_BUTTON = { - "text": "Confirm", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - "window": names.insecure_connection_QMessageBox, - } - USERNAME_BOX = { - "container": names.contentWidget_OCC_QmlUtils_OCQuickWidget, - "id": "userNameField", - "type": "TextField", - "visible": True, - } - SELECT_LOCAL_FOLDER = { - "container": names.advancedConfigGroupBox_localDirectoryGroupBox_QGroupBox, - "name": "localDirectoryLineEdit", - "type": "QLineEdit", - "visible": 1, - } - DIRECTORY_NAME_BOX = { - "container": names.advancedConfigGroupBox_localDirectoryGroupBox_QGroupBox, - "name": "chooseLocalDirectoryButton", - "type": "QToolButton", - "visible": 1, - } - CHOOSE_BUTTON = { - "text": "Choose", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - "window": names.qFileDialog_QFileDialog, - } - OAUTH_CREDENTIAL_PAGE = { - "container": names.contentWidget_contentWidget_QStackedWidget, - "type": "OCC::Wizard::OAuthCredentialsSetupWizardPage", - "visible": 1, - } - COPY_URL_TO_CLIPBOARD_BUTTON = { - "container": names.contentWidget_OCC_QmlUtils_OCQuickWidget, - "id": "copyToClipboardButton", - "type": "Button", - "visible": True, - } - CONF_SYNC_MANUALLY_RADIO_BUTTON = { - "container": names.advancedConfigGroupBox_syncModeGroupBox_QGroupBox, - "name": "configureSyncManuallyRadioButton", - "type": "QRadioButton", - "visible": 1, - } - ADVANCED_CONFIGURATION_CHECKBOX = { - "container": names.setupWizardWindow_contentWidget_QStackedWidget, - "name": "advancedConfigGroupBox", - "type": "QGroupBox", - "visible": 1, - } - DIRECTORY_NAME_EDIT_BOX = { - "buddy": names.qFileDialog_fileNameLabel_QLabel, - "name": "fileNameEdit", - "type": "QLineEdit", - "visible": 1, - } - SYNC_EVERYTHING_RADIO_BUTTON = { - "container": names.advancedConfigGroupBox_syncModeGroupBox_QGroupBox, - "name": "syncEverythingRadioButton", - "type": "QRadioButton", - "visible": 1, - } + 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") + LOGIN_DIALOG = SimpleNamespace(by=By.NAME, selector="Log in with your web browser") + 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): - squish.mouseClick( - squish.waitForObject(AccountConnectionWizard.SERVER_ADDRESS_BOX) - ) - squish.type( - squish.waitForObject(AccountConnectionWizard.SERVER_ADDRESS_BOX), - 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(): - squish.clickButton(squish.waitForObject(EnterPassword.ACCEPT_CERTIFICATE_YES)) + 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): @@ -117,22 +77,26 @@ def add_user_credentials(username, password): @staticmethod def oidc_login(username, password): - AccountConnectionWizard.browser_login(username, password, "oidc") + AccountConnectionWizard.browser_login(username, password) @staticmethod - def browser_login(username, password, login_type=None): - # wait 500ms for copy button to fully load - squish.snooze(1 / 2) - squish.mouseClick( - squish.waitForObject(AccountConnectionWizard.COPY_URL_TO_CLIPBOARD_BUTTON) - ) - authorize_via_webui(username, password, login_type) + 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(): - squish.clickButton( - squish.waitForObjectExists(AccountConnectionWizard.NEXT_BUTTON) - ) + app().find_element( + AccountConnectionWizard.NEXT_BUTTON.by, + AccountConnectionWizard.NEXT_BUTTON.selector, + ).click() @staticmethod def select_sync_folder(user): @@ -140,14 +104,20 @@ def select_sync_folder(user): sync_path = create_user_sync_path(user) AccountConnectionWizard.select_advanced_config() - squish.mouseClick( - squish.waitForObject(AccountConnectionWizard.DIRECTORY_NAME_BOX) + 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, ) - squish.type( - squish.waitForObject(AccountConnectionWizard.DIRECTORY_NAME_EDIT_BOX), - sync_path, - ) - squish.clickButton(squish.waitForObject(AccountConnectionWizard.CHOOSE_BUTTON)) + 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 @@ -199,12 +169,10 @@ def add_account_information(account_details): @staticmethod def select_manual_sync_folder_option(): - squish.clickButton( - squish.waitForObject( - AccountConnectionWizard.CONF_SYNC_MANUALLY_RADIO_BUTTON - ) - ) - + app().find_element( + AccountConnectionWizard.CONF_SYNC_MANUALLY_RADIO_BUTTON.by, + AccountConnectionWizard.CONF_SYNC_MANUALLY_RADIO_BUTTON.selector, + ).click() @staticmethod def select_download_everything_option(): @@ -212,7 +180,6 @@ def select_download_everything_option(): squish.waitForObject(AccountConnectionWizard.SYNC_EVERYTHING_RADIO_BUTTON) ) - @staticmethod def is_new_connection_window_visible(): visible = False @@ -225,19 +192,18 @@ def is_new_connection_window_visible(): @staticmethod def is_credential_window_visible(): - visible = False - try: - squish.waitForObject(AccountConnectionWizard.OAUTH_CREDENTIAL_PAGE) - visible = True - except: - pass + visible = app().find_element( + AccountConnectionWizard.LOGIN_DIALOG.by, + AccountConnectionWizard.LOGIN_DIALOG.selector + ).is_displayed() return visible @staticmethod def select_advanced_config(): - squish.waitForObject( - AccountConnectionWizard.ADVANCED_CONFIGURATION_CHECKBOX - ).setChecked(True) + app().find_element( + AccountConnectionWizard.ADVANCED_CONFIGURATION_CHECKBOX.by, + AccountConnectionWizard.ADVANCED_CONFIGURATION_CHECKBOX.selector, + ).click() @staticmethod def can_change_local_sync_dir(): @@ -247,7 +213,7 @@ def can_change_local_sync_dir(): squish.clickButton( squish.waitForObject(AccountConnectionWizard.DIRECTORY_NAME_BOX) ) - squish.waitForObjectExists(AccountConnectionWizard.CHOOSE_BUTTON) + squish.waitForObjectExists(AccountConnectionWizard.CHOOSE_FOLDER_BUTTON) can_change = True except: pass diff --git a/test/gui/pageObjects/AccountSetting.py b/test/gui/pageObjects/AccountSetting.py new file mode 100644 index 0000000000..7809e2e451 --- /dev/null +++ b/test/gui/pageObjects/AccountSetting.py @@ -0,0 +1,173 @@ +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.Utils import wait_for +from helpers.ConfigHelper import get_config + + +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=get_config('min_timeout')): + result = squish.waitFor( + AccountSetting.is_connecting, + timeout, + ) + + if not result: + raise TimeoutError( + "Timeout waiting for connection to be configured for " + + str(timeout) + + " seconds" + ) + + @staticmethod + def wait_until_account_is_connected(timeout=get_config('min_timeout')): + 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) + + " seconds" + ) + return result + + @staticmethod + def wait_until_sync_folder_is_configured(timeout=get_config('min_timeout')): + 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) + + " seconds" + ) + 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=get_config('min_timeout')): + 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) + + " seconds" + ) diff --git a/test/gui/pageObjects/Activity.py b/test/gui/pageObjects/Activity.py new file mode 100644 index 0000000000..76e8b8fde9 --- /dev/null +++ b/test/gui/pageObjects/Activity.py @@ -0,0 +1,225 @@ +# 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 +from helpers.Utils import wait_for + + +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=By.ACCESSIBILITY_ID, + selector="QApplication.Settings.centralwidget.dialogStack.page.stack.OCC::ActivitySettings.QTabWidget.qt_tabwidget_stackedwidget.OCC__IssuesWidget._filterButton", + ) + 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 = wait_for( + lambda: Activity.has_sync_status(filename, "Blacklisted"), + get_config("sync_timeout"), + ) + return result + + @staticmethod + def is_resource_ignored(filename): + result = squish.waitFor( + lambda: Activity.has_sync_status(filename, "File Ignored"), + get_config("sync_timeout"), + ) + return result + + @staticmethod + def is_resource_excluded(filename): + result = wait_for( + lambda: Activity.has_sync_status(filename, "Excluded"), + get_config("sync_timeout"), + ) + 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): + menu = app().find_element( + Activity.NOT_SYNCED_FILTER_BUTTON.by, + Activity.NOT_SYNCED_FILTER_BUTTON.selector, + ) + menu.click() + # NOTE: Filter options are not visible in the accessibility tree. + # As a workaround, select the 6th filter option (which is an Excluded filter). + # This means we cannot select a specific Excluded filter for now. + for _ in range(6): + menu.send_keys(Keys.ARROW_DOWN) + menu.send_keys(Keys.ENTER) + + @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("lowest_timeout"), + )["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 diff --git a/test/gui/pageObjects/EnterPassword.py b/test/gui/pageObjects/EnterPassword.py new file mode 100644 index 0000000000..50bcdbd6a6 --- /dev/null +++ b/test/gui/pageObjects/EnterPassword.py @@ -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() diff --git a/test/gui/shared/scripts/pageObjects/PublicLinkDialog.py b/test/gui/pageObjects/PublicLinkDialog.py similarity index 100% rename from test/gui/shared/scripts/pageObjects/PublicLinkDialog.py rename to test/gui/pageObjects/PublicLinkDialog.py diff --git a/test/gui/pageObjects/Settings.py b/test/gui/pageObjects/Settings.py new file mode 100644 index 0000000000..10141648f3 --- /dev/null +++ b/test/gui/pageObjects/Settings.py @@ -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() diff --git a/test/gui/pageObjects/SyncConnection.py b/test/gui/pageObjects/SyncConnection.py new file mode 100644 index 0000000000..330d312520 --- /dev/null +++ b/test/gui/pageObjects/SyncConnection.py @@ -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("max_timeout"), + ) + 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) diff --git a/test/gui/pageObjects/SyncConnectionWizard.py b/test/gui/pageObjects/SyncConnectionWizard.py new file mode 100644 index 0000000000..e58917a7bf --- /dev/null +++ b/test/gui/pageObjects/SyncConnectionWizard.py @@ -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, + ) diff --git a/test/gui/pageObjects/Toolbar.py b/test/gui/pageObjects/Toolbar.py new file mode 100644 index 0000000000..6d1d689866 --- /dev/null +++ b/test/gui/pageObjects/Toolbar.py @@ -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.Utils 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('max_timeout') + enabled = wait_for( + 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_opencloud(): + 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 diff --git a/test/manual/test_plan/images/overlay-icons.png b/test/gui/release-test-plan/images/overlay-icons.png similarity index 100% rename from test/manual/test_plan/images/overlay-icons.png rename to test/gui/release-test-plan/images/overlay-icons.png diff --git a/test/manual/test_plan/testplan.md b/test/gui/release-test-plan/testplan.md similarity index 98% rename from test/manual/test_plan/testplan.md rename to test/gui/release-test-plan/testplan.md index fe6e9590b1..7018d04f7c 100644 --- a/test/manual/test_plan/testplan.md +++ b/test/gui/release-test-plan/testplan.md @@ -177,16 +177,18 @@ Note: "Via Web" means check files on server in the web browser | 3 | Configure synchronization manually, a space | 1. Start the desktop client and fill in the server details
2. Check the advanced configuration checkbox
3. choose `Configure synchronization manually`
4. Connect the account
5. Choose "Cancel" in the next screen | - No local sync folder is created
- The setting window is opened and the account is registered | :robot: Win
:construction: macOS
: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
2. add an account to the desktop client with manual sync configuration
3. Choose the personal space to be synced
4. choose a local folder
5. Select only one folder to be synced and add the connection | Only one folder is synced | :robot: Win
:construction: macOS
:robot: Linux | tst_syncing | -| 3 | unselected subfolders | 1. Upload a folder that has many subfolders to the server
2. Connect the desktop client and sync the personal space
From the `...` button for the space select "Chose what to sync" window, select the folder that has many subfolders
3. Extend that folder and unselect some subfolders
3. Click "OK" | The parent folder is synced but not the unselected subfolders | :robot: Win
:construction: macOS
:robot: Linux | tst_syncing | -| 4 | 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: Win
:construction: macOS
:construction: Linux | | -| 5 | sync files both ways for selected folder | 1. From the `Deselect remote folders...` window, select a folder to sync and add the connection
2. Upload some files via webUI into that folder
3. Copy some other files into the corresponding local folder
4. Wait for sync | Files are synced both ways | :construction: Win
:construction: macOS
:construction: Linux | | -| 6 | sync files for unselected folder | 1. From the `Deselect remote folders...` window, unselect a folder and add connection
2. From the server, upload some files in that unselected folder | The folder and files are not available in the sync folder
Previously synced folders are deleted | :construction: Win
:construction: macOS
:robot: Linux | tst-syncing | -| 10 | sync of files in root folder | 1. From the `Deselect remote folders...` window, unselect all the folders
2. Add the connection | files that are in the root folder are synced | :construction: Win
:construction: macOS
:construction: Linux | | -| 11 | sorting of folders | 1. In the `Deselect remote folders...` window, sort the folders by name and size | Sorting works | :robot: Win
:construction: macOS
:robot: Linux | tst_syncing | +| 1 | sync only one folder | 1. Upload some files and folders to the server
2. add an account to the desktop client with manual sync configuration
3. Choose the personal space to be synced
4. choose a local folder
5. Select only one folder to be synced and add the connection | Only one folder is synced | :construction: macOS
:robot: Linux | tst_syncing | +| 2 | unselected subfolders | 1. Upload a folder that has many subfolders to the server
2. Connect the desktop client and sync the personal space
From the `...` button for the space select "Chose what to sync" window, select the folder that has many subfolders
3. Extend that folder and unselect some subfolders
3. Click "OK" | The parent folder is synced but not the unselected subfolders | :construction: macOS
: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
: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
2. Upload some files via webUI into that folder
3. Copy some other files into the corresponding local folder
4. Wait for sync | Files are synced both ways | :construction: macOS
:construction: Linux | | +| 5 | sync files for unselected folder | 1. From the `Deselect remote folders...` window, unselect a folder and add connection
2. From the server, upload some files in that unselected folder | The folder and files are not available in the sync folder
Previously synced folders are deleted | :construction: macOS
:robot: Linux | tst-syncing | +| 6 | sync of files in root folder | 1. From the `Deselect remote folders...` window, unselect all the folders
2. Add the connection | files that are in the root folder are synced | :construction: macOS
:construction: Linux | | +| 7 | sorting of folders | 1. In the `Deselect remote folders...` window, sort the folders by name and size | Sorting works | :construction: macOS
:robot: Linux | tst_syncing | ### 12. Overlay icons diff --git a/test/gui/requirements.txt b/test/gui/requirements.txt index 498e9a078b..434ce7001b 100644 --- a/test/gui/requirements.txt +++ b/test/gui/requirements.txt @@ -1,7 +1,22 @@ requests==2.32.*; python_version >= "3.10" -PyGObject==3.42.*; sys_platform != 'win32' and 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; sys_platform == 'win32' and python_version >= "3.10" -pyside6==6.9.*; python_version >= "3.10" \ No newline at end of file +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" diff --git a/test/gui/shared/scripts/bdd_hooks.py b/test/gui/shared/scripts/bdd_hooks.py deleted file mode 100644 index 5d5c1ee7ee..0000000000 --- a/test/gui/shared/scripts/bdd_hooks.py +++ /dev/null @@ -1,268 +0,0 @@ -# -*- coding: utf-8 -*- - -# This file contains hook functions to run as the .feature file is executed. -# -# A common use-case is to use the OnScenarioStart/OnScenarioEnd hooks to -# start and stop an AUT, e.g. -# -# @OnScenarioStart -# def hook(context): -# startApplication("addressbook") -# -# @OnScenarioEnd -# def hook(context): -# currentApplicationContext().detach() -# -# See the section 'Performing Actions During Test Execution Via Hooks' in the Squish -# manual for a complete reference of the available API. -import shutil -import os -from datetime import datetime -from types import SimpleNamespace - -from helpers.StacktraceHelper import get_core_dumps, generate_stacktrace -from helpers.SyncHelper import close_socket_connection, clear_waited_after_sync -from helpers.SpaceHelper import delete_project_spaces -from helpers.api.provisioning import delete_created_users -from helpers.SetupClientHelper import wait_until_app_killed, unlock_keyring -from helpers.ConfigHelper import ( - init_config, - get_config, - set_config, - clear_scenario_config, - is_windows, - is_linux, -) -from helpers.FilesHelper import prefix_path_namespace, cleanup_created_paths -from helpers.ReportHelper import save_video_recording, take_screenshot, is_video_enabled - -from pageObjects.Toolbar import Toolbar -from pageObjects.AccountSetting import AccountSetting -from pageObjects.AccountConnectionWizard import AccountConnectionWizard -import names - -# Squish test settings: -# This controls whether a test (scenario) should stop execution on failure or not -# If set to True, the scenario will stop on the first step failure and remaining steps will not be executed -# If set to False, the scenario will continue to execute all steps and report all failures at the end -testSettings.throwOnFailure = True - -# this will reset in every test suite -PREVIOUS_FAIL_RESULT_COUNT = 0 -PREVIOUS_ERROR_RESULT_COUNT = 0 - - -# runs before a feature -# Order: 1 -@OnFeatureStart -def hook(context): - init_config() - - -# runs before every scenario -# Order: 1 -@OnScenarioStart -def hook(context): - unlock_keyring() - clear_scenario_config() - - -# runs before every scenario -# Order: 2 -@OnScenarioStart -def hook(context): - # set opencloud config file path - config_dir = get_config("clientConfigDir") - if os.path.exists(config_dir): - if len(os.listdir(config_dir)) and is_windows(): - raise FileExistsError( - "Looks like you have previous client config in '" - + config_dir - + "'\n[DANGER] Delete it and try again." - + "\n[DANGER] Removing config file will make client to lost the previously added accounts." - ) - # clean previous configs - shutil.rmtree(config_dir) - os.makedirs(config_dir, 0o0755) - - # create reports dir if not exists - test_report_dir = get_config("guiTestReportDir") - if not os.path.exists(test_report_dir): - os.makedirs(test_report_dir) - - # log tests scenario title on serverlog file - if os.getenv("CI"): - with open(test_report_dir + "/serverlog.log", "a", encoding="utf-8") as f: - f.write( - str((datetime.now()).strftime("%H:%M:%S:%f")) - + "\tBDD Scenario: " - + context.title - + "\n" - ) - - # this path will be changed according to the user added to the client - # e.g.: /tmp/client-bdd/Alice - set_config("currentUserSyncPath", "") - - root_sync_dir = get_config("clientRootSyncPath") - if not os.path.exists(root_sync_dir): - os.makedirs(root_sync_dir) - - tmp_dir = get_config("tempFolderPath") - if not os.path.exists(tmp_dir): - os.makedirs(tmp_dir) - - -# determines if the test scenario failed or not -# Currently, this workaround is needed because we cannot find out -# a way to determine the pass/fail status of currently running test scenario. -# And, resultCount("errors") and resultCount("fails") -# return the total number of error/failed test scenarios of a test suite. -def scenario_failed(): - return ( - test.resultCount("fails") - PREVIOUS_FAIL_RESULT_COUNT > 0 - or test.resultCount("errors") - PREVIOUS_ERROR_RESULT_COUNT > 0 - ) - - -def scenario_title_to_filename(title): - # scenario name can have "/" which is invalid filename - return title.replace(" ", "_").replace("/", "_").strip(".") - - -# runs after every scenario -# Order: 1 -# server cleanup -@OnScenarioEnd -def hook(context): - delete_project_spaces() - delete_created_users() - - -# runs after every scenario -# Order: 2 -@OnScenarioEnd -def hook(context): - clear_waited_after_sync() - close_socket_connection() - - # generate screenshot and video reports - if is_linux(): - filename = scenario_title_to_filename(context.title) - if scenario_failed(): - take_screenshot(f"{filename}.png") - - if is_video_enabled(): - save_video_recording(f"{filename}.mp4", scenario_failed()) - - # teardown accounts and configs - teardown_client() - - # search coredumps after every test scenario - # CI pipeline might fail although all tests are passing - if coredumps := get_core_dumps(): - try: - generate_stacktrace(context.title, coredumps) - test.log("Stacktrace generated!") - except OSError as err: - test.log("Exception occured:" + str(err)) - elif scenario_failed(): - test.log("No coredump found!") - - global PREVIOUS_FAIL_RESULT_COUNT, PREVIOUS_ERROR_RESULT_COUNT - PREVIOUS_FAIL_RESULT_COUNT = test.resultCount("fails") - PREVIOUS_ERROR_RESULT_COUNT = test.resultCount("errors") - - -def get_active_widget(): - dialog_widgets = object.children(squish.waitForObject(AccountSetting.DIALOG_STACK, get_config('minSyncTimeout') * 100)) - for child_widget in dialog_widgets: - if hasattr(child_widget, "objectName") and child_widget.objectName and child_widget.objectName != "page": - return child_widget - - # return empty object if not found - return SimpleNamespace(objectName="") - - -def teardown_client(): - # Cleanup user accounts from UI for Windows platform - # It is not needed for Linux so skipping it in order to save CI time - if is_windows(): - # remove account from UI - # In Windows, removing only config and sync folders won't help - # so to work around that, remove the account connection - close_dialogs() - close_widgets() - active_widget = get_active_widget() - if active_widget.objectName and active_widget.objectName != names.setupWizardWindow_OCC_Wizard_SetupWizardWindow["name"]: - accounts, selectors = Toolbar.get_accounts() - for display_name in selectors: - _, account_objects = Toolbar.get_accounts() - squish.mouseClick(squish.waitForObject(account_objects[display_name])) - AccountSetting.remove_account_connection() - - # re-fetch accounts after removing from UI - accounts, _ = Toolbar.get_accounts() - if accounts: - squish.waitForObject(AccountConnectionWizard.SERVER_ADDRESS_BOX) - - # Detach (i.e. potentially terminate) all AUTs at the end of a scenario - for ctx in squish.applicationContextList(): - # get pid before detaching - pid = ctx.pid - ctx.detach() - wait_until_app_killed(pid) - - # clean up config files - shutil.rmtree(get_config("clientConfigDir")) - - # delete test files/folders - for entry in os.scandir(get_config("clientRootSyncPath")): - try: - if entry.is_file() or entry.is_symlink(): - test.log("Deleting file: " + entry.name) - os.unlink(prefix_path_namespace(entry.path)) - elif entry.is_dir(): - test.log("Deleting folder: " + entry.name) - shutil.rmtree(prefix_path_namespace(entry.path)) - except OSError as e: - test.log(f"Failed to delete '{entry.name}'.\nReason: {e}.") - # cleanup paths created outside of the temporary directory during the test - cleanup_created_paths() - - -def close_dialogs(): - # close the current active dailog if it's not a main client window - while True: - active_window = QApplication.activeModalWidget() - if str(active_window) == "": - break - test.log(f"Closing '{active_window.objectName}' window") - if not active_window.close(): - confirm_dialog = QApplication.activeModalWidget() - if confirm_dialog.visible: - squish.clickButton( - squish.waitForObject(AccountSetting.CONFIRMATION_YES_BUTTON) - ) - - -def close_widgets(): - try: - ch = object.children(squish.waitForObject(AccountSetting.DIALOG_STACK, 500)) - for obj in ch: - if ( - hasattr(obj, "objectName") - and obj.objectName - and obj.objectName != "page" - ): - obj.close() - # if the dialog has a confirmation dialog, confirm it - confirm_dialog = QApplication.activeModalWidget() - if str(confirm_dialog) != "" and confirm_dialog.visible: - squish.clickButton( - squish.waitForObject(AccountSetting.CONFIRMATION_YES_BUTTON) - ) - except LookupError: - # nothing to close if DIALOG_STACK is not found - # required for client versions <= 5 - pass diff --git a/test/gui/shared/scripts/helpers/WebUIHelper.py b/test/gui/shared/scripts/helpers/WebUIHelper.py deleted file mode 100644 index 52e2f30105..0000000000 --- a/test/gui/shared/scripts/helpers/WebUIHelper.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -import subprocess -import squish - - -def get_clipboard_text(): - try: - return squish.getClipboardText() - except: - # Retry after 2 seconds - squish.snooze(2) - return squish.getClipboardText() - - -def authorize_via_webui(username, password, login_type='oidc'): - script_path = os.path.dirname(os.path.realpath(__file__)) - - webui_path = os.path.join(script_path, '..', '..', '..', 'webUI') - os.chdir(webui_path) - - envs = { - 'OC_USERNAME': username.strip('"'), - 'OC_PASSWORD': password.strip('"'), - 'OC_AUTH_URL': get_clipboard_text(), - } - proc = subprocess.run( - f"pnpm run {login_type}-login", - capture_output=True, - shell=True, - env={**os.environ, **envs}, - check=False, - ) - if proc.returncode: - if proc.stderr.decode('utf-8'): - raise OSError(proc.stderr.decode('utf-8')) - raise OSError(proc.stdout.decode('utf-8')) - os.chdir(script_path) diff --git a/test/gui/shared/scripts/names.py b/test/gui/shared/scripts/names.py deleted file mode 100644 index b2f2691792..0000000000 --- a/test/gui/shared/scripts/names.py +++ /dev/null @@ -1,80 +0,0 @@ -# encoding: UTF-8 -# fmt: off - -from objectmaphelper import * - -settings_OCC_SettingsDialog = {"name": "Settings", "type": "OCC::SettingsDialog", "visible": 1} -opencloudWizard_OCC_OpencloudWizard = {"name": "opencloudWizard", "type": "OCC::OpencloudWizard", "visible": 1} -qFileDialog_QFileDialog = {"name": "QFileDialog", "type": "QFileDialog", "visible": 1} -settings_stack_QStackedWidget = {"name": "stack", "type": "QStackedWidget", "visible": 1, "window": settings_OCC_SettingsDialog} -settings_dialogStack_QStackedWidget = {"name": "dialogStack", "type": "QStackedWidget", "visible": 1, "window": settings_OCC_SettingsDialog} -qFileDialog_fileNameLabel_QLabel = {"name": "fileNameLabel", "type": "QLabel", "visible": 1, "window": qFileDialog_QFileDialog} -sharingDialog_OCC_ShareDialog = {"name": "SharingDialog", "type": "OCC::ShareDialog", "visible": 1} -sharingDialog_qt_tabwidget_stackedwidget_QStackedWidget = {"name": "qt_tabwidget_stackedwidget", "type": "QStackedWidget", "visible": 1, "window": sharingDialog_OCC_ShareDialog} -qt_tabwidget_stackedwidget_SharingDialogUG_OCC_ShareUserGroupWidget = {"container": sharingDialog_qt_tabwidget_stackedwidget_QStackedWidget, "name": "SharingDialogUG", "type": "OCC::ShareUserGroupWidget", "visible": 1} -sharingDialogUG_scrollArea_QScrollArea = {"container": qt_tabwidget_stackedwidget_SharingDialogUG_OCC_ShareUserGroupWidget, "name": "scrollArea", "type": "QScrollArea", "visible": 1} -settings_settingsdialog_toolbutton_Quit_OpenCloud_QToolButton = {"name": "settingsdialog_toolbutton_Quit openCloud", "type": "QToolButton", "visible": 1, "window": settings_OCC_SettingsDialog} -settings_settingsdialog_toolbutton_Settings_QToolButton = {"name": "settingsdialog_toolbutton_Settings", "type": "QToolButton", "visible": 1, "window": settings_OCC_SettingsDialog} -stack_qt_tabwidget_stackedwidget_QStackedWidget = {"container": settings_stack_QStackedWidget, "name": "qt_tabwidget_stackedwidget", "type": "QStackedWidget", "visible": 1} -qt_tabwidget_stackedwidget_OCC_IssuesWidget_OCC_IssuesWidget = {"container": stack_qt_tabwidget_stackedwidget_QStackedWidget, "name": "OCC__IssuesWidget", "type": "OCC::IssuesWidget", "visible": 1} -sharingDialog_qt_tabwidget_tabbar_QTabBar = {"name": "qt_tabwidget_tabbar", "type": "QTabBar", "visible": 1, "window": sharingDialog_OCC_ShareDialog} -qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget = {"container": sharingDialog_qt_tabwidget_stackedwidget_QStackedWidget, "name": "OCC__ShareLinkWidget", "type": "OCC::ShareLinkWidget", "visible": 1} -oCC_ShareLinkWidget_checkBox_password_QCheckBox = {"container": qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget, "name": "checkBox_password", "type": "QCheckBox", "visible": 1} -oCC_ShareLinkWidget_widget_editing_QWidget = {"container": qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget, "name": "widget_editing", "type": "QWidget", "visible": 1} -oCC_ShareLinkWidget_checkBox_password_QProgressIndicator = {"aboveWidget": oCC_ShareLinkWidget_widget_editing_QWidget, "container": qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget, "leftWidget": oCC_ShareLinkWidget_checkBox_password_QCheckBox, "type": "QProgressIndicator", "unnamed": 1, "visible": 1} -oCC_ShareLinkWidget_linkShares_QTableWidget = {"container": qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget, "name": "linkShares", "type": "QTableWidget", "visible": 1} -oCC_ShareLinkWidget_lineEdit_password_QLineEdit = {"container": qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget, "name": "lineEdit_password", "type": "QLineEdit", "visible": 1} -oCC_ShareLinkWidget_checkBox_expire_QCheckBox = {"container": qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget, "name": "checkBox_expire", "type": "QCheckBox", "visible": 1} -oCC_ShareLinkWidget_checkBox_expire_QProgressIndicator = {"aboveWidget": oCC_ShareLinkWidget_lineEdit_password_QLineEdit, "container": qt_tabwidget_stackedwidget_OCC_ShareLinkWidget_OCC_ShareLinkWidget, "leftWidget": oCC_ShareLinkWidget_checkBox_expire_QCheckBox, "type": "QProgressIndicator", "unnamed": 1, "visible": 1} -settings_settingsdialog_toolbutton_Add_account_QToolButton = {"name": "settingsdialog_toolbutton_Add account", "type": "QToolButton", "visible": 1, "window": settings_OCC_SettingsDialog} -settings_settingsdialog_toolbutton_Activity_QToolButton = {"name": "settingsdialog_toolbutton_Activity", "type": "QToolButton", "visible": 1, "window": settings_OCC_SettingsDialog} -opencloudWizard_urlLabel_QLabel = {"name": "urlLabel", "type": "QLabel", "visible": 1, "window": opencloudWizard_OCC_OpencloudWizard} -setupWizardWindow_OCC_Wizard_SetupWizardWindow = {"name": "SetupWizardWidget", "type": "OCC::Wizard::SetupWizardWidget", "visible": 1} -setupWizardWindow_contentWidget_QStackedWidget = {"name": "contentWidget", "type": "QStackedWidget", "visible": 1, "window": setupWizardWindow_OCC_Wizard_SetupWizardWindow} -insecure_connection_QMessageBox = {"type": "QMessageBox", "unnamed": 1, "visible": 1, "windowTitle": "Insecure connection"} -contentWidget_advancedConfigGroupBox_QGroupBox = {"container": setupWizardWindow_contentWidget_QStackedWidget, "name": "advancedConfigGroupBox", "type": "QGroupBox", "visible": 1} -advancedConfigGroupBox_localDirectoryGroupBox_QGroupBox = {"container": contentWidget_advancedConfigGroupBox_QGroupBox, "name": "localDirectoryGroupBox", "type": "QGroupBox", "visible": 1} -advancedConfigGroupBox_syncModeGroupBox_QGroupBox = {"container": contentWidget_advancedConfigGroupBox_QGroupBox, "name": "syncModeGroupBox", "type": "QGroupBox", "visible": 1} -add_Folder_Sync_Connection_OCC_FolderWizard = {"type": "OCC::FolderWizard", "unnamed": 1, "visible": 1, "windowTitle": "Add Folder Sync Connection"} -add_Folder_Sync_Connection_groupBox_QGroupBox = {"name": "groupBox", "type": "QGroupBox", "visible": 1, "window": add_Folder_Sync_Connection_OCC_FolderWizard} -loginRequiredDialog_OCC_LoginRequiredDialog = {"name": "LoginRequiredDialog", "type": "OCC::LoginRequiredDialog", "visible": 1} -loginRequiredDialog_contentWidget_QStackedWidget = {"name": "contentWidget", "type": "QStackedWidget", "visible": 1, "window": loginRequiredDialog_OCC_LoginRequiredDialog} -contentWidget_contentWidget_QStackedWidget = {"container": setupWizardWindow_contentWidget_QStackedWidget, "name": "contentWidget", "type": "QStackedWidget", "visible": 1} -add_Folder_Sync_Connection_tableView_QTableView = {"name": "tableView","type": "QTableView","visible": 1,"window": add_Folder_Sync_Connection_OCC_FolderWizard} -stack_scrollArea_QScrollArea = {"container": settings_stack_QStackedWidget, "name": "scrollArea", "type": "QScrollArea", "visible": 1} -stack_stackedWidget_QStackedWidget = {"container": settings_stack_QStackedWidget, "name": "stackedWidget", "type": "QStackedWidget", "visible": 1} -stackedWidget_quickWidget_QQuickWidget = {"container": stack_stackedWidget_QStackedWidget, "name": "quickWidget", "type": "QQuickWidget", "visible": 1} -quickWidget_scrollView_ScrollView = {"container": stackedWidget_quickWidget_QQuickWidget, "id": "scrollView", "type": "ScrollView", "unnamed": 1, "visible": True} -scrollView_ListView = {"container": quickWidget_scrollView_ScrollView, "type": "ListView", "unnamed": 1, "visible": True} -dialogStack_quickWidget_QQuickWidget = {"container": settings_dialogStack_QStackedWidget, "name": "quickWidget", "type": "QQuickWidget", "visible": 1} -create_Remote_Folder_QInputDialog = {"type": "QInputDialog", "unnamed": 1, "visible": 1, "windowTitle": "Create Remote Folder"} -create_Remote_Folder_Enter_the_name_of_the_new_folder_to_be_created_below_QLabel = {"text": "Enter the name of the new folder to be created below '/':", "type": "QLabel", "unnamed": 1, "visible": 1, "window": create_Remote_Folder_QInputDialog} -groupBox_folderTreeWidget_QTreeWidget = {"container": add_Folder_Sync_Connection_groupBox_QGroupBox, "name": "folderTreeWidget", "type": "QTreeWidget", "visible": 1} -confirm_Folder_Sync_Connection_Removal_QMessageBox = {"type": "QMessageBox", "unnamed": 1, "visible": 1, "windowTitle": "Confirm Folder Sync Connection Removal"} -stackedWidget_quickWidget_OCC_QmlUtils_OCQuickWidget = {"container": stack_stackedWidget_QStackedWidget, "name": "quickWidget", "type": "OCC::QmlUtils::OCQuickWidget", "visible": 1} -qt_tabwidget_stackedwidget_OCC_ProtocolWidget_OCC_ProtocolWidget = {"container": stack_qt_tabwidget_stackedwidget_QStackedWidget, "name": "OCC__ProtocolWidget", "type": "OCC::ProtocolWidget", "visible": 1} -oCC_ProtocolWidget_tableView_QTableView = {"container": qt_tabwidget_stackedwidget_OCC_ProtocolWidget_OCC_ProtocolWidget, "name": "_tableView", "type": "QTableView", "visible": 1} -oCC_IssuesWidget_tableView_QTableView = {"container": qt_tabwidget_stackedwidget_OCC_IssuesWidget_OCC_IssuesWidget, "name": "_tableView", "type": "QTableView", "visible": 1} -dialogStack_quickWidget_OCC_QmlUtils_OCQuickWidget = {"container": settings_dialogStack_QStackedWidget, "name": "quickWidget", "type": "OCC::QmlUtils::OCQuickWidget", "visible": 1} -contentWidget_OCC_QmlUtils_OCQuickWidget = {"container": contentWidget_contentWidget_QStackedWidget, "type": "OCC::QmlUtils::OCQuickWidget", "unnamed": 1, "visible": 1} -stackedWidget_Add_Folder_Sync_Connection_QGroupBox = {"container": stack_stackedWidget_QStackedWidget, "title": "Add Folder Sync Connection", "type": "QGroupBox", "unnamed": 1, "visible": 1} -stackedWidget_groupBox_QGroupBox = {"container": settings_stack_QStackedWidget, "name": "groupBox", "type": "QGroupBox", "visible": 1} -groupBox_OCC_QmlUtils_OCQuickWidget = {"container": stackedWidget_groupBox_QGroupBox, "type": "OCC::QmlUtils::OCQuickWidget", "unnamed": 1, "visible": 1} -quickWidget_Overlay = {"container": stackedWidget_quickWidget_OCC_QmlUtils_OCQuickWidget, "type": "Overlay", "unnamed": 1, "visible": True} -scrollView_moreButton_Image = {"container": quickWidget_scrollView_ScrollView, "id": "moreButton", "source": "image://opencloud?theme=fontawesome&icon=&enabled=true&size=undefined", "type": "Image", "unnamed": 1, "visible": True} -pause_sync_MenuItem = {"checkable": False, "container": quickWidget_Overlay, "enabled": True, "text": "Pause sync", "type": "MenuItem", "unnamed": 1, "visible": True} -quit_OpenCloud_Desktop_QMessageBox = {"type": "QMessageBox", "unnamed": 1, "visible": 1, "windowTitle": "Quit OpenCloud Desktop"} -stackedWidget_Add_Space_QGroupBox = {"container": stack_stackedWidget_QStackedWidget, "title": "Add Space", "type": "QGroupBox", "unnamed": 1, "visible": 1} -add_Space_label_QLabel = {"container": stackedWidget_Add_Space_QGroupBox, "name": "label", "type": "QLabel", "visible": 1} -add_Space_qt_passive_wizardbutton0_QPushButton = {"container": stackedWidget_Add_Space_QGroupBox, "name": "__qt__passive_wizardbutton0", "type": "QPushButton", "visible": 1} -add_Space_Deselect_remote_folders_you_do_not_wish_to_synchronize_QLabel = {"container": stackedWidget_Add_Space_QGroupBox, "text": "Deselect remote folders you do not wish to synchronize.", "type": "QLabel", "unnamed": 1, "visible": 1} -add_Space_Deselect_remote_folders_you_do_not_wish_to_synchronize_QTreeWidget = {"aboveWidget": add_Space_Deselect_remote_folders_you_do_not_wish_to_synchronize_QLabel, "container": stackedWidget_Add_Space_QGroupBox, "type": "QTreeWidget", "unnamed": 1, "visible": 1} -add_Space_qt_passive_wizardbutton1_QPushButton = {"container": stackedWidget_Add_Space_QGroupBox, "name": "__qt__passive_wizardbutton1", "type": "QPushButton", "visible": 1} -remove_Space_MenuItem = {"checkable": False, "container": quickWidget_Overlay, "enabled": True, "text": "Remove Space", "type": "MenuItem", "unnamed": 1, "visible": True} -confirm_removal_of_Space_QMessageBox = {"type": "QMessageBox", "unnamed": 1, "visible": 1, "windowTitle": "Confirm removal of Space"} -deselect_remote_folders_you_do_not_wish_to_synchronize_OpenCloud_QModelIndex = {"column": 0, "container": add_Space_Deselect_remote_folders_you_do_not_wish_to_synchronize_QTreeWidget, "text": "Personal", "type": "QModelIndex"} -folderError_Container = {"container": quickWidget_scrollView_ScrollView, "type": "FolderError"} -groupBox_Deselect_remote_folders_you_do_not_wish_to_synchronize_QLabel = {"container": stackedWidget_groupBox_QGroupBox, "text": "Deselect remote folders you do not wish to synchronize.", "type": "QLabel", "unnamed": 1, "visible": 1} -groupBox_Deselect_remote_folders_you_do_not_wish_to_synchronize_QTreeWidget = {"aboveWidget": groupBox_Deselect_remote_folders_you_do_not_wish_to_synchronize_QLabel, "container": stackedWidget_groupBox_QGroupBox, "type": "QTreeWidget", "unnamed": 1, "visible": 1} -deselect_remote_folders_you_do_not_wish_to_synchronize_Personal_QModelIndex = {"column": 0, "container": groupBox_Deselect_remote_folders_you_do_not_wish_to_synchronize_QTreeWidget, "text": "Personal", "type": "QModelIndex"} -stackedWidget_OK_QPushButton = {"container": stack_stackedWidget_QStackedWidget, "text": "OK", "type": "QPushButton", "unnamed": 1, "visible": 1} diff --git a/test/gui/shared/scripts/pageObjects/AccountSetting.py b/test/gui/shared/scripts/pageObjects/AccountSetting.py deleted file mode 100644 index 8071bb0de4..0000000000 --- a/test/gui/shared/scripts/pageObjects/AccountSetting.py +++ /dev/null @@ -1,168 +0,0 @@ -import names -import squish - -from helpers.UserHelper import get_displayname_for_user -from helpers.SetupClientHelper import substitute_inline_codes - -from pageObjects.Toolbar import Toolbar - - -class AccountSetting: - MANAGE_ACCOUNT_BUTTON = { - "container": names.stackedWidget_quickWidget_OCC_QmlUtils_OCQuickWidget, - "id": "manageAccountButton", - "text": "Manage Account", - "type": "Button", - "visible": 1, - } - ACCOUNT_MENU = { - "checkable": False, - "container": names.quickWidget_Overlay, - "text": "", - "enabled": True, - "type": "MenuItem", - "unnamed": 1, - "visible": True - } - CONFIRM_REMOVE_CONNECTION_BUTTON = { - "container": names.settings_dialogStack_QStackedWidget, - "text": "Remove connection", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - } - ACCOUNT_CONNECTION_LABEL = { - "container": names.stackedWidget_quickWidget_OCC_QmlUtils_OCQuickWidget, - "type": "Label", - "visible": 1 - } - LOG_BROWSER_WINDOW = { - "name": "OCC__LogBrowser", - "type": "OCC::LogBrowser", - "visible": 1, - } - ACCOUNT_LOADING = { - "window": names.settings_OCC_SettingsDialog, - "name": "loadingPage", - "type": "QWidget", - "visible": 0, - } - DIALOG_STACK = { - "name": "dialogStack", - "type": "QStackedWidget", - "visible": 1, - "window": names.settings_OCC_SettingsDialog, - } - CONFIRMATION_YES_BUTTON = {"text": "Yes", "type": "QPushButton", "visible": 1} - - @staticmethod - def account_action(action): - squish.mouseClick(squish.waitForObject(AccountSetting.MANAGE_ACCOUNT_BUTTON)) - selector = AccountSetting.ACCOUNT_MENU.copy() - selector['text'] = action - squish.mouseClick( - squish.waitForObject(selector) - ) - - @staticmethod - def remove_account_connection(): - AccountSetting.account_action("Remove") - squish.clickButton( - squish.waitForObject(AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON) - ) - - @staticmethod - def logout(): - AccountSetting.account_action("Log out") - - @staticmethod - def login(): - AccountSetting.account_action("Log in") - - @staticmethod - def get_account_connection_label(): - return str( - squish.waitForObjectExists(AccountSetting.ACCOUNT_CONNECTION_LABEL).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 = squish.waitFor( - 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): - displayname = get_displayname_for_user(username) - displayname = substitute_inline_codes(displayname) - Toolbar.open_account(displayname) - AccountSetting.remove_account_connection() diff --git a/test/gui/shared/scripts/pageObjects/Activity.py b/test/gui/shared/scripts/pageObjects/Activity.py deleted file mode 100644 index 01be75e6ad..0000000000 --- a/test/gui/shared/scripts/pageObjects/Activity.py +++ /dev/null @@ -1,303 +0,0 @@ -import names -import squish -from objectmaphelper import RegularExpression - -from helpers.FilesHelper import build_conflicted_regex -from helpers.ConfigHelper import get_config - - -class Activity: - TAB_CONTAINER = { - "container": names.settings_dialogStack_QStackedWidget, - "type": "QTabWidget", - "visible": 1, - } - SUBTAB_CONTAINER = { - "container": names.settings_dialogStack_QStackedWidget, - "name": "qt_tabwidget_tabbar", - "type": "QTabBar", - "visible": 1, - } - NOT_SYNCED_TABLE = { - "container": names.qt_tabwidget_stackedwidget_OCC_IssuesWidget_OCC_IssuesWidget, - "name": "_tableView", - "type": "QTableView", - "visible": 1, - } - LOCAL_ACTIVITY_FILTER_BUTTON = { - "container": names.qt_tabwidget_stackedwidget_OCC_ProtocolWidget_OCC_ProtocolWidget, - "name": "_filterButton", - "type": "QPushButton", - "visible": 1, - } - SYNCED_ACTIVITY_FILTER_OPTION_SELECTOR = { - "type": "QMenu", - "unnamed": 1, - "visible": 1, - "window": names.settings_OCC_SettingsDialog, - } - SYNCED_ACTIVITY_TABLE = { - "container": names.qt_tabwidget_stackedwidget_OCC_ProtocolWidget_OCC_ProtocolWidget, - "name": "_tableView", - "type": "QTableView", - "visible": 1, - } - NOT_SYNCED_FILTER_BUTTON = { - "container": names.qt_tabwidget_stackedwidget_OCC_IssuesWidget_OCC_IssuesWidget, - "name": "_filterButton", - "type": "QPushButton", - "visible": 1, - } - NOT_SYNCED_FILTER_OPTION_SELECTOR = { - "type": "QMenu", - "unnamed": 1, - "visible": 1, - "window": names.settings_OCC_SettingsDialog, - } - SYNCED_ACTIVITY_TABLE_HEADER_SELECTOR = { - "container": names.oCC_ProtocolWidget_tableView_QTableView, - "name": "ActivityListHeaderV2", - "orientation": 1, - "type": "OCC::ExpandingHeaderView", - "visible": 1, - } - NOT_SYNCED_ACTIVITY_TABLE_HEADER_SELECTOR = { - "container": names.oCC_IssuesWidget_tableView_QTableView, - "name": "ActivityErrorListHeaderV2", - "orientation": 1, - "type": "OCC::ExpandingHeaderView", - "visible": 1, - } - - @staticmethod - def get_tab_object(tab_index): - return { - "container": Activity.SUBTAB_CONTAINER, - "index": tab_index, - "type": "TabItem", - } - - @staticmethod - def get_tab_text(tab_index): - return squish.waitForObjectExists(Activity.get_tab_object(tab_index)).text - - @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): - tab_found = False - - # NOTE: Some activity tabs are loaded dynamically - # and the tab index changes after all the tabs are loaded properly - # So wait for a second to let the UI render the tabs properly - # before trying to click the tab - squish.snooze(get_config("lowestSyncTimeout")) - - # Selecting tab by name fails for "Not Synced" when there are no unsynced files - # Because files count will be appended like "Not Synced (2)" - # So to overcome this the following approach has been implemented - tab_count = squish.waitForObjectExists(Activity.SUBTAB_CONTAINER).count - tabs = [] - for index in range(tab_count): - tab_text = Activity.get_tab_text(index) - tabs.append(tab_text) - - if tab_name in tab_text: - tab_found = True - # click_tab becomes flaky with "Not Synced" tab - # because the tab text changes. e.g. "Not Synced (2)" - # squish.click_tab(Activity.TAB_CONTAINER, tab_text) - - # NOTE: If only the objectOrName is specified, - # the object is clicked in the middle by the Qt::LeftButton button - # and with no keyboard modifiers pressed. - squish.mouseClick( - squish.waitForObjectExists(Activity.get_tab_object(index)) - ) - break - - if not tab_found: - raise LookupError( - "Tab not found: " - + tab_name - + " in " - + str(tabs) - + ". Tabs count: " - + str(tab_count) - ) - - @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: - file_row = squish.waitForObject( - Activity.get_not_synced_file_selector(filename), - get_config("lowestSyncTimeout") * 1000, - )["row"] - if Activity.get_not_synced_status(file_row) == status: - return True - return False - except: - return False - - @staticmethod - def select_synced_filter(sync_filter): - squish.clickButton(squish.waitForObject(Activity.LOCAL_ACTIVITY_FILTER_BUTTON)) - squish.activateItem( - squish.waitForObjectItem( - Activity.SYNCED_ACTIVITY_FILTER_OPTION_SELECTOR, sync_filter - ) - ) - - @staticmethod - def get_synced_file_selector(resource): - return { - "column": Activity.get_synced_table_column_number_by_name("File"), - "container": Activity.SYNCED_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 check_synced_table(resource, action, account): - try: - file_row = squish.waitForObject( - Activity.get_synced_file_selector(resource), - get_config("lowestSyncTimeout") * 1000, - )["row"] - squish.waitForObjectExists( - { - "column": Activity.get_synced_table_column_number_by_name("Action"), - "row": file_row, - "container": Activity.SYNCED_ACTIVITY_TABLE, - "text": action, - "type": "QModelIndex", - } - ) - squish.waitForObjectExists( - { - "column": Activity.get_synced_table_column_number_by_name( - "Account" - ), - "row": file_row, - "container": Activity.SYNCED_ACTIVITY_TABLE, - "text": account, - "type": "QModelIndex", - } - ) - 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 diff --git a/test/gui/shared/scripts/pageObjects/EnterPassword.py b/test/gui/shared/scripts/pageObjects/EnterPassword.py deleted file mode 100644 index 769b712300..0000000000 --- a/test/gui/shared/scripts/pageObjects/EnterPassword.py +++ /dev/null @@ -1,73 +0,0 @@ -import names -import squish - - -from helpers.WebUIHelper import authorize_via_webui -from helpers.ConfigHelper import get_config - - -class EnterPassword: - LOGIN_CONTAINER = { - "name": "LoginRequiredDialog", - "type": "OCC::LoginRequiredDialog", - "visible": 1, - } - LOGIN_USER_LABEL = { - "container": names.groupBox_OCC_QmlUtils_OCQuickWidget, - "type": "Label", - "visible": True, - } - USERNAME_BOX = { - "name": "usernameLineEdit", - "type": "QLineEdit", - "visible": 1, - "window": LOGIN_CONTAINER, - } - LOGOUT_BUTTON = { - "container": names.groupBox_OCC_QmlUtils_OCQuickWidget, - "id": "logOutButton", - "type": "Button", - "visible": True, - } - COPY_URL_TO_CLIPBOARD_BUTTON = { - "container": names.groupBox_OCC_QmlUtils_OCQuickWidget, - "id": "copyToClipboardButton", - "type": "Button", - "visible": True, - } - TLS_CERT_WINDOW = { - "name": "OCC__TlsErrorDialog", - "type": "OCC::TlsErrorDialog", - "visible": 1, - } - ACCEPT_CERTIFICATE_YES = { - "text": "Yes", - "type": "QPushButton", - "visible": 1, - "window": TLS_CERT_WINDOW, - } - - def __init__(self, occurrence=1): - if occurrence > 1: - self.TLS_CERT_WINDOW.update({"occurrence": occurrence}) - - def get_username(self): - # Parse username from the login label: - label = str(squish.waitForObjectExists(self.LOGIN_USER_LABEL).text) - username = label.split(" ", maxsplit=2)[1] - return username.capitalize() - - def oidc_relogin(self, username, password): - # wait 500ms for copy button to fully load - squish.snooze(1 / 2) - squish.mouseClick(squish.waitForObject(self.COPY_URL_TO_CLIPBOARD_BUTTON)) - 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): - squish.clickButton(squish.waitForObject(self.ACCEPT_CERTIFICATE_YES)) diff --git a/test/gui/shared/scripts/pageObjects/Settings.py b/test/gui/shared/scripts/pageObjects/Settings.py deleted file mode 100644 index cb134237fa..0000000000 --- a/test/gui/shared/scripts/pageObjects/Settings.py +++ /dev/null @@ -1,96 +0,0 @@ -import names -import squish - - -class Settings: - CHECKBOX_OPTION_ITEM = { - "container": names.stack_scrollArea_QScrollArea, - "type": "QCheckBox", - "visible": 1, - } - NETWORK_OPTION_ITEM = { - "container": names.stack_scrollArea_QScrollArea, - "type": "QGroupBox", - "visible": 1, - } - ABOUT_BUTTON = { - "container": names.settings_stack_QStackedWidget, - "name": "about_pushButton", - "type": "QPushButton", - "visible": 1, - } - ABOUT_DIALOG = { - "name": "OCC__AboutDialog", - "type": "OCC::AboutDialog", - "visible": 1, - } - ABOUT_DIALOG_OK_BUTTON = { - "text": "OK", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - "window": ABOUT_DIALOG, - } - - GENERAL_OPTIONS_MAP = { - "Start on Login": "autostartCheckBox", - "Use Monochrome Icons in the system tray": "monoIconsCheckBox", - "Language": "languageDropdown", - "Show desktop Notifications": "desktopNotificationsCheckBox", - } - ADVANCED_OPTION_MAP = { - "Sync hidden files": "syncHiddenFilesCheckBox", - "Show crash reporter": "", - "Edit ignored files": "ignoredFilesButton", - "Log settings": "logSettingsButton", - "Ask for confirmation before synchronizing folders larger than 500 MB": "newFolderLimitCheckBox", - "Ask for confirmation before synchronizing external storages": "newExternalStorage", - } - NETWORK_OPTION_MAP = { - "Proxy Settings": "proxyGroupBox", - "Download Bandwidth": "downloadBox", - "Upload Bandwidth": "uploadBox", - } - - @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 check_general_option(option): - selector = Settings.GENERAL_OPTIONS_MAP[option] - squish.waitForObjectExists(Settings.get_checkbox_option_selector(selector)) - - @staticmethod - def check_advanced_option(option): - selector = Settings.ADVANCED_OPTION_MAP[option] - squish.waitForObjectExists(Settings.get_checkbox_option_selector(selector)) - - @staticmethod - def check_network_option(option): - selector = Settings.NETWORK_OPTION_MAP[option] - squish.waitForObjectExists(Settings.get_network_option_selector(selector)) - - @staticmethod - def open_about_button(): - squish.clickButton(squish.waitForObject(Settings.ABOUT_BUTTON)) - - @staticmethod - def wait_for_about_dialog_to_be_visible(): - squish.waitForObjectExists(Settings.ABOUT_DIALOG) - - @staticmethod - def close_about_dialog(): - squish.clickButton(squish.waitForObjectExists(Settings.ABOUT_DIALOG_OK_BUTTON)) diff --git a/test/gui/shared/scripts/pageObjects/SyncConnection.py b/test/gui/shared/scripts/pageObjects/SyncConnection.py deleted file mode 100644 index c93f6e69c0..0000000000 --- a/test/gui/shared/scripts/pageObjects/SyncConnection.py +++ /dev/null @@ -1,142 +0,0 @@ -import names -import squish -import object # pylint: disable=redefined-builtin - -from helpers.ConfigHelper import get_config - - -class SyncConnection: - WAIT_ERROR_LABEL_TIMEOUT = 10 - - FOLDER_SYNC_CONNECTION_LIST = { - "container": names.quickWidget_scrollView_ScrollView, - "type": "ListView", - "visible": True, - } - FOLDER_SYNC_CONNECTION = { - "container": names.settings_stack_QStackedWidget, - "name": "_folderList", - "type": "QListView", - "visible": 1, - } - FOLDER_SYNC_CONNECTION_MENU_BUTTON = { - "container": names.quickWidget_scrollView_ScrollView, - "id": "moreButton", - "type": "Image", - "visible": True - } - MENU = { - "checkable": False, - "container": names.quickWidget_Overlay, - "enabled": True, - "text": "", - "type": "MenuItem", - "unnamed": 1, - "visible": True - } - SELECTIVE_SYNC_APPLY_BUTTON = { - "container": names.settings_stack_QStackedWidget, - "name": "selectiveSyncApply", - "type": "QPushButton", - "visible": 1, - } - CANCEL_FOLDER_SYNC_CONNECTION_DIALOG = { - "text": "Cancel", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - "window": names.confirm_Folder_Sync_Connection_Removal_QMessageBox, - } - REMOVE_FOLDER_SYNC_CONNECTION_BUTTON = { - "text": "Remove Space", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - "window": names.confirm_removal_of_Space_QMessageBox, - } - PERMISSION_ERROR_LABEL = { - "container": names.folderError_Container, - "type": "Label", - "visible": True - } - - @staticmethod - def open_menu(): - menu_button = squish.waitForObject( - SyncConnection.FOLDER_SYNC_CONNECTION_MENU_BUTTON - ) - squish.mouseClick(menu_button) - - @staticmethod - def perform_action(action): - SyncConnection.open_menu() - selector = SyncConnection.MENU.copy() - selector['text'] = action - squish.mouseClick( - squish.waitForObject(selector) - ) - - @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) - - @staticmethod - def menu_item_exists(menu_item): - obj = SyncConnection.MENU.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 get_folder_connection_count(): - return squish.waitForObject(SyncConnection.FOLDER_SYNC_CONNECTION_LIST).count - - @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(): - squish.clickButton( - squish.waitForObject(SyncConnection.REMOVE_FOLDER_SYNC_CONNECTION_BUTTON) - ) - - @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, - SyncConnection.WAIT_ERROR_LABEL_TIMEOUT * 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) diff --git a/test/gui/shared/scripts/pageObjects/SyncConnectionWizard.py b/test/gui/shared/scripts/pageObjects/SyncConnectionWizard.py deleted file mode 100644 index f876342d36..0000000000 --- a/test/gui/shared/scripts/pageObjects/SyncConnectionWizard.py +++ /dev/null @@ -1,345 +0,0 @@ -from os import path -import names -import squish - -from helpers.SetupClientHelper import ( - get_current_user_sync_path, - set_current_user_sync_path, -) -from helpers.ConfigHelper import get_config - - -class SyncConnectionWizard: - CHOOSE_LOCAL_SYNC_FOLDER = { - "buddy": names.add_Space_label_QLabel, - "name": "localFolderLineEdit", - "type": "QLineEdit", - "visible": 1 - } - BACK_BUTTON = { - "window": names.stackedWidget_Add_Space_QGroupBox, - "name": "__qt__passive_wizardbutton0", - "type": "QPushButton", - "visible": 1, - } - NEXT_BUTTON = { - "window": names.stackedWidget_Add_Space_QGroupBox, - "name": "__qt__passive_wizardbutton1", - "type": "QPushButton", - "visible": 1, - } - SELECTIVE_SYNC_ROOT_FOLDER = { - "column": 0, - "container": names.add_Space_Deselect_remote_folders_you_do_not_wish_to_synchronize_QTreeWidget, - "text": "Personal", - "type": "QModelIndex", - } - ADD_SPACE_FOLDER_TREE = { - "column": 0, - "container": names.deselect_remote_folders_you_do_not_wish_to_synchronize_OpenCloud_QModelIndex, - "type": "QModelIndex", - } - ADD_SYNC_CONNECTION_BUTTON = { - "name": "qt_wizard_finish", - "type": "QPushButton", - "visible": 1, - "window": names.stackedWidget_Add_Space_QGroupBox, - } - REMOTE_FOLDER_TREE = { - "container": names.add_Folder_Sync_Connection_groupBox_QGroupBox, - "name": "folderTreeWidget", - "type": "QTreeWidget", - "visible": 1, - } - SELECTIVE_SYNC_TREE_HEADER = { - "container": names.add_Space_Deselect_remote_folders_you_do_not_wish_to_synchronize_QTreeWidget, - "orientation": 1, - "type": "QHeaderView", - "unnamed": 1, - "visible": 1, - } - CANCEL_FOLDER_SYNC_CONNECTION_WIZARD = { - "window": names.stackedWidget_Add_Space_QGroupBox, - "name": "qt_wizard_cancel", - "type": "QPushButton", - "visible": 1, - } - SPACE_NAME_SELECTOR = { - "container": names.quickWidget_scrollView_ScrollView, - "type": "Label", - "visible": True, - } - CREATE_REMOTE_FOLDER_BUTTON = { - "container": names.add_Folder_Sync_Connection_groupBox_QGroupBox, - "name": "addFolderButton", - "type": "QPushButton", - "visible": 1, - } - CREATE_REMOTE_FOLDER_INPUT = { - "buddy": names.create_Remote_Folder_Enter_the_name_of_the_new_folder_to_be_created_below_QLabel, - "type": "QLineEdit", - "unnamed": 1, - "visible": 1, - } - CREATE_REMOTE_FOLDER_CONFIRM_BUTTON = { - "text": "OK", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - "window": names.create_Remote_Folder_QInputDialog, - } - REFRESH_BUTTON = { - "container": names.add_Folder_Sync_Connection_groupBox_QGroupBox, - "name": "refreshButton", - "type": "QPushButton", - "visible": 1, - } - REMOTE_FOLDER_SELECTION_INPUT = { - "name": "folderEntry", - "type": "QLineEdit", - "visible": 1, - "window": names.add_Folder_Sync_Connection_OCC_FolderWizard, - } - ADD_FOLDER_SYNC_BUTTON = { - "checkable": False, - "container": names.stackedWidget_quickWidget_OCC_QmlUtils_OCQuickWidget, - "id": "addSyncButton", - "type": "Button", - "unnamed": 1, - "visible": True, - } - WARN_LABEL = { - "window": names.add_Folder_Sync_Connection_OCC_FolderWizard, - "name": "warnLabel", - "type": "QLabel", - "visible": 1, - } - - CHOOSE_WHAT_TO_SYNC_FOLDER_TREE = { - "column": 0, - "container": names.deselect_remote_folders_you_do_not_wish_to_synchronize_Personal_QModelIndex, - "type": "QModelIndex", - } - - @staticmethod - def set_sync_path_oc(sync_path): - if not sync_path: - sync_path = get_current_user_sync_path() - squish.type( - squish.waitForObject(SyncConnectionWizard.CHOOSE_LOCAL_SYNC_FOLDER), - "", - ) - squish.type( - SyncConnectionWizard.CHOOSE_LOCAL_SYNC_FOLDER, - sync_path, - ) - SyncConnectionWizard.next_step() - - @staticmethod - def set_sync_path(sync_path=""): - SyncConnectionWizard.set_sync_path_oc(sync_path) - - @staticmethod - def next_step(): - squish.clickButton(squish.waitForObject(SyncConnectionWizard.NEXT_BUTTON)) - - @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(): - # NOTE: checkbox does not have separate object - # click on (11,11) which is a checkbox - squish.mouseClick( - squish.waitForObject(SyncConnectionWizard.SELECTIVE_SYNC_ROOT_FOLDER), - 11, - 11, - squish.Qt.NoModifier, - squish.Qt.LeftButton, - ) - - - @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(): - squish.clickButton( - squish.waitForObject(SyncConnectionWizard.ADD_SYNC_CONNECTION_BUTTON) - ) - - @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(): - squish.clickButton( - squish.waitForObject( - SyncConnectionWizard.CANCEL_FOLDER_SYNC_CONNECTION_WIZARD - ) - ) - - @staticmethod - def select_space(space_name): - selector = SyncConnectionWizard.SPACE_NAME_SELECTOR.copy() - selector["text"] = space_name - squish.mouseClick(squish.waitForObject(selector)) - - @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, should_select=True, new_sync_connection_wizard=False): - if should_select: - # First deselect all - SyncConnectionWizard.deselect_all_remote_folders() - folder_tree_locator = SyncConnectionWizard.get_folder_tree_locator(new_sync_connection_wizard) - for folder in folders: - folder_levels = folder.strip("/").split("/") - parent_selector = None - for sub_folder in folder_levels: - if not parent_selector: - folder_tree_locator["text"] = sub_folder - parent_selector = folder_tree_locator - selector = parent_selector - else: - selector = { - "column": "0", - "container": parent_selector, - "text": sub_folder, - "type": "QModelIndex", - } - if ( - len(folder_levels) == 1 - or folder_levels.index(sub_folder) == len(folder_levels) - 1 - ): - # NOTE: checkbox does not have separate object - # click on (11,11) which is a checkbox - squish.mouseClick( - squish.waitForObject(selector), - 11, - 11, - squish.Qt.NoModifier, - squish.Qt.LeftButton, - ) - else: - squish.doubleClick(squish.waitForObject(selector)) - - @staticmethod - def confirm_choose_what_to_sync_selection(): - squish.clickButton(squish.waitForObject(names.stackedWidget_OK_QPushButton)) - - @staticmethod - def __handle_folder_selection(folders, should_select, new_sync_connection_wizard): - SyncConnectionWizard.select_or_unselect_folders_to_sync( - folders, - should_select=should_select, - new_sync_connection_wizard=new_sync_connection_wizard - ) - - 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 - ) - - @staticmethod - def get_folder_tree_locator(new_sync_connection_wizard=False): - return ( - SyncConnectionWizard.ADD_SPACE_FOLDER_TREE.copy() - if new_sync_connection_wizard - else SyncConnectionWizard.CHOOSE_WHAT_TO_SYNC_FOLDER_TREE.copy() - ) diff --git a/test/gui/shared/scripts/pageObjects/Toolbar.py b/test/gui/shared/scripts/pageObjects/Toolbar.py deleted file mode 100644 index be66fa984e..0000000000 --- a/test/gui/shared/scripts/pageObjects/Toolbar.py +++ /dev/null @@ -1,157 +0,0 @@ -import names -import squish -import object # pylint: disable=redefined-builtin - -from helpers.SetupClientHelper import wait_until_app_killed -from helpers.ConfigHelper import get_config - - -class Toolbar: - TOOLBAR_ROW = { - "container": names.dialogStack_quickWidget_OCC_QmlUtils_OCQuickWidget, - "type": "RowLayout", - "visible": True, - } - ACCOUNT_BUTTON = { - "checkable": False, - "container": names.dialogStack_quickWidget_OCC_QmlUtils_OCQuickWidget, - "type": "AccountButton", - "visible": True, - } - ADD_ACCOUNT_BUTTON = { - "container": names.dialogStack_quickWidget_QQuickWidget, - "id": "addAccountButton", - "type": "AccountButton", - "visible": True, - } - ACTIVITY_BUTTON = { - "container": names.dialogStack_quickWidget_QQuickWidget, - "id": "logButton", - "type": "AccountButton", - "visible": True, - } - SETTINGS_BUTTON = { - "container": names.dialogStack_quickWidget_QQuickWidget, - "id": "settingsButton", - "type": "AccountButton", - "visible": True, - } - QUIT_BUTTON = { - "container": names.dialogStack_quickWidget_QQuickWidget, - "id": "quitButton", - "type": "AccountButton", - "visible": True, - } - CONFIRM_QUIT_BUTTON = { - "text": "Yes", - "type": "QPushButton", - "unnamed": 1, - "visible": 1, - "window": names.quit_OpenCloud_Desktop_QMessageBox, - } - - TOOLBAR_ITEMS = ["Add Account", "Activity", "Settings", "Quit"] - - @staticmethod - def get_item_selector(item_name): - return { - "container": names.dialogStack_quickWidget_QQuickWidget, - "text": item_name, - "type": "Label", - "visible": True, - } - - @staticmethod - def has_item(item_name, timeout=get_config("minSyncTimeout") * 1000): - try: - squish.waitForObject(Toolbar.get_item_selector(item_name), timeout) - return True - except: - return False - - @staticmethod - def open_activity(): - squish.mouseClick(squish.waitForObject(Toolbar.ACTIVITY_BUTTON)) - - @staticmethod - def open_new_account_setup(): - squish.mouseClick(squish.waitForObject(Toolbar.ADD_ACCOUNT_BUTTON)) - - @staticmethod - def open_account(displayname): - _, selector = Toolbar.get_account(displayname) - squish.mouseClick(squish.waitForObject(selector)) - - @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(): - squish.mouseClick(squish.waitForObject(Toolbar.SETTINGS_BUTTON)) - - @staticmethod - def quit_opencloud(): - squish.mouseClick(squish.waitForObject(Toolbar.QUIT_BUTTON)) - squish.clickButton(squish.waitForObject(Toolbar.CONFIRM_QUIT_BUTTON)) - for ctx in squish.applicationContextList(): - pid = ctx.pid - ctx.detach() - wait_until_app_killed(pid) - - @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_BUTTON.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(display_name): - accounts, selectors = Toolbar.get_accounts() - return accounts.get(display_name), selectors.get(display_name) - - @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(display_name): - account, selector = Toolbar.get_account(display_name) - return account["current"] and squish.waitForObject(selector).checked - - @staticmethod - def account_exists(display_name): - account, selector = Toolbar.get_account(display_name) - if ( - account is None - or selector is None - and account["displayname"] != display_name - ): - raise LookupError(f'Account "{display_name}" does not exist') - squish.waitForObject(selector) diff --git a/test/gui/shared/steps/server_context.py b/test/gui/shared/steps/server_context.py deleted file mode 100644 index 89ab6e2fa6..0000000000 --- a/test/gui/shared/steps/server_context.py +++ /dev/null @@ -1,127 +0,0 @@ -from helpers.api import provisioning, webdav_helper as webdav - -from pageObjects.Toolbar import Toolbar - - -@Then( - r'^as "([^"].*)" (?:file|folder) "([^"].*)" should not exist in the server', - regexp=True, -) -def step(context, user_name, resource_name): - test.compare( - webdav.resource_exists(user_name, resource_name), - False, - f"Resource '{resource_name}' should not exist, but does", - ) - - -@Then( - r'^as "([^"].*)" (?:file|folder) "([^"].*)" should exist in the server', regexp=True -) -def step(context, user_name, resource_name): - test.compare( - webdav.resource_exists(user_name, resource_name), - True, - f"Resource '{resource_name}' should exist, but does not", - ) - - -@Then('as "|any|" the file "|any|" should have the content "|any|" in the server') -def step(context, user_name, file_name, content): - text_content = webdav.get_file_content(user_name, file_name) - test.compare( - text_content, - content, - f"File '{file_name}' should have content '{content}' but found '{text_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 "|any|" has created folder "|any|" in the server') -def step(context, user, folder_name): - webdav.create_folder(user, folder_name) - - -@Given('user "|any|" has uploaded file with content "|any|" to "|any|" 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 "|any|" uploads file with content "|any|" to "|any|" in the server') -def step(context, user, file_content, file_name): - webdav.create_file(user, file_name, file_content) - - -@When('user "|any|" deletes the folder "|any|" in the server') -def step(context, user, folder_name): - webdav.delete_resource(user, folder_name) - - -@Given('user "|any|" has been created in the server with default attributes') -def step(context, user): - provisioning.create_user(user) - - -@Given('user "|any|" has uploaded file "|any|" to "|any|" 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): - server_content = webdav.get_file_content(user_name, server_file_name) - local_content = open(get_file_for_upload(local_file_name), "rb").read() - - 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 "|any|" has sent the following resource share invitation:') -def step(context, user): - resource_details = {row[0]: row[1] for row in context.table} - webdav.send_resource_share_invitation( - user, - resource_details['resource'], - resource_details['sharee'], - resource_details['permissionsRole'] - ) diff --git a/test/gui/shared/steps/vfs_context.py b/test/gui/shared/steps/vfs_context.py deleted file mode 100644 index febd6c5214..0000000000 --- a/test/gui/shared/steps/vfs_context.py +++ /dev/null @@ -1,23 +0,0 @@ -from helpers.FilesHelper import get_file_size_on_disk, get_file_size -from helpers.SetupClientHelper import get_resource_path - - -@Then('the placeholder of file "|any|" should exist on the file system') -def step(context, file_name): - resource_path = get_resource_path(file_name) - size_on_disk = get_file_size_on_disk(resource_path) - test.compare( - size_on_disk, 0, f"Size of the placeholder on the disk is: '{size_on_disk}'" - ) - - -@Then('the file "|any|" should be downloaded') -def step(context, file_name): - resource_path = get_resource_path(file_name) - size_on_disk = get_file_size_on_disk(resource_path) - file_size = get_file_size(resource_path) - test.compare( - size_on_disk, - file_size, - f"Original file size '{file_size}' is not equal to its size on disk '{size_on_disk}'", - ) diff --git a/test/gui/shared/verificationPoints/publicLinkExpirationProgressIndicatorInvisible b/test/gui/shared/verificationPoints/publicLinkExpirationProgressIndicatorInvisible deleted file mode 100644 index cd9a1357e4..0000000000 --- a/test/gui/shared/verificationPoints/publicLinkExpirationProgressIndicatorInvisible +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAIklEQVQ4jWN8//79fwYqAiZqGjZq4KiBowaOGjhq4FAyEACDpgP0OKH6SQAAAABJRU5ErkJggg== - - QWidget - QObject - - - - - 528 - 604 - 20 - 20 - - - - - true - - - false - - - 0 - - - false - - - false - - - false - - - false - - - 40 - - - - - - true - - - 1 - - - -1 - - - - - false - - - - 0 - - - 0 - - - false - - - - - - - - - - - - - - - - - - - diff --git a/test/gui/shared/verificationPoints/publicLinkPasswordProgressIndicatorInvisible b/test/gui/shared/verificationPoints/publicLinkPasswordProgressIndicatorInvisible deleted file mode 100644 index 27f93dad69..0000000000 --- a/test/gui/shared/verificationPoints/publicLinkPasswordProgressIndicatorInvisible +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAIklEQVQ4jWN8//79fwYqAiZqGjZq4KiBowaOGjhq4FAyEACDpgP0OKH6SQAAAABJRU5ErkJggg== - - QWidget - QObject - - - - - 2739 - 457 - 20 - 20 - - - - - false - - - false - - - - 40 - - - -1 - - - false - - - - 0 - - - - 1 - - - true - - - 0 - - - true - - - - - - - 0 - - - false - - - false - - - false - - - false - - - - - - - - - - - - - - - - - - diff --git a/test/gui/step_types/types.py b/test/gui/step_types/types.py new file mode 100644 index 0000000000..db8cd0c503 --- /dev/null +++ b/test/gui/step_types/types.py @@ -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) diff --git a/test/gui/shared/steps/account_context.py b/test/gui/steps/account_context.py similarity index 72% rename from test/gui/shared/steps/account_context.py rename to test/gui/steps/account_context.py index 1f77481571..18ebb662e6 100644 --- a/test/gui/shared/steps/account_context.py +++ b/test/gui/steps/account_context.py @@ -1,27 +1,34 @@ 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.EnterPassword import EnterPassword -from pageObjects.Toolbar import Toolbar from pageObjects.AccountSetting import AccountSetting - +from pageObjects.Toolbar import Toolbar +from pageObjects.EnterPassword import EnterPassword from helpers.SetupClientHelper import ( - setup_client, start_client, + setup_client, substitute_inline_codes, get_client_details, generate_account_config, get_resource_path, ) -from helpers.UserHelper import get_displayname_for_user, get_password_for_user from helpers.SyncHelper import ( wait_for_initial_sync_to_complete, listen_sync_status_for_item, ) -from helpers.ConfigHelper import get_config, set_config, is_linux -from helpers.FilesHelper import convert_path_separators_for_os +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:') @@ -33,25 +40,19 @@ def step(context): ) -@Then('the account with displayname "|any|" should be displayed') -def step(context, displayname): - displayname = substitute_inline_codes(displayname) - Toolbar.account_exists(displayname) - +@Then('"{username}" account should be added') +def step(context, username): + username = substitute_inline_codes(username) + expect(Toolbar.account_exists(username)).to.be.true -@Then('the account with displayname "|any|" should not be displayed') -def step(context, displayname): - displayname = substitute_inline_codes(displayname) - timeout = get_config('lowestSyncTimeout') * 1000 - test.compare( - False, - Toolbar.has_item(displayname, timeout), - f"Expected account '{displayname}' to be removed", - ) +@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 "|any|" has set up a client with default settings') +@Given('user "{username}" has set up a client with default settings') def step(context, username): password = get_password_for_user(username) setup_client(username) @@ -62,6 +63,7 @@ def step(context, username): # 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:') @@ -73,24 +75,19 @@ def step(context): start_client() # accept certificate for each user for idx, _ in enumerate(users): - enter_password = EnterPassword(len(users) - idx) + enter_password = EnterPassword() enter_password.accept_certificate() for idx, _ in enumerate(sync_paths.values()): # login from last dialog - account_idx = len(sync_paths) - idx - enter_password = EnterPassword(account_idx) + 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]) - - -@Given('the user has started the client') -def step(context): - start_client() + Toolbar.wait_toolbar_enabled() @When('the user starts the client') @@ -105,40 +102,42 @@ def step(context): @When('the user adds the following account:') def step(context): - account_details = get_client_details(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 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): - account_details = get_client_details(context) + data = table_rows_hash(context.table) + account_details = get_client_details(data) AccountConnectionWizard.add_account_information(account_details) -@When('the user "|any|" logs out using the client-UI') -def step(context, _): +@When('the user "{username}" logs out using the client-UI') +def step(context, username): AccountSetting.logout() -@Then('user "|any|" should be signed out') +@Then('user "{username}" should be signed out') def step(context, username): - test.compare( - AccountSetting.is_user_signed_out(), - True, - f'User "{username}" is signed out', - ) + 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 "|any|" has logged out from the client-UI') +@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 "|any|" logs in using the client-UI') +@When('user "{username}" logs in using the client-UI') def step(context, username): AccountSetting.login() password = get_password_for_user(username) @@ -147,6 +146,7 @@ def step(context, username): # 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') @@ -154,14 +154,14 @@ def step(context, _): AccountSetting.login() -@Then('user "|any|" should be connected to the server') -def step(context, _): +@Then('user "{username}" should be connected to the server') +def step(context, username): AccountSetting.wait_until_account_is_connected() - AccountSetting.wait_until_sync_folder_is_configured() -@When('the user removes the connection for user "|any|"') +@When('the user removes the connection for user "{username}"') def step(context, username): + username = substitute_inline_codes(username) AccountSetting.remove_connection_for_user(username) @@ -179,9 +179,9 @@ def step(context): AccountConnectionWizard.accept_certificate() -@When('the user adds the server "|any|"') -def step(context, server): - server_url = substitute_inline_codes(server) +@When('the user adds the server "{server_url}"') +def step(context, server_url): + server_url = substitute_inline_codes(server_url) AccountConnectionWizard.add_server(server_url) @@ -193,12 +193,10 @@ def step(context): @Then('credentials wizard should be visible') def step(context): - test.compare( - AccountConnectionWizard.is_credential_window_visible(), - True, - 'Credentials wizard is visible', - ) - + with ensure( + 'Credentials wizard is not be visible' + ): + AccountConnectionWizard.is_credential_window_visible().should.be.true @When('the user selects download everything option in advanced section') def step(context): @@ -225,6 +223,7 @@ def step(context): 'Sync everything option is checked', ) + @When(r'^the user presses the "([^"]*)" key(?:s)?', regexp=True) def step(context, key): AccountSetting.press_key(key) @@ -235,7 +234,7 @@ def step(context): test.compare(True, AccountSetting.is_log_dialog_visible(), 'Log dialog is opened') -@Step('the user cancels the sync connection wizard') +@When('the user cancels the sync connection wizard') def step(context): SyncConnectionWizard.cancel_folder_sync_connection_wizard() @@ -243,13 +242,13 @@ def step(context): @When('the user quits the client') def step(context): Toolbar.quit_opencloud() + close_and_kill_app() -@Then('"|any|" account should be opened') -def step(context, displayname): - displayname = substitute_inline_codes(displayname) - if not Toolbar.account_has_focus(displayname): - raise LookupError(f"Account '{displayname}' should be opened, but it is not") +@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( @@ -283,7 +282,9 @@ def step(context, warn_message): ) -@Given('the user has removed the connection for user "|any|"') +@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)) diff --git a/test/gui/shared/steps/file_context.py b/test/gui/steps/file_context.py similarity index 59% rename from test/gui/shared/steps/file_context.py rename to test/gui/steps/file_context.py index 6d3f2bf666..d02d09ec61 100644 --- a/test/gui/shared/steps/file_context.py +++ b/test/gui/steps/file_context.py @@ -1,14 +1,18 @@ -# -*- coding: utf-8 -*- import os import re import builtins import shutil import zipfile -from os.path import isfile, join, isdir -import squish +from os.path import isfile, join, isdir, exists +from behave import when as When, then as Then, given as Given +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 +from helpers.SyncHelper import ( + wait_for_client_to_be_ready, + listen_sync_status_for_item, +) +from helpers.Utils import wait_for from helpers.ConfigHelper import get_config from helpers.FilesHelper import ( build_conflicted_regex, @@ -20,19 +24,19 @@ prefix_path_namespace, remember_path, convert_path_separators_for_os, - get_file_for_upload + get_file_for_upload, ) -def folder_exists(folder_path, timeout=1000): - return squish.waitFor( +def folder_exists(folder_path, timeout=get_config('min_timeout')): + return wait_for( lambda: isdir(sanitize_path(folder_path)), timeout, ) -def file_exists(file_path, timeout=1000): - return squish.waitFor( +def file_exists(file_path, timeout=get_config('min_timeout')): + return wait_for( lambda: isfile(sanitize_path(file_path)), timeout, ) @@ -71,11 +75,13 @@ def write_file(resource, 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: @@ -95,27 +101,44 @@ def extract_zip(zip_file_path, 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] + ' - Copy.' + source_dir[-1] - return resource_path + ' - Copy' + return source_dir[0] + suffix + '.' + source_dir[-1] + return resource_path + suffix def copy_resource(resource_type, source, destination, from_files_for_upload=False): - wait_for_client_to_be_ready() if from_files_for_upload: - source_dir = get_file_for_upload(source) + source = get_file_for_upload(source) else: - source_dir = get_resource_path(source) - destination_dir = get_resource_path(destination) - if source_dir == destination_dir and destination_dir != '/': - destination_dir = add_copy_suffix(source, resource_type) + 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_dir, destination_dir) - return shutil.copy2(source_dir, destination_dir) + 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) @@ -124,77 +147,96 @@ def deleteResource(resource, resource_type): @When( - 'user "|any|" creates a file "|any|" with the following content inside the sync folder' + 'user "{username}" creates a file "{filename}" with the following content inside the sync folder' ) def step(context, username, filename): - file_content = '\n'.join(context.multiLineText) file = get_resource_path(filename, username) - wait_and_write_file(convert_path_separators_for_os(file), file_content) + wait_and_write_file(convert_path_separators_for_os(file), context.text) -@When('user "|any|" creates a folder "|any|" inside the sync folder') +@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 "|any|" has created a folder "|any|" inside the sync folder') +@Given('user "{username}" has created a folder "{foldername}" inside the sync folder') def step(context, username, foldername): create_folder(foldername, username) -@When('user "|any|" creates a file "|any|" with size "|any|" inside the sync folder') -def step(context, _, filename, filesize): +@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 the (file|folder) "([^"]*)" to "([^"]*)"', regexp=True) -def step(context, resource_type, file_name, destination): - copy_resource(resource_type, file_name, destination, False) +@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(r'the user renames a (?:file|folder) "([^"]*)" to "([^"]*)"', regexp=True) + +@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 "|any|" should exist on the file system with the following content') +@Then( + 'the file "{file_path}" should exist on the file system with the following content' +) def step(context, file_path): - expected = '\n'.join(context.multiLineText) + expected = context.text file_path = get_resource_path(file_path) with open(file_path, 'r', encoding='utf-8') as f: contents = f.read() - test.compare( + with ensure( + '{0} expected to exist with content "{1}" but has content "{2}"', + file_path, expected, contents, - 'file expected to exist with content ' - + expected - + ' but does not have the expected content', - ) + ): + contents.should.equal(expected) -@Then(r'^the (file|folder) "([^"]*)" (should|should not) exist on the file system$', regexp=True) -def step(context, resource_type, resource, should_or_should_not): +@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('max_timeout') if resource_type == 'file': - if should_or_should_not == 'should': - resource_exists = file_exists( - resource_path, get_config('maxSyncTimeout') * 1000 - ) + resource_exists = file_exists(resource_path, timeout) else: - if should_or_should_not == 'should': - resource_exists = folder_exists( - resource_path, get_config('maxSyncTimeout') * 1000 - ) + resource_exists = folder_exists(resource_path, timeout) - expected = should_or_should_not == 'should' - test.compare( - expected, - resource_exists, - f'{resource_type.capitalize()} "{resource}" {"exists" if resource_exists else "does not exist"} on the system', + 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:') @@ -225,7 +267,7 @@ def step(context, filename): raise AssertionError('Conflict file not found with given name') -@When('the user overwrites the file "|any|" with content "|any|"') +@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) @@ -243,20 +285,16 @@ def step(context, user, resource, content): wait_and_try_to_write_file(resource, content) -@When(r'the user deletes the (file|folder) "([^"]*)"', regexp=True) -def step(context, item_type, resource): - wait_for_client_to_be_ready() - - deleteResource(resource, item_type) +@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:') +@When('user "{username}" creates the following files inside the sync folder:') def step(context, username): - wait_for_client_to_be_ready() - - for row in context.table[1:]: + for row in context.table: file = get_resource_path(row[0], username) - write_file(file, '') + wait_and_write_file(file, '') @Given('the user has created a folder "|any|" in temp folder') @@ -280,40 +318,65 @@ def step(context, file_number, file_size, folder_name): ) +@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_name): +def step(context, username, resource_type, resource_name): source_dir = join(get_config('tempFolderPath'), resource_name) - destination_dir = get_resource_path('/', username) - shutil.move(source_dir, destination_dir) + move_resource(username, resource_type, source_dir, '/', True) @When( - r'user "([^"]*)" moves (?:file|folder) "([^"]*)" to "([^"]*)" in the sync folder', + r'user "([^"]*)" moves (folder|file) "([^"]*)" to the temp folder', regexp=True, ) -def step(context, username, source, destination): - wait_for_client_to_be_ready() - source_dir = get_resource_path(source, username) - if destination in (None, '/'): - destination = '' - destination_dir = get_resource_path(destination, username) - shutil.move(source_dir, destination_dir) +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 "|any|" should be able to open the file "|any|" on the file system') +@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) - test.compare(can_read(file_path), True, 'File should be readable') + 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 "|any|" the file "|any|" should have content "|any|" on the file system') +@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) - test.compare(file_content, content, 'Comparing file content') + 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') @@ -366,7 +429,7 @@ def step(context, folder_name): @Given( - r'the user has copied file "([^"]*)" from outside the sync folder to "([^"]*)" in the sync folder', + 'the user has copied file "{resource_name}" from outside the sync folder to "{destination}" in the sync folder', regexp=True, ) def step(context, resource_name, destination): @@ -374,7 +437,7 @@ def step(context, resource_name, destination): @When( - r'the user copies file "([^"]*)" from outside the sync folder to "([^"]*)" in the sync folder', + 'the user copies file "{resource_name}" from outside the sync folder to "{destination}" in the sync folder', regexp=True, ) def step(context, resource_name, destination): @@ -384,12 +447,13 @@ def step(context, resource_name, destination): @When('the user deletes the following files') def step(context): wait_for_client_to_be_ready() - - for row in context.table[1:]: + for row in context.table: 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): +@Given( + 'the user has created a file "{filename}" with size "{filesize}" in the sync folder' +) +def step(context, filename, filesize): create_file_with_size(filename, filesize) diff --git a/test/gui/steps/server_context.py b/test/gui/steps/server_context.py new file mode 100644 index 0000000000..28513dc467 --- /dev/null +++ b/test/gui/steps/server_context.py @@ -0,0 +1,148 @@ +import tempfile +from pathlib import Path +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 +from helpers.FilesHelper import get_file_for_upload, get_document_content + + +@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 "{user_name}" the content of file "{server_file_name}" in the server should match the content of local file "{local_file_name}"' +) +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)) + + with ensure( + f"Server file '{server_file_name}' differs from local file '{local_file_name}'", + ): + server_content.should.equal(local_content) + + +@Then('as "{user_name}" following files should not exist in the server',) +def step(context, user_name): + for row in context.table: + resource_name = row[0] + resource_exists = webdav.resource_exists(user_name, resource_name) + with ensure( + f"Resource '{resource_name}' should not exist, but it does", + ): + resource_exists.should.be.false + + + +@Given('user "{user}" has uploaded the following files to the server') +def step(context, user): + for row in context.table: + 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'], + ) diff --git a/test/gui/shared/steps/spaces_context.py b/test/gui/steps/spaces_context.py similarity index 55% rename from test/gui/shared/steps/spaces_context.py rename to test/gui/steps/spaces_context.py index 7150929451..5d7861b814 100644 --- a/test/gui/shared/steps/spaces_context.py +++ b/test/gui/steps/spaces_context.py @@ -1,5 +1,7 @@ -from pageObjects.EnterPassword import EnterPassword +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 @@ -14,29 +16,31 @@ from helpers.ConfigHelper import get_config, set_config -@Given('the administrator has created a space "|any|"') +@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 "|any|" in space "|any|"') +@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 "|any|" with content "|any|" inside space "|any|"' + '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 "|any|" to space "|any|" with role "|any|"') +@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 "|any|" has set up a client with space "|any|"') +@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) @@ -46,20 +50,31 @@ def step(context, user, space_name): 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 "|any|" the file "|any|" in the space "|any|" should have content "|any|" in the server' + '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) - test.compare(downloaded_content, content, 'Comparing file content') + 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( - r'as "([^"]*)" the space "([^"]*)" should have (?:folder|file) "([^"]*)" in the server', - regexp=True, + '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) - test.compare(exists, True, 'Resource exists') + with ensure('Resource "{0}" should exist but it does not', resource_name): + exists.should.be.true diff --git a/test/gui/shared/steps/sync_context.py b/test/gui/steps/sync_context.py similarity index 61% rename from test/gui/shared/steps/sync_context.py rename to test/gui/steps/sync_context.py index 7cafaaa9a3..82f50d94f0 100644 --- a/test/gui/shared/steps/sync_context.py +++ b/test/gui/steps/sync_context.py @@ -1,12 +1,12 @@ -import squish +from behave import when as When, then as Then +from sure import ensure from pageObjects.SyncConnectionWizard import SyncConnectionWizard -from pageObjects.SyncConnection import SyncConnection from pageObjects.Toolbar import Toolbar from pageObjects.Activity import Activity +from pageObjects.SyncConnection import SyncConnection from pageObjects.Settings import Settings - -from helpers.ConfigHelper import get_config, is_windows, set_config +from helpers.ConfigHelper import set_config from helpers.SyncHelper import ( wait_for_resource_to_sync, wait_for_resource_to_have_sync_error, @@ -18,6 +18,27 @@ get_resource_path, ) from helpers.FilesHelper import convert_path_separators_for_os +from helpers.TableParser import table_hashes, table_raw + + +def _check_activities(context, not_synced=False, should_exist=True): + field = "status" if not_synced else "action" + activities = table_hashes(context.table) + for activity in activities: + activity["account"] = substitute_inline_codes(activity["account"]) + has_activity = Activity.has_activity( + activity["resource"], activity[field], activity["account"] + ) + with ensure( + 'Activity should exist: {0} | {1} | {2}', + activity["resource"], + activity[field], + activity["account"], + ): + if should_exist: + has_activity.should.be.true + else: + has_activity.should.be.false @Given('the user has paused the file sync') @@ -37,13 +58,14 @@ def step(context): @When('the user waits for the files to sync') def step(context): - wait_for_resource_to_sync(get_resource_path('/')) + wait_for_resource_to_sync(get_resource_path('/'), force_sync=True) -@When(r'the user waits for (file|folder) "([^"]*)" to be synced', regexp=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) @@ -76,21 +98,25 @@ def step(context, item): ) -@When('the user clicks on the activity tab') +@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') +@Then('the file "{filename}" should be blacklisted') def step(context, filename): - test.compare( - True, Activity.is_resource_blacklisted(filename), 'File is Blacklisted' - ) + with ensure('File is Blacklisted'): + Activity.is_resource_blacklisted(filename).should.be.true @Then('the file "|any|" should be ignored') @@ -98,28 +124,34 @@ def step(context, filename): test.compare(True, Activity.is_resource_ignored(filename), 'File is Ignored') -@Then('the file "|any|" should be excluded') +@Then('the file "{filename}" should be excluded') def step(context, filename): - test.compare(True, Activity.is_resource_excluded(filename), 'File is Excluded') + with ensure('File is Excluded'): + Activity.is_resource_excluded(filename).should.be.true -@When('the user selects "|any|" tab in the activity') +@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): - for tab_name in context.table: - Toolbar.has_item(tab_name[0]) + 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[1:]: + for row in context.table: folders.append(row[0]) - SyncConnectionWizard.select_folders_to_sync(folders, new_sync_connection_wizard=True) + SyncConnectionWizard.select_folders_to_sync( + folders, new_sync_connection_wizard=True + ) @When('the user sorts the folder list by "|any|"') @@ -150,7 +182,7 @@ def step(context): row_index += 1 -@When('the user selects "|any|" space in sync connection wizard') +@When('the user selects "{space_name}" space in sync connection wizard') def step(context, space_name): SyncConnectionWizard.select_space(space_name) SyncConnectionWizard.next_step() @@ -171,37 +203,52 @@ def step(context, folder_name): set_current_user_sync_path(sync_path) -@When('the user syncs the "|any|" space') +@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): - for item in context.table: - Settings.check_general_option(item[0]) + 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): - for item in context.table: - Settings.check_advanced_option(item[0]) + 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): - for item in context.table: - Settings.check_network_option(item[0]) + 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_button() + Settings.open_about_dialog() @Then('the about dialog should be opened') def step(context): - Settings.wait_for_about_dialog_to_be_visible() + 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') @@ -214,13 +261,14 @@ def step(context): SyncConnectionWizard.deselect_all_remote_folders() -@Then('the sync folder list should be empty') -def step(context): - test.compare( - 0, - SyncConnection.get_folder_connection_count(), - 'Sync connections should be empty', - ) +@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') @@ -234,7 +282,7 @@ def step(context): SyncConnection.confirm_folder_sync_connection_removal() -@Then('the file "|any|" should have status "|any|" in the activity tab') +@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) @@ -253,7 +301,7 @@ def step(context): ) -@When('the user checks the activities of account "|any|"') +@When('the user checks the activities of account "{account}"') def step(context, account): account = substitute_inline_codes(account) Activity.select_synced_filter(account) @@ -261,32 +309,25 @@ def step(context, account): @Then('the following activities should be displayed in synced table') def step(context): - for row in context.table[1:]: - resource = row[0] - action = row[1] - account = substitute_inline_codes(row[2]) - test.compare( - Activity.check_synced_table(resource, action, account), - True, - 'Resource should be displayed in the synced table', - ) + _check_activities(context) -@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', - ) +@Then('the following activities should be displayed in not synced table') +def step(context): + _check_activities(context, not_synced=True) -@When('the user unchecks the "|any|" filter') +@Then('the following activities should not be displayed in synced table') +def step(context): + _check_activities(context, should_exist=False) + + +@Then('the following activities should not be displayed in not synced table') +def step(context): + _check_activities(context, not_synced=True, should_exist=False) + + +@When('the user unchecks the "{filter_option}" filter') def step(context, filter_option): Activity.select_not_synced_filter(filter_option) @@ -303,7 +344,7 @@ def step(context): test.compare( actual_error_message, expected_error_message, - f'Expected error message: "{expected_error_message}" but got: "{actual_error_message}"' + f'Expected error message: "{expected_error_message}" but got: "{actual_error_message}"', ) @@ -312,10 +353,14 @@ 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:') +@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[1:]: + for row in context.table: folders.append(row[0]) - SyncConnectionWizard.unselect_folders_to_sync(folders, new_sync_connection_wizard=False) + SyncConnectionWizard.unselect_folders_to_sync( + folders, new_sync_connection_wizard=False + ) diff --git a/test/gui/steps/vfs_context.py b/test/gui/steps/vfs_context.py new file mode 100644 index 0000000000..5416d7c507 --- /dev/null +++ b/test/gui/steps/vfs_context.py @@ -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) diff --git a/test/gui/suite.conf b/test/gui/suite.conf deleted file mode 100644 index 16dbe6dc4d..0000000000 --- a/test/gui/suite.conf +++ /dev/null @@ -1,8 +0,0 @@ -AUT=opencloud --showsettings -ENVVARS=envs.txt -HOOK_SUB_PROCESSES=false -IMPLICITAUTSTART=false -LANGUAGE=Python -OBJECTMAPSTYLE=script -VERSION=3 -WRAPPERS=Qt diff --git a/test/gui/tst_activity/test.py b/test/gui/tst_activity/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_activity/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_addAccount/test.py b/test/gui/tst_addAccount/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_addAccount/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_checkAlltabs/test.py b/test/gui/tst_checkAlltabs/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_checkAlltabs/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_deleteFilesFolders/test.py b/test/gui/tst_deleteFilesFolders/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_deleteFilesFolders/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_editFiles/test.py b/test/gui/tst_editFiles/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_editFiles/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_loginLogout/test.py b/test/gui/tst_loginLogout/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_loginLogout/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_moveFilesFolders/test.py b/test/gui/tst_moveFilesFolders/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_moveFilesFolders/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_removeAccountConnection/test.py b/test/gui/tst_removeAccountConnection/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_removeAccountConnection/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_spaces/test.py b/test/gui/tst_spaces/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_spaces/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/tst_syncing/test.py b/test/gui/tst_syncing/test.py deleted file mode 100644 index 83b0a5275a..0000000000 --- a/test/gui/tst_syncing/test.py +++ /dev/null @@ -1,8 +0,0 @@ -source(findFile('scripts', 'python/bdd.py')) - -setupHooks('../shared/scripts/bdd_hooks.py') -collectStepDefinitions('./steps', '../shared/steps') - - -def main(): - runFeatureFile('test.feature') diff --git a/test/gui/webUI/.editorconfig b/test/gui/webUI/.editorconfig deleted file mode 100644 index 9d08b338a6..0000000000 --- a/test/gui/webUI/.editorconfig +++ /dev/null @@ -1,11 +0,0 @@ -[*] -end_of_line = lf -insert_final_newline = true - -[*.js] -indent_style = space -indent_size = 2 - -[*.json] -indent_style = space -indent_size = 4 diff --git a/test/gui/webUI/.gitignore b/test/gui/webUI/.gitignore deleted file mode 100644 index 48413d4739..0000000000 --- a/test/gui/webUI/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules/ -/test-results/ -/playwright-report/ -/playwright/.cache/ -.pnpm-store/ -package-lock.json -yarn.lock -.playwright diff --git a/test/gui/webUI/login.spec.js b/test/gui/webUI/login.spec.js deleted file mode 100644 index e46fc0214f..0000000000 --- a/test/gui/webUI/login.spec.js +++ /dev/null @@ -1,41 +0,0 @@ -// clang-format off -const { test, expect } = require("@playwright/test"); - -const config = { - auth_url: "", - username: "", - password: "", -}; - -test.beforeEach(async ({ page }) => { - config.auth_url = process.env.OC_AUTH_URL; - config.username = process.env.OC_USERNAME; - config.password = process.env.OC_PASSWORD; - if (!config.auth_url || !config.username || !config.password) { - throw new Error( - "Some of the following envs are not set:\n" + - ` OC_AUTH_URL: ${config.auth_url}\n` + - ` OC_USERNAME: ${config.username}\n` + - ` OC_PASSWORD: ${config.password}` - ); - } - console.info( - "Login info:\n" + - ` OC_AUTH_URL: ${config.auth_url}\n` + - ` OC_USERNAME: ${config.username}\n` + - ` OC_PASSWORD: ${config.password}` - ); - - await page.goto(config.auth_url); -}); - -test("oc login @oidc", async ({ page }) => { - // login - await page.fill("#oc-login-username", config.username); - await page.fill("#oc-login-password", config.password); - await page.click("button[type=submit]"); - // allow permissions - await page.click("button >> text=Allow"); - // confirm successful login - await page.waitForSelector("text=Login Successful"); -}); diff --git a/test/gui/webUI/package.json b/test/gui/webUI/package.json deleted file mode 100644 index a30416fa4a..0000000000 --- a/test/gui/webUI/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "oidc-login", - "version": "0.0.1", - "scripts": { - "oidc-login": "playwright test --grep @oidc" - }, - "devDependencies": { - "@playwright/test": "1.45.0" - }, - "packageManager": "pnpm@8.15.8" -} diff --git a/test/gui/webUI/playwright.config.js b/test/gui/webUI/playwright.config.js deleted file mode 100644 index cb7eb04787..0000000000 --- a/test/gui/webUI/playwright.config.js +++ /dev/null @@ -1,23 +0,0 @@ -// clang-format off -const { devices } = require("@playwright/test"); - -const config = { - testDir: "./", - /* Maximum time one test can run for. */ - timeout: 30 * 1000, - use: { - headless: true, - ignoreHTTPSErrors: true, - }, - - projects: [ - { - name: "chromium", - use: { - ...devices["Desktop Chrome"], - }, - }, - ], -}; - -module.exports = config; diff --git a/test/gui/webUI/pnpm-lock.yaml b/test/gui/webUI/pnpm-lock.yaml deleted file mode 100644 index 9dbccb52ca..0000000000 --- a/test/gui/webUI/pnpm-lock.yaml +++ /dev/null @@ -1,44 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -devDependencies: - '@playwright/test': - specifier: 1.45.0 - version: 1.45.0 - -packages: - - /@playwright/test@1.45.0: - resolution: {integrity: sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==} - engines: {node: '>=18'} - hasBin: true - dependencies: - playwright: 1.45.0 - dev: true - - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /playwright-core@1.45.0: - resolution: {integrity: sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==} - engines: {node: '>=18'} - hasBin: true - dev: true - - /playwright@1.45.0: - resolution: {integrity: sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==} - engines: {node: '>=18'} - hasBin: true - dependencies: - playwright-core: 1.45.0 - optionalDependencies: - fsevents: 2.3.2 - dev: true diff --git a/test/gui/woodpecker/gui_test_reports.sh b/test/gui/woodpecker/gui_test_reports.sh index 4af7fbe98a..166913bba5 100644 --- a/test/gui/woodpecker/gui_test_reports.sh +++ b/test/gui/woodpecker/gui_test_reports.sh @@ -1,11 +1,13 @@ #!/bin/bash -REPORT_PATH="$PUBLIC_BUCKET/desktop/$CI_PIPELINE_NUMBER/guiReportUpload" +REPORT_PATH="$PUBLIC_BUCKET/desktop/testlogs/$CI_PIPELINE_NUMBER/$MATRIX_NAME/reports" REPORT_URL="$MC_HOST/$REPORT_PATH" echo "" echo "--- GUI Test Reports ---" -echo "GUI Test Report: $REPORT_URL/index.html" +echo "Test Report: $REPORT_URL/report.html" +echo "Client Log: $REPORT_URL/opencloud.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 @@ -18,3 +20,16 @@ if [[ -n "$screenshots" ]]; then 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 diff --git a/test/gui/woodpecker/run_atspi_webdriver.sh b/test/gui/woodpecker/run_atspi_webdriver.sh new file mode 100644 index 0000000000..a9cec89fa9 --- /dev/null +++ b/test/gui/woodpecker/run_atspi_webdriver.sh @@ -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 \ No newline at end of file diff --git a/test/gui/woodpecker/script.sh b/test/gui/woodpecker/script.sh index fa191257d8..821f21f738 100644 --- a/test/gui/woodpecker/script.sh +++ b/test/gui/woodpecker/script.sh @@ -1,46 +1,19 @@ #!/bin/bash -source .woodpecker.env +touch .woodpecker.env -# Function to get the latest OpenCloud commit ID -get_latest_opencloud_commit_id() { - echo "Getting latest commit ID for branch: $OPENCLOUD_BRANCH" - latest_commit_id=$(git ls-remote https://github.com/opencloud-eu/opencloud.git "refs/heads/$OPENCLOUD_BRANCH" | cut -f 1) +PY_REQUIREMENTS_PATH="test/gui/requirements.txt" - # Update the OPENCLOUD in the .woodpecker.env file - env_file="./.woodpecker.env" - sed -i "s/^OPENCLOUD_COMMITID=.*/OPENCLOUD_COMMITID=$latest_commit_id/" "$env_file" - - echo "Updated .woodpecker.env with latest commit ID: $latest_commit_id" - cat $env_file - exit 0 -} - -# Function to check if the cache exists for the given commit ID -check_opencloud_cache() { - echo "Checking OpenCloud cache for commit ID: $OPENCLOUD_COMMITID" - opencloud_cache=$(mc find s3/$CACHE_BUCKET/opencloud-build/$OPENCLOUD_COMMITID/opencloud 2>&1 | grep 'Object does not exist') - - if [[ "$opencloud_cache" != "" ]] - then - echo "$OPENCLOUD_COMMITID doesn't exist in cache." - ENV="OPENCLOUD_CACHE_FOUND=false\n" - else - echo "$OPENCLOUD_COMMITID found in cache." - ENV="OPENCLOUD_CACHE_FOUND=true\n" - fi -} - -# get playwright version from package.json +# get playwright version from requirements.txt get_playwright_version() { - PACKAGE_JSON_PATH="test/gui/webUI/package.json" - if [[ ! -f "$PACKAGE_JSON_PATH" ]]; then - echo "Error: package.json file not found." + if [[ ! -f "$PY_REQUIREMENTS_PATH" ]]; then + echo "Error: file not found: $PY_REQUIREMENTS_PATH" fi - playwright_version=$(grep '"@playwright/test":' "$PACKAGE_JSON_PATH" | cut -d':' -f2 | tr -d '", ') + 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 package.json." >&2 + echo "Error: Playwright package not found in requirements.txt" >&2 exit 78 fi @@ -49,30 +22,35 @@ get_playwright_version() { # Function to check if the cache exists for the given commit ID check_browsers_cache() { - get_playwright_version + playwright_version=$(get_playwright_version) - playwright_cache=$(mc find s3/$CACHE_BUCKET/web/browsers-cache/$playwright_version/playwright-browsers.tar.gz 2>&1 | grep 'Object does not exist') + 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 "Playwright v$playwright_version supported browsers doesn't exist in cache." + echo "Browsers cache for playwright v$playwright_version not found in cache." ENV="BROWSER_CACHE_FOUND=false\n" else - echo "Playwright v$playwright_version supported browsers found in cache." + 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=$(sha1sum test/gui/requirements.txt | cut -d" " -f1) - python_cache=$(mc find s3/$CACHE_BUCKET/desktop-build/python-cache-$requirements_sha.tar.gz 2>&1 | grep 'Object does not exist') + 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 of requirements with hash $requirements_sha doesn't exist in cache." + echo "Python cache for '$requirements_sha' hash not found in cache." ENV="PYTHON_CACHE_FOUND=false\n" else - echo "Python cache of requirements with hash $requirements_sha found in cache." + echo "Python cache for '$requirements_sha' hash found in cache." ENV="PYTHON_CACHE_FOUND=true\n" fi } @@ -80,9 +58,8 @@ check_python_cache() { if [[ "$1" == "" ]]; then echo "Usage: $0 [COMMAND]" echo "Commands:" - echo -e " get_latest_opencloud_commit_id \t get the latest OpenCloud commit ID" - echo -e " check_opencloud_cache \t\t check if the cache exists for the given commit ID" - echo -e " get_playwright_version \t get the playwright version from package.json" + 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