diff --git a/.env.sample b/.env.sample index 74ad21dd2..430e46d50 100644 --- a/.env.sample +++ b/.env.sample @@ -2,10 +2,7 @@ ANDROID_APK=/home/yougotthis/Downloads/session-android-universal.apk IOS_APP_PATH_PREFIX=/home/yougotthis/Downloads/Session.app -SDK_MANAGER_FULL_PATH=/home/yougotthis/Android/Sdk/cmdline-tools/latest/bin/sdkmanager -AVD_MANAGER_FULL_PATH=/home/yougotthis/Android/Sdk/cmdline-tools/latest/bin/avdmanager EMULATOR_FULL_PATH=/home/yougotthis/Android/Sdk/emulator/emulator -ANDROID_SYSTEM_IMAGE="system-images;android-35;google_atd;x86_64" APPIUM_ADB_FULL_PATH=/home/yougotthis/Android/sdk/platform-tools/adb PRINT_TEST_LOGS='true' PRINT_ONGOING_TEST_LOGS = 1 diff --git a/.gitattributes b/.gitattributes index 99ac5c82c..64b88312a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,6 @@ -run/screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/android-regression.yml b/.github/workflows/android-regression.yml index 94c0758bc..eeddcb227 100644 --- a/.github/workflows/android-regression.yml +++ b/.github/workflows/android-regression.yml @@ -4,6 +4,15 @@ run-name: '${{ inputs.RISK }} regressions on ${{ github.head_ref || github.ref } on: workflow_dispatch: inputs: + NETWORK_TARGET: + description: 'network target to run the tests on' + required: true + type: choice + options: + - 'devnet' + - 'mainnet' + default: 'devnet' + APK_URL: description: 'apk url to test (.tar.xz)' required: true @@ -24,14 +33,17 @@ on: - 'low-risk' - '' + RUN_PRO_TESTS: + description: 'include Session Pro tests in this run' + required: false + default: false + type: boolean + ALLURE_ENABLED: description: 'generate allure report' required: false - default: 'true' - type: choice - options: - - 'true' - - 'false' + default: true + type: boolean PLAYWRIGHT_RETRIES_COUNT: description: 'retries of failing tests to do at most' @@ -53,6 +65,9 @@ on: - 'verbose' # Ongoing and failed test logs default: 'minimal' +permissions: + contents: write + jobs: android-regression: runs-on: [self-hosted, linux, X64, qa-android] @@ -62,15 +77,19 @@ jobs: BUILD_NUMBER: ${{ github.event.inputs.BUILD_NUMBER }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CI: 1 - ALLURE_ENABLED: ${{ github.event.inputs.ALLURE_ENABLED}} + ALLURE_ENABLED: ${{ github.event.inputs.ALLURE_ENABLED }} IOS_APP_PATH_PREFIX: '' ANDROID_APK: './extracted/session-android.apk' APPIUM_ADB_FULL_PATH: '/opt/android/platform-tools/adb' + EMULATOR_FULL_PATH: '/opt/android/emulator/emulator' ANDROID_SDK_ROOT: '/opt/android' PLAYWRIGHT_RETRIES_COUNT: ${{ github.event.inputs.PLAYWRIGHT_RETRIES_COUNT }} _TESTING: 1 # Always hide webdriver logs (@appium/support/ flag) PRINT_FAILED_TEST_LOGS: ${{ github.event.inputs.LOG_LEVEL != 'minimal' && '1' || '0' }} # Show stdout/stderr if test fails (@session-foundation/playwright-reporter/ flag) PRINT_ONGOING_TEST_LOGS: ${{ github.event.inputs.LOG_LEVEL == 'verbose' && '1' || '0' }} # Show everything as it happens (@session-foundation/playwright-reporter/ flag) + SOGS_ADMIN_SEED: ${{ secrets.SOGS_ADMIN_SEED }} + PRO_GREP_INVERT: ${{ inputs.RUN_PRO_TESTS != true && '--grep-invert @pro' || '' }} + PRO_INVERT_SUFFIX: ${{ inputs.RUN_PRO_TESTS != true && '|@pro' || '' }} steps: - uses: actions/checkout@v6 @@ -97,57 +116,62 @@ jobs: run: | pwd - # Check if devnet is accessible before choosing APK - echo "Checking devnet accessibility for APK selection..." - DEVNET_ACCESSIBLE=false - - # Retry logic - for attempt in 1 2 3; do - echo "Devnet check attempt $attempt/3..." - if curl -s --connect-timeout 5 --max-time 10 http://sesh-net.local:1280 >/dev/null 2>&1; then - echo "Devnet is accessible on attempt $attempt" - DEVNET_ACCESSIBLE=true - break - else - echo "Attempt $attempt failed" - if [ $attempt -lt 3 ]; then - echo "Waiting ${attempt}s before retry..." - sleep $attempt - fi - fi - done + NETWORK_TARGET="${{ github.event.inputs.NETWORK_TARGET }}" + echo "Network target: $NETWORK_TARGET" - if [ "$DEVNET_ACCESSIBLE" = "false" ]; then - echo "Devnet is not accessible after 3 attempts" - fi - - # Download and extract APK + # Download and extract APK wget -q -O session-android.apk.tar.xz ${{ github.event.inputs.APK_URL }} tar xf session-android.apk.tar.xz mv session-android-*universal extracted - # Choose APK based on devnet accessibility - if ls extracted/*automaticQa.apk 1>/dev/null 2>&1; then - if [ "$DEVNET_ACCESSIBLE" = "true" ]; then - echo "Using AQA build (devnet accessible)" + # Choose APK based on network target + if [ "$NETWORK_TARGET" = "devnet" ]; then + echo "Devnet target - checking devnet accessibility..." + DEVNET_ACCESSIBLE=false + + # Retry logic + for attempt in 1 2 3; do + echo "Devnet check attempt $attempt/3..." + if curl -s --connect-timeout 5 --max-time 10 http://sesh-net.local:1280 >/dev/null 2>&1; then + echo "Devnet is accessible on attempt $attempt" + DEVNET_ACCESSIBLE=true + break + else + echo "Attempt $attempt failed" + if [ $attempt -lt 3 ]; then + echo "Waiting ${attempt}s before retry..." + sleep $attempt + fi + fi + done + + if [ "$DEVNET_ACCESSIBLE" = "false" ]; then + echo "ERROR: Devnet is not accessible after 3 attempts, but devnet target was selected" + exit 1 + fi + + # Use AQA build for devnet + if ls extracted/*automaticQa.apk 1>/dev/null 2>&1; then + echo "Using AQA build for devnet" mv extracted/*automaticQa.apk extracted/session-android.apk echo "IS_AUTOMATIC_QA=true" >> $GITHUB_ENV else - echo "AQA build available but devnet not accessible - falling back to regular QA build" - if ls extracted/*qa.apk 1>/dev/null 2>&1; then - mv extracted/*qa.apk extracted/session-android.apk - echo "IS_AUTOMATIC_QA=false" >> $GITHUB_ENV - else - echo "No regular QA build found as fallback" - exit 1 - fi + echo "ERROR: No AQA build found for devnet target" + exit 1 fi - elif ls extracted/*qa.apk 1>/dev/null 2>&1; then - echo "Using regular QA build" - mv extracted/*qa.apk extracted/session-android.apk - echo "IS_AUTOMATIC_QA=false" >> $GITHUB_ENV + + elif [ "$NETWORK_TARGET" = "mainnet" ]; then + echo "Mainnet target - using regular QA build" + if ls extracted/*qa.apk 1>/dev/null 2>&1; then + mv extracted/*qa.apk extracted/session-android.apk + echo "IS_AUTOMATIC_QA=false" >> $GITHUB_ENV + else + echo "ERROR: No regular QA build found for mainnet target" + exit 1 + fi + else - echo "No suitable APK found" + echo "ERROR: Unknown network target: $NETWORK_TARGET" exit 1 fi @@ -162,6 +186,9 @@ jobs: adb kill-server; adb start-server; + - name: Apply emulator virtual scene config + run: pnpm setup-virtual-scene + - name: Start emulators from snapshot shell: bash run: | @@ -174,6 +201,7 @@ jobs: with: PLATFORM: ${{ env.PLATFORM }} RISK: ${{ github.event.inputs.RISK }} + PRO_GREP_INVERT: ${{ env.PRO_GREP_INVERT }} - name: Run the 1-devices tests ​​with 4 workers continue-on-error: true @@ -186,7 +214,7 @@ jobs: PRINT_ONGOING_TEST_LOGS: ${{ env.PRINT_ONGOING_TEST_LOGS }} run: | pwd - npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${DEVICES_PER_TEST_COUNT}-devices)(?=.*@${{ github.event.inputs.RISK }})" #Note: this has to be double quotes + npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${DEVICES_PER_TEST_COUNT}-devices)(?=.*@${{ github.event.inputs.RISK }})" $PRO_GREP_INVERT #Note: this has to be double quotes - name: Upload results of this run uses: ./github/actions/upload-test-results @@ -194,6 +222,9 @@ jobs: PLATFORM: ${{ env.PLATFORM }} UPLOAD_IDENTIFIER: 'devices-1-test-run' + - name: Recover emulators if needed + run: pnpm recover-emulators + - name: Run the 2-devices tests ​​with 2 workers continue-on-error: true id: devices-2-test-run @@ -205,7 +236,7 @@ jobs: PRINT_ONGOING_TEST_LOGS: ${{ env.PRINT_ONGOING_TEST_LOGS }} run: | pwd - npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${DEVICES_PER_TEST_COUNT}-devices)(?=.*@${{ github.event.inputs.RISK }})" #Note: this has to be double quotes + npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${DEVICES_PER_TEST_COUNT}-devices)(?=.*@${{ github.event.inputs.RISK }})" $PRO_GREP_INVERT #Note: this has to be double quotes - name: Upload results of this run uses: ./github/actions/upload-test-results @@ -213,6 +244,9 @@ jobs: PLATFORM: ${{ env.PLATFORM }} UPLOAD_IDENTIFIER: 'devices-2-test-run' + - name: Recover emulators if needed + run: pnpm recover-emulators + - name: Run the other tests with 1 worker continue-on-error: true id: other-devices-test-run @@ -224,7 +258,7 @@ jobs: PRINT_ONGOING_TEST_LOGS: ${{ env.PRINT_ONGOING_TEST_LOGS }} run: | pwd - npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${{ github.event.inputs.RISK }})" --grep-invert "@1-devices|@2-devices" #Note: this has to be double quotes + npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${{ github.event.inputs.RISK }})" --grep-invert "@1-devices|@2-devices${PRO_INVERT_SUFFIX}" #Note: this has to be double quotes - name: Generate and publish test report uses: ./github/actions/generate-publish-test-report @@ -237,6 +271,7 @@ jobs: RISK: ${{github.event.inputs.RISK}} GITHUB_RUN_NUMBER: ${{ github.run_number }} GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} + NETWORK_TARGET: ${{ github.event.inputs.NETWORK_TARGET }} - name: Upload results of this run uses: ./github/actions/upload-test-results diff --git a/.github/workflows/deploy-gh-pages.yml b/.github/workflows/deploy-gh-pages.yml index 4e2831590..2759d1fcf 100644 --- a/.github/workflows/deploy-gh-pages.yml +++ b/.github/workflows/deploy-gh-pages.yml @@ -33,13 +33,13 @@ jobs: submodules: 'recursive' - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: '.' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/ios-regression.yml b/.github/workflows/ios-regression.yml index fccbd3f3d..9fd714d90 100644 --- a/.github/workflows/ios-regression.yml +++ b/.github/workflows/ios-regression.yml @@ -24,14 +24,17 @@ on: - 'low-risk' - '' + RUN_PRO_TESTS: + description: 'include Session Pro tests in this run' + required: false + default: false + type: boolean + ALLURE_ENABLED: description: 'generate allure report' required: false - default: 'true' - type: choice - options: - - 'true' - - 'false' + default: true + type: boolean PLAYWRIGHT_RETRIES_COUNT: description: 'retries of failing tests to do at most' @@ -63,6 +66,9 @@ on: - 'verbose' # All test logs - use with caution! default: 'minimal' +permissions: + contents: write + jobs: ios-regression: runs-on: [self-hosted, macOS] @@ -72,20 +78,19 @@ jobs: BUILD_NUMBER: ${{ github.event.inputs.BUILD_NUMBER }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CI: 1 - ALLURE_ENABLED: ${{ github.event.inputs.ALLURE_ENABLED}} + ALLURE_ENABLED: ${{ github.event.inputs.ALLURE_ENABLED }} IOS_APP_PATH_PREFIX: './extracted/Session.app' ANDROID_APK: '' APPIUM_ADB_FULL_PATH: '' ANDROID_SDK_ROOT: '' PLAYWRIGHT_RETRIES_COUNT: ${{ github.event.inputs.PLAYWRIGHT_RETRIES_COUNT }} + NETWORK_TARGET: 'mainnet' # iOS only supports mainnet for now _TESTING: 1 # Always hide webdriver logs (@appium/support/ flag) PRINT_FAILED_TEST_LOGS: ${{ github.event.inputs.LOG_LEVEL != 'minimal' && '1' || '0' }} # Show stdout/stderr if test fails (@session-foundation/playwright-reporter/ flag) PRINT_ONGOING_TEST_LOGS: ${{ github.event.inputs.LOG_LEVEL == 'verbose' && '1' || '0' }} # Show everything as it happens (@session-foundation/playwright-reporter/ flag) PLAYWRIGHT_WORKERS_COUNT: 3 # for iOS, this is the max we can have on our self-hosted runner - SDK_MANAGER_FULL_PATH: '' - AVD_MANAGER_FULL_PATH: '' - ANDROID_SYSTEM_IMAGE: '' - EMULATOR_FULL_PATH: '' + SOGS_ADMIN_SEED: ${{ secrets.SOGS_ADMIN_SEED }} + PRO_GREP_INVERT: ${{ inputs.RUN_PRO_TESTS != true && '--grep-invert @pro' || '' }} steps: - uses: actions/checkout@v6 @@ -130,6 +135,7 @@ jobs: with: PLATFORM: ${{ env.PLATFORM }} RISK: ${{ github.event.inputs.RISK }} + PRO_GREP_INVERT: ${{ env.PRO_GREP_INVERT }} - name: Run the iOS tests​​ (all device counts) env: @@ -139,7 +145,7 @@ jobs: PRINT_ONGOING_TEST_LOGS: ${{ env.PRINT_ONGOING_TEST_LOGS }} run: | pwd - npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${{ github.event.inputs.RISK }})" #Note: this has to be double quotes + npx playwright test --grep "(?=.*@${PLATFORM})(?=.*@${{ github.event.inputs.RISK }})" $PRO_GREP_INVERT #Note: this has to be double quotes - name: Generate and publish test report uses: ./github/actions/generate-publish-test-report @@ -152,6 +158,7 @@ jobs: RISK: ${{github.event.inputs.RISK}} GITHUB_RUN_NUMBER: ${{ github.run_number }} GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} + NETWORK_TARGET: ${{ env.NETWORK_TARGET }} - name: Upload results of this run uses: ./github/actions/upload-test-results diff --git a/.github/workflows/prune-attachments.yml b/.github/workflows/prune-attachments.yml index 1fedfe1e7..4576211eb 100644 --- a/.github/workflows/prune-attachments.yml +++ b/.github/workflows/prune-attachments.yml @@ -1,14 +1,19 @@ -name: Prune Old Allure Attachments +name: Prune Old Allure Reports on: schedule: - cron: '0 16 * * 0' # Weekly, 16:00 UTC Sunday workflow_dispatch: inputs: - retention-days: + attachment-retention-days: description: 'Number of days to retain attachments' required: false default: '14' type: string + report-retention-days: + description: 'Number of days to retain full report directories' + required: false + default: '60' + type: string dry-run: description: 'If true, only list files that would be deleted (no deletion or commit)' required: false @@ -28,50 +33,66 @@ jobs: env: GIT_LFS_SKIP_SMUDGE: 1 # Don't actually download LFS content - - name: Set retention days - id: retention - run: | - DAYS="${{ github.event.inputs.retention-days || '14' }}" - [[ "$DAYS" =~ ^[1-9][0-9]*$ ]] || { echo "Error: retention-days must be a positive integer"; exit 1; } - echo "days=$DAYS" >> $GITHUB_OUTPUT - - - name: Prune Allure attachments + - name: Prune old attachments and report directories env: DRY_RUN: ${{ github.event.inputs.dry-run || 'false' }} - RETENTION_DAYS: ${{ steps.retention.outputs.days }} + ATTACHMENT_DAYS: ${{ github.event.inputs.attachment-retention-days || '14' }} + REPORT_DAYS: ${{ github.event.inputs.report-retention-days || '60' }} run: | - CUTOFF_DATE=$(date -d "$RETENTION_DAYS days ago" +%s) + [[ "$ATTACHMENT_DAYS" =~ ^[1-9][0-9]*$ ]] || { echo "Error: retention-days must be a positive integer"; exit 1; } + [[ "$REPORT_DAYS" =~ ^[1-9][0-9]*$ ]] || { echo "Error: report-retention-days must be a positive integer"; exit 1; } + PREVIEW_LIMIT=20 - DELETE_LIST="files_to_delete.txt" - echo "Retention: $RETENTION_DAYS days | Dry run: $DRY_RUN" >> $GITHUB_STEP_SUMMARY + # Returns 0 (true) if the given path's last commit is older than the given cutoff epoch + is_older_than() { + local path="$1" cutoff="$2" + local commit_date + commit_date=$(git log -1 --format="%ct" -- "$path" 2>/dev/null || echo "0") + [ "$commit_date" -ne "0" ] && [ "$commit_date" -lt "$cutoff" ] + } + + # Previews or deletes items listed in a file, then summarises + apply_deletions() { + local list="$1" label="$2" + local count + count=$(wc -l < "$list" 2>/dev/null || echo 0) + echo "Found $count old $label" >> $GITHUB_STEP_SUMMARY + if [ "$count" -gt 0 ]; then + if [ "$DRY_RUN" == "true" ]; then + echo "$label to delete:" >> $GITHUB_STEP_SUMMARY + head -$PREVIEW_LIMIT "$list" >> $GITHUB_STEP_SUMMARY + [ "$count" -gt $PREVIEW_LIMIT ] && echo "...(+$((count-PREVIEW_LIMIT)) more)" >> $GITHUB_STEP_SUMMARY + else + while IFS= read -r item; do rm -rf "$item"; done < "$list" + echo "Deleted $count $label" >> $GITHUB_STEP_SUMMARY + fi + fi + rm -f "$list" + } - # Find old attachment files - > "$DELETE_LIST" + echo "Attachment retention: $ATTACHMENT_DAYS days | Report retention: $REPORT_DAYS days | Dry run: $DRY_RUN" >> $GITHUB_STEP_SUMMARY + + # Prune attachment files older than ATTACHMENT_DAYS + ATTACHMENT_CUTOFF=$(date -d "$ATTACHMENT_DAYS days ago" +%s) + ATTACHMENT_LIST="attachments_to_delete.txt" + > "$ATTACHMENT_LIST" for pattern in '*/data/attachments/*.png' '*/data/attachments/*.txt' '*/data/attachments/*.imagediff'; do while IFS= read -r -d '' file; do - COMMIT_DATE=$(git log -1 --format="%ct" -- "$file" 2>/dev/null || echo "0") - [ "$COMMIT_DATE" -lt "$CUTOFF_DATE" ] && [ "$COMMIT_DATE" -ne "0" ] && echo "$file" >> "$DELETE_LIST" + is_older_than "$file" "$ATTACHMENT_CUTOFF" && echo "$file" >> "$ATTACHMENT_LIST" done < <(git ls-files -z "$pattern") done + apply_deletions "$ATTACHMENT_LIST" "attachment files" - COUNT=$(wc -l < "$DELETE_LIST" 2>/dev/null || echo 0) - echo "Found $COUNT old files" >> $GITHUB_STEP_SUMMARY - - if [ "$COUNT" -gt 0 ]; then - if [ "$DRY_RUN" == "true" ]; then - echo "Files to delete:" >> $GITHUB_STEP_SUMMARY - if [ -s "$DELETE_LIST" ]; then - head -$PREVIEW_LIMIT "$DELETE_LIST" >> $GITHUB_STEP_SUMMARY - [ "$COUNT" -gt $PREVIEW_LIMIT ] && echo "...(+$((COUNT-PREVIEW_LIMIT)) more)" >> $GITHUB_STEP_SUMMARY - fi - else - xargs rm -f < "$DELETE_LIST" - echo "Deleted $COUNT files" >> $GITHUB_STEP_SUMMARY - fi - fi - - rm -f "$DELETE_LIST" + # Prune report directories older than REPORT_DAYS + REPORT_CUTOFF=$(date -d "$REPORT_DAYS days ago" +%s) + REPORT_LIST="reports_to_delete.txt" + > "$REPORT_LIST" + for dir in android/run-* ios/run-*; do + [ -d "$dir" ] && is_older_than "$dir" "$REPORT_CUTOFF" && echo "$dir" >> "$REPORT_LIST" + done + REPORT_FILE_COUNT=$(while IFS= read -r dir; do find "$dir" -type f; done < "$REPORT_LIST" | wc -l) + apply_deletions "$REPORT_LIST" "report directories ($REPORT_FILE_COUNT files)" - name: Commit changes if: github.event.inputs.dry-run != 'true' @@ -81,7 +102,7 @@ jobs: if [ -n "$(git status --porcelain)" ]; then git add -A - git commit -m "ci: Prune attachments older than ${{ steps.retention.outputs.days }} days" + git commit -m "ci: prune old attachments and report directories" git push fi diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index f60bc0fe7..9cbf79715 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -25,7 +25,7 @@ jobs: submodules: 'recursive' - name: Install node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' @@ -36,7 +36,7 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'patches/**') }} - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 - name: Install dependencies shell: bash diff --git a/.gitmodules b/.gitmodules index e5d68dc1e..3a7ca6af5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "run/localizer/lib"] path = run/localizer/lib - url = https://github.com/session-foundation/session-localization.git + branch = main + url = https://github.com/session-foundation/session-localization.git \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..c67acb7e9 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,204 @@ +# Architecture + +For local setup and running tests, see README.md. + +## Repository Structure + +``` +.github/workflows/ # CI workflows (see Workflows section below) +run/ + test/ + specs/ # Test files (*.spec.ts) + locators/ # UI element locators (LocatorsInterface subclasses + index) + utils/ # Helpers: device open, account creation, screenshots, etc. + state_builder/ # Pre-built multi-device test states via qa-seeder + media/ # Test media files (images, videos, GIFs) + types/ + DeviceWrapper.ts # Central device abstraction (test interaction goes here) + sessionIt.ts # Test wrapper functions (bothPlatformsIt, androidIt, iosIt) + testing.ts # Shared types (User, StrategyExtractionObj, AccessibilityId) + constants/ # Community config, test file paths + localizer/ # Generated string lookup cache (see External Dependencies) + screenshots/ + android/ # Baseline screenshots for visual regression + ios/ +scripts/ # Device setup scripts +``` + +## Key Abstractions + +### DeviceWrapper (`run/types/DeviceWrapper.ts`) + +The tests interact with devices overwhelmingly through `DeviceWrapper`. It wraps the Appium/WebdriverIO client with higher-level methods (element interaction, scrolling, assertions, screenshot capture, etc.). + +**Platform gating.** `device.onIOS()` and `device.onAndroid()` return a stub that no-ops all calls on the wrong platform. This lets test code call platform-specific APIs without wrapping every call in an `if` block. + +**Self-healing locators.** When a locator fails to find an element, the tests can attempt a fuzzy match against all selectors. If healing succeeds, the test continues and is annotated in the Allure report. The annotation makes it easy to spot brittle locators before they cause hard failures — a healed test is a signal to update the locator, not a silent pass. + +### LocatorsInterface / StrategyExtractionObj (`run/test/locators/index.ts`) + +UI element selectors use one of two forms: + +- **`StrategyExtractionObj` (SEO)** — a plain `{ strategy, selector, text? }` object. Used inline for one-off locators where the selector is the same on both platforms. +- **`LocatorsInterface` (LI)** — an abstract class with a `build(): StrategyExtractionObj` method. Subclass one per UI element when the selector differs by platform or the locator is reused across tests. Platform branching lives inside `build()`, keeping call sites clean. + +Rule of thumb: one-off usage → inline SEO. Platform-specific or reused → LI subclass. + +`DeviceWrapper`'s private `resolveLocator()` method accepts either form transparently, so call sites don't need to know which they're passing. + +### State Builder (`run/test/state_builder/index.ts`) + +Pre-builds complex test states (contacts, group chats) using `@session-foundation/qa-seeder` before the app is opened. This avoids spending the first minutes of every multi-device test manually establishing relationships through the UI. + +Exported functions follow the pattern `open_Alice1_Bob1_friends()`, `open_Alice1_Bob1_Charlie1_friends_group()`, etc. Each returns `{ devices, prebuilt }` where `prebuilt` contains typed `User` objects (`{ userName, accountID, recoveryPhrase }`). + +The `User` type is local. The seeder's `StateUser` type (`sessionId`, `seedPhrase`) is mapped at this boundary and never leaks into test code. + +### iOS Capabilities & Test Context (`run/test/utils/capabilities_ios.ts`) + +Every iOS session launches with a set of process arguments baked into the shared capabilities: + +- `debugDisappearingMessageDurations: true` — enables shortened disappearing message timers in the app, so DM tests don't have to wait for real-world durations +- `animationsEnabled: false` — disables UI animations for test stability +- `communityPollLimit: 3` — caps community polling frequency + +Per-test overrides are passed via `IOSTestContext`: + +```typescript +type IOSTestContext = { + customInstallTime?: string; // fake first-install timestamp ("time travel") + sessionProEnabled?: string; // enables Session Pro features +}; +``` + +`customInstallTime` injects a `customFirstInstallDateTime` env var into the app process, letting CTA tests simulate the app having been installed days in the past without waiting. `IOSTestContext` threads through the state builder functions and `openApp*` utilities — pass it at the call site to apply it for that test. + +Android handles these behaviours via build flags in the `qa` binary rather than runtime capability overrides, so no equivalent mechanism exists on that side. + +### Test Wrappers (`run/types/sessionIt.ts`) + +Tests use `bothPlatformsIt()`, `androidIt()`, `iosIt()`, or `bothPlatformsItSeparate()` instead of Playwright's `test()`. Each takes: + +```typescript +{ + title: string; + risk: 'low' | 'medium' | 'high'; + countOfDevicesNeeded: 1 | 2 | 3 | 4; + testCb: (platform, testInfo) => Promise; + shouldSkip?: boolean; + isPro?: boolean; + allureSuites?: { parent: string; suite: string }; + allureDescription?: string; + allureLinks?: { + all?: string[] | string; + android?: string[] | string; + ios?: string[] | string; + }; +} +``` + +The wrapper generates test names with grep tags automatically: `@android`/`@ios`, `@low-risk`/`@medium-risk`/`@high-risk`, `@1-devices` through `@4-devices`, `@pro`. These tags are how CI filters test runs by platform, risk level, or device count. + +## Test Execution Flow + +1. `global-setup.ts` validates that the environment is sane (correct platform env var, reachable network target). +2. Playwright assigns tests to workers. Workers run fully parallel; device allocation is tracked via `run/test/utils/device_registry.ts` to prevent conflicts. +3. The test callback calls a state builder function or `openApp*` directly. Appium connects to the emulator/simulator, installs the app, and restores accounts from recovery phrases. +4. Test steps run against `DeviceWrapper` instances. +5. On failure, `run/test/utils/failure_artifacts.ts` captures screenshots and device logs and attaches them via Playwright's `testInfo.attach()` — these appear in the standard Playwright report regardless of whether Allure is enabled. +6. A `finally` block unregisters devices from the registry regardless of outcome. +7. If `ALLURE_ENABLED=true`, additional Allure metadata (suites, risk, healed locator annotations) is written to the report. + +## External Dependencies + +### `@session-foundation/qa-seeder` + +A package that handles pre-test state: creates users with recovery phrases, provisions groups and communities, and links devices over the network. It is a required dependency for the state builder functions. If the package becomes unavailable or its API drifts out of sync, those functions will fail — but tests that call `openApp*` utilities directly (without the state builder) will continue to work. + +### Visual regression baselines + +Screenshot comparison uses SSIM via `looks-same`. Baselines live in `run/screenshots/{android,ios}/`. On mismatch, diffs are saved to `test-results/diffs/` and attached to the Allure report with a visual comparison UI. + +`UPDATE_BASELINES=true` auto-saves a baseline when none exists. It only runs when the baseline file is missing — it will not overwrite an existing one. To update a baseline after an intentional UI change, delete the old file first, then run with `UPDATE_BASELINES=true`. + +Tests require specific device resolutions (Pixel 6 for Android, iPhone 17 for iOS) to produce consistent screenshots. Using different device models will cause baseline mismatches. + +### Localizer (`run/localizer/`) + +A generated cache of UI strings extracted from the app. Used in tests that assert specific copy. If the app's strings change, the latest strings need to be pulled from the [shared repo](https://github.com/session-foundation/session-localization): + +```bash +git submodule update --init --recursive --remote +``` + +## CI + +The automated regression tests currently run on [self-hosted runners](https://docs.github.com/en/actions/concepts/runners/self-hosted-runners). + +This document assumes that the Node.js/Android/iOS environment outlined in README.md has been set up successfully. + +### Workflows + +| Workflow | Trigger | Purpose | +| ------------------------ | ------------------------------- | ------------------------------------------------------- | +| `android-regression.yml` | Manual dispatch | Full Android test suite on the self-hosted Linux runner | +| `ios-regression.yml` | Manual dispatch | Full iOS test suite on the self-hosted macOS runner | +| `pull.yml` | Pull request | PR validation | +| `deploy-gh-pages.yml` | After Android/iOS completion | Publishes Allure report to GitHub Pages | +| `allure-rollback.yml` | Manual dispatch | Rolls back the last published report | +| `prune-attachments.yml` | Manual dispatch/weekly cron job | Prunes LFS attachment history to keep footprint low | + +### Android + +The Android tests run on a self-hosted Linux machine. The workflow currently identifies this machine by the following runner tags: `[self-hosted, linux, X64, qa-android]` + +4 emulators are booted from a stable (low CPU usage) snapshot. + +The scripts that create and start these devices incl. snapshot management are contained in `scripts/ci.sh`. + +#### Network + +The CI tests are configured to run against a local devnet which is not exposed to the public internet. The self-hosted runner must be on the same network as the devnet to function. + +### iOS + +The iOS tests run on a self-hosted macOS machine. The workflow currently identifies this machine by the following runner tags: `[self-hosted, macOS]` + +12 simulators are booted which are pre-loaded with various media files. + +To set up these devices, run `CI=1 pnpm create-simulators 12` and commit the resulting `ci-simulators.json` to the repository. + +### Allure + +When run on CI, the tests generate and publish test reports to [GitHub Pages](https://session-foundation.github.io/session-appium/) by default. + +The corresponding deployment workflow runs automatically after Android/iOS workflow completion. + +To keep the repository lean, attachments are stored in LFS with their URLs patched to point to GitHub's own CDN. + +The deployment preserves history across runs but it is expected that the `prune-attachments.yml` script is ran periodically to keep the LFS footprint low. + +The last test report can be rolled back on demand with the `allure-rollback.yml` script. + +### Secrets + +To run community admin tests, the 13-word recovery phrase of a Community Admin has been saved under the `SOGS_ADMIN_SEED` secret variable. + +## Maintenance Notes + +**Locators break when app UI changes.** Update the relevant `LocatorsInterface` subclass in `run/test/locators/`. Check the Allure report for self-healed tests first — healing surfaces brittle locators before they become full failures. + +**Baseline screenshots need updating when intentional UI changes ship.** Delete the affected baseline files in `run/screenshots/`, then run the affected tests with `UPDATE_BASELINES=true` against the correct device (Pixel 6 / iPhone 17) to regenerate them, then commit the new images via LFS. + +**iOS simulators need reprovisioning if the macOS runner is rebuilt.** Rerun `CI=1 pnpm create-simulators 12` and commit the new `ci-simulators.json`. + +**Android emulator snapshots** are managed by `scripts/ci.sh`. Refer to that script if the Linux runner needs rebuilding. + +**LFS footprint** grows with each CI run. Run `prune-attachments.yml` periodically. + +**Dependabot** is configured in `.github/dependabot.yml`. + +**pnpm patches** live in `patches/` and are applied automatically on install. If either patched dependency is upgraded, the patch may fail to apply or become redundant — check `pnpm install` output after upgrades: + +- `appium-uiautomator2-driver` — expands the device port range to `[8200, 8999]` to support parallel device sessions +- `allure-playwright` — purely cosmetic, renames stdout/stderr labels in the Allure report diff --git a/README.md b/README.md index a90028fc3..619f0a775 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,102 @@ -# Automation testing for Session +# Automated Testing for Session Mobile -This repository holds the code to do integration tests with Appium and Session on iOS and Android. +This repository holds the code to run integration tests for Session iOS and Android. -# Setup +## Quick Start -## Android SDK & Emulators +### Prerequisites -First, you need to download android studio at https://developer.android.com/studio. +- Node.js 24.12.0 +- pnpm 10.28.1 +- Git LFS -Once installed, run it, open the SDK Manager and install the latest SDK tools. - -Once this is done, open up the AVD Manager, click on "Create Device" -> "Pixel 6" -> Next -> Select the System Image you want (I did my tests with **UpsideDownCake**), install it, select it, "Next" and "Finish". - -Then, create a second emulator following the exact same steps (the tests need 2 different emulators to run). - -Once done, you should be able to start each emulators and have them running at the same time. They will need to be running for the tests to work, because Appium won't start them. - -## Environment variables needed - -Before you can start the tests, you need to setup some environment variables. See the file .env.sample for an example. - -#### ANDROID_SDK_ROOT - -`ANDROID_SDK_ROOT` should point to the folder containing the sdks, so the folder containing folders like `platform-tools`, `system-images`, etc... -`export ANDROID_SDK_ROOT=~/Android/Sdk` - -#### APPIUM_ANDROID_BINARIES_ROOT - -`APPIUM_ANDROID_BINARIES_ROOT` should point to the file containing the apks to install for testing (such as `session-1.18.2-x86.apk`) -`export APPIUM_ANDROID_BINARIES_ROOT=~/appium-binaries` - -#### APPIUM_ADB_FULL_PATH - -`APPIUM_ADB_FULL_PATH` should point to the binary of adb inside the ANDROID_SDK folder -`export APPIUM_ADB_FULL_PATH=~/Android/Sdk/platform-tools/adb` - -### Multiple adb binaries - -Having multiple adb on your system will make tests unreliable, because the server will be restarted by Appium. - -On linux, if running `which adb` does not point to the `adb` binary in the `ANDROID_SDK_ROOT` you will have issues. - -You can get rid of adb on linux by running - -``` -sudo apt remove adb -sudo apt remove android-tools-adb -``` - -`which adb` should not return anything. - -Somehow, Appium asks for the sdk tools but do not force the adb binary to come from the sdk tools folder. Making sure that there is no adb in your path should solve this. - -## Running tests on iOS Emulators - -First you need to get correct branch of Session that you want to test from Github. See [(https://github.com/session-foundation/session-ios/releases/)] and download the latest **ipa** under **Assets** - -Then to access the **.app** file that Appium needs for testing you need to build in Xcode and then find .app in your **Derived Data** folder for Xcode. - -For Mac users this file will exist in: - -Macintosh HD > Username > Library > Developer > Xcode > Derived Data > (Then there will be a version of Session with a very long line of letters) > Build > Products > App store-iphonesimulator > Session.app - -Then Copy and Paste then app file onto Desktop (or anywhere you can access easily) then each time you build, navigate back to the file in Derived Data and copy and paste back to Desktop. -Then set the path to Session.app in your ios capabilities file. - -## Appium & tests setup - -First, install nvm for your system (https://github.com/nvm-sh/nvm). -For windows, head here: https://github.com/coreybutler/nvm-windows -For Mac, https://github.com/nvm-sh/nvm - -You can check the current node version in `.tool-versions` -``` -nvm install -nvm use -git lfs install -git lfs pull -git submodule update --init --recursive +```bash pnpm install --frozen-lockfile +git lfs install && git lfs pull +git submodule update --init --recursive ``` -Then, choose an option: +### Running tests +```bash +pnpm start-server # Starts Appium server +pnpm test # Run all tests +pnpm test-android # Android tests only +pnpm test-ios # iOS tests only +pnpm test-one 'Test name' # Run specific test (both platforms) +pnpm test-one 'Test name @android' # Run specific test on one platform ``` -pnpm tsc # Build typescript files -pnpm run test # Run all the tests -Platform specific -pnpm run test-android # To run just Android tests -pnpm run test-ios # To run just iOS tests +For CI setup and codebase overview, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Local Development + +Note: The tests use devices with specific resolutions for visual regression testing - ensure you have these available (see below). + +### Android + +Prerequisites: Android Studio installed with SDK tools available +1. Create 4x Pixel 6 emulators via AVD Manager (minimum 4 emulators - tests require up to 4 devices simultaneously) + - Recommended system image is Android API 34 with Google Play services + - Emulator names are not significant. The tests discover running emulators automatically. +2. Configure the emulators' virtual scene to enable custom image injection to camera viewport + ```bash + pnpm setup-virtual-scene + ``` +3. Download Session binaries from [the build repository](https://oxen.rocks) + - Choose the appropriate binary based on your network access: + - QA: Pre-configured to mainnet, can run on any network + - AutomaticQA: Pre-configured to a local devnet, must have access +4. Set environment variable: + ```bash + # In your .env file + ANDROID_APK=/path/to/session-android.apk + ``` +5. Start emulators manually - they need to be running before tests start (Appium won't launch them automatically) + ```bash + emulator @ + ``` + +### iOS +Prerequisites: Xcode installed and the appropriate simulator runtime available - check in `scripts/create_ios_simulators.ts` + +1. Create iOS simulators with preloaded media attachments: + ```bash + # Local development (create 4 simulators to be able to run all tests) + pnpm create-simulators 4 + # Or specify custom count + pnpm create-simulators + ``` +2. Download Session binaries from the [the build repository](https://oxen.rocks) +3. Extract .app file and copy Session.app to an easily accessible location +4. Set environment variable: + + ```bash + # In your .env file + IOS_APP_PATH_PREFIX=/path/to/Session.app + ``` + +### Environment Configuration + +```bash +cp .env.sample .env +``` -pnpm run test-one 'Name of test' # To run one test (on both platforms) -pnpm run test-one 'Name of test android/ios' # To run one test on either platform +**Required paths:** +```bash +ANDROID_SDK_ROOT=/path/to/Android/Sdk # SDK tools auto-discovered from here +ANDROID_APK=/path/to/session-android.apk # Android APK for testing +IOS_APP_PATH_PREFIX=/path/to/Session.app # iOS app for testing ``` + +**Test configuration:** +```bash +_TESTING=1 # Skip printing appium/wdio logs +PLAYWRIGHT_RETRIES_COUNT=0 # Test retry attempts +PLAYWRIGHT_REPEAT_COUNT=0 # Successful test repeat count +PLAYWRIGHT_WORKERS_COUNT=1 # Parallel test workers +CI=0 # Set to 1 to simulate CI (mostly for Allure reporting) +ALLURE_ENABLED=false # Set to 'true' to generate Allure reports (in conjunction with CI=1) +UPDATE_BASELINES=true # Auto-save new screenshot baselines if unavailable +SOGS_ADMIN_SEED='word1 word2...' # 13-word recovery phrase of an account that's an admin in the testing SOGS. +``` \ No newline at end of file diff --git a/appium_next.d.ts b/appium_next.d.ts deleted file mode 100644 index bed033dc9..000000000 --- a/appium_next.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ -import { ExternalDriver } from '@appium/types/build/lib/driver'; - -// typings comes from : -// node_modules/@appium/types/build/lib/driver.d.ts -// BUT. they are defined as optional, so here we just copy and paste the one we need, and hardcode the fact that they are defined. -// We need to do this, because the iosDriver and the androidDriver do not export the typings (where they defined that those function exists) - -export interface MightBeUndefinedDeviceType extends ExternalDriver {} - -export type AppiumNextDeviceType = { - pushFile(remotePath: string, payloadBase64: string): Promise; - - // not sure at all - touchMove(x: number, y: number): Promise; -}; diff --git a/english_wordlist.txt b/english_wordlist.txt new file mode 100644 index 000000000..9b92e9344 --- /dev/null +++ b/english_wordlist.txt @@ -0,0 +1,1626 @@ +abbey +abducts +ability +ablaze +abnormal +abort +abrasive +absorb +abyss +academy +aces +aching +acidic +acoustic +acquire +across +actress +acumen +adapt +addicted +adept +adhesive +adjust +adopt +adrenalin +adult +adventure +aerial +afar +affair +afield +afloat +afoot +afraid +after +against +agenda +aggravate +agile +aglow +agnostic +agony +agreed +ahead +aided +ailments +aimless +airport +aisle +ajar +akin +alarms +album +alchemy +alerts +algebra +alkaline +alley +almost +aloof +alpine +already +also +altitude +alumni +always +amaze +ambush +amended +amidst +ammo +amnesty +among +amply +amused +anchor +android +anecdote +angled +ankle +annoyed +answers +antics +anvil +anxiety +anybody +apart +apex +aphid +aplomb +apology +apply +apricot +aptitude +aquarium +arbitrary +archer +ardent +arena +argue +arises +army +around +arrow +arsenic +artistic +ascend +ashtray +aside +asked +asleep +aspire +assorted +asylum +athlete +atlas +atom +atrium +attire +auburn +auctions +audio +august +aunt +austere +autumn +avatar +avidly +avoid +awakened +awesome +awful +awkward +awning +awoken +axes +axis +axle +aztec +azure +baby +bacon +badge +baffles +bagpipe +bailed +bakery +balding +bamboo +banjo +baptism +basin +batch +bawled +bays +because +beer +befit +begun +behind +being +below +bemused +benches +berries +bested +betting +bevel +beware +beyond +bias +bicycle +bids +bifocals +biggest +bikini +bimonthly +binocular +biology +biplane +birth +biscuit +bite +biweekly +blender +blip +bluntly +boat +bobsled +bodies +bogeys +boil +boldly +bomb +border +boss +both +bounced +bovine +bowling +boxes +boyfriend +broken +brunt +bubble +buckets +budget +buffet +bugs +building +bulb +bumper +bunch +business +butter +buying +buzzer +bygones +byline +bypass +cabin +cactus +cadets +cafe +cage +cajun +cake +calamity +camp +candy +casket +catch +cause +cavernous +cease +cedar +ceiling +cell +cement +cent +certain +chlorine +chrome +cider +cigar +cinema +circle +cistern +citadel +civilian +claim +click +clue +coal +cobra +cocoa +code +coexist +coffee +cogs +cohesive +coils +colony +comb +cool +copy +corrode +costume +cottage +cousin +cowl +criminal +cube +cucumber +cuddled +cuffs +cuisine +cunning +cupcake +custom +cycling +cylinder +cynical +dabbing +dads +daft +dagger +daily +damp +dangerous +dapper +darted +dash +dating +dauntless +dawn +daytime +dazed +debut +decay +dedicated +deepest +deftly +degrees +dehydrate +deity +dejected +delayed +demonstrate +dented +deodorant +depth +desk +devoid +dewdrop +dexterity +dialect +dice +diet +different +digit +dilute +dime +dinner +diode +diplomat +directed +distance +ditch +divers +dizzy +doctor +dodge +does +dogs +doing +dolphin +domestic +donuts +doorway +dormant +dosage +dotted +double +dove +down +dozen +dreams +drinks +drowning +drunk +drying +dual +dubbed +duckling +dude +duets +duke +dullness +dummy +dunes +duplex +duration +dusted +duties +dwarf +dwelt +dwindling +dying +dynamite +dyslexic +each +eagle +earth +easy +eating +eavesdrop +eccentric +echo +eclipse +economics +ecstatic +eden +edgy +edited +educated +eels +efficient +eggs +egotistic +eight +either +eject +elapse +elbow +eldest +eleven +elite +elope +else +eluded +emails +ember +emerge +emit +emotion +empty +emulate +energy +enforce +enhanced +enigma +enjoy +enlist +enmity +enough +enraged +ensign +entrance +envy +epoxy +equip +erase +erected +erosion +error +eskimos +espionage +essential +estate +etched +eternal +ethics +etiquette +evaluate +evenings +evicted +evolved +examine +excess +exhale +exit +exotic +exquisite +extra +exult +fabrics +factual +fading +fainted +faked +fall +family +fancy +farming +fatal +faulty +fawns +faxed +fazed +feast +february +federal +feel +feline +females +fences +ferry +festival +fetches +fever +fewest +fiat +fibula +fictional +fidget +fierce +fifteen +fight +films +firm +fishing +fitting +five +fixate +fizzle +fleet +flippant +flying +foamy +focus +foes +foggy +foiled +folding +fonts +foolish +fossil +fountain +fowls +foxes +foyer +framed +friendly +frown +fruit +frying +fudge +fuel +fugitive +fully +fuming +fungal +furnished +fuselage +future +fuzzy +gables +gadget +gags +gained +galaxy +gambit +gang +gasp +gather +gauze +gave +gawk +gaze +gearbox +gecko +geek +gels +gemstone +general +geometry +germs +gesture +getting +geyser +ghetto +ghost +giant +giddy +gifts +gigantic +gills +gimmick +ginger +girth +giving +glass +gleeful +glide +gnaw +gnome +goat +goblet +godfather +goes +goggles +going +goldfish +gone +goodbye +gopher +gorilla +gossip +gotten +gourmet +governing +gown +greater +grunt +guarded +guest +guide +gulp +gumball +guru +gusts +gutter +guys +gymnast +gypsy +gyrate +habitat +hacksaw +haggled +hairy +hamburger +happens +hashing +hatchet +haunted +having +hawk +haystack +hazard +hectare +hedgehog +heels +hefty +height +hemlock +hence +heron +hesitate +hexagon +hickory +hiding +highway +hijack +hiker +hills +himself +hinder +hippo +hire +history +hitched +hive +hoax +hobby +hockey +hoisting +hold +honked +hookup +hope +hornet +hospital +hotel +hounded +hover +howls +hubcaps +huddle +huge +hull +humid +hunter +hurried +husband +huts +hybrid +hydrogen +hyper +iceberg +icing +icon +identity +idiom +idled +idols +igloo +ignore +iguana +illness +imagine +imbalance +imitate +impel +inactive +inbound +incur +industrial +inexact +inflamed +ingested +initiate +injury +inkling +inline +inmate +innocent +inorganic +input +inquest +inroads +insult +intended +inundate +invoke +inwardly +ionic +irate +iris +irony +irritate +island +isolated +issued +italics +itches +items +itinerary +itself +ivory +jabbed +jackets +jaded +jagged +jailed +jamming +january +jargon +jaunt +javelin +jaws +jazz +jeans +jeers +jellyfish +jeopardy +jerseys +jester +jetting +jewels +jigsaw +jingle +jittery +jive +jobs +jockey +jogger +joining +joking +jolted +jostle +journal +joyous +jubilee +judge +juggled +juicy +jukebox +july +jump +junk +jury +justice +juvenile +kangaroo +karate +keep +kennel +kept +kernels +kettle +keyboard +kickoff +kidneys +king +kiosk +kisses +kitchens +kiwi +knapsack +knee +knife +knowledge +knuckle +koala +laboratory +ladder +lagoon +lair +lakes +lamb +language +laptop +large +last +later +launching +lava +lawsuit +layout +lazy +lectures +ledge +leech +left +legion +leisure +lemon +lending +leopard +lesson +lettuce +lexicon +liar +library +licks +lids +lied +lifestyle +light +likewise +lilac +limits +linen +lion +lipstick +liquid +listen +lively +loaded +lobster +locker +lodge +lofty +logic +loincloth +long +looking +lopped +lordship +losing +lottery +loudly +love +lower +loyal +lucky +luggage +lukewarm +lullaby +lumber +lunar +lurk +lush +luxury +lymph +lynx +lyrics +macro +madness +magically +mailed +major +makeup +malady +mammal +maps +masterful +match +maul +maverick +maximum +mayor +maze +meant +mechanic +medicate +meeting +megabyte +melting +memoir +menu +merger +mesh +metro +mews +mice +midst +mighty +mime +mirror +misery +mittens +mixture +moat +mobile +mocked +mohawk +moisture +molten +moment +money +moon +mops +morsel +mostly +motherly +mouth +movement +mowing +much +muddy +muffin +mugged +mullet +mumble +mundane +muppet +mural +musical +muzzle +myriad +mystery +myth +nabbing +nagged +nail +names +nanny +napkin +narrate +nasty +natural +nautical +navy +nearby +necklace +needed +negative +neither +neon +nephew +nerves +nestle +network +neutral +never +newt +nexus +nibs +niche +niece +nifty +nightly +nimbly +nineteen +nirvana +nitrogen +nobody +nocturnal +nodes +noises +nomad +noodles +northern +nostril +noted +nouns +novelty +nowhere +nozzle +nuance +nucleus +nudged +nugget +nuisance +null +number +nuns +nurse +nutshell +nylon +oaks +oars +oasis +oatmeal +obedient +object +obliged +obnoxious +observant +obtains +obvious +occur +ocean +october +odds +odometer +offend +often +oilfield +ointment +okay +older +olive +olympics +omega +omission +omnibus +onboard +oncoming +oneself +ongoing +onion +online +onslaught +onto +onward +oozed +opacity +opened +opposite +optical +opus +orange +orbit +orchid +orders +organs +origin +ornament +orphans +oscar +ostrich +otherwise +otter +ouch +ought +ounce +ourselves +oust +outbreak +oval +oven +owed +owls +owner +oxidant +oxygen +oyster +ozone +pact +paddles +pager +pairing +palace +pamphlet +pancakes +paper +paradise +pastry +patio +pause +pavements +pawnshop +payment +peaches +pebbles +peculiar +pedantic +peeled +pegs +pelican +pencil +people +pepper +perfect +pests +petals +phase +pheasants +phone +phrases +physics +piano +picked +pierce +pigment +piloted +pimple +pinched +pioneer +pipeline +pirate +pistons +pitched +pivot +pixels +pizza +playful +pledge +pliers +plotting +plus +plywood +poaching +pockets +podcast +poetry +point +poker +polar +ponies +pool +popular +portents +possible +potato +pouch +poverty +powder +pram +present +pride +problems +pruned +prying +psychic +public +puck +puddle +puffin +pulp +pumpkins +punch +puppy +purged +push +putty +puzzled +pylons +pyramid +python +queen +quick +quote +rabbits +racetrack +radar +rafts +rage +railway +raking +rally +ramped +randomly +rapid +rarest +rash +rated +ravine +rays +razor +react +rebel +recipe +reduce +reef +refer +regular +reheat +reinvest +rejoices +rekindle +relic +remedy +renting +reorder +repent +request +reruns +rest +return +reunion +revamp +rewind +rhino +rhythm +ribbon +richly +ridges +rift +rigid +rims +ringing +riots +ripped +rising +ritual +river +roared +robot +rockets +rodent +rogue +roles +romance +roomy +roped +roster +rotate +rounded +rover +rowboat +royal +ruby +rudely +ruffled +rugged +ruined +ruling +rumble +runway +rural +rustled +ruthless +sabotage +sack +sadness +safety +saga +sailor +sake +salads +sample +sanity +sapling +sarcasm +sash +satin +saucepan +saved +sawmill +saxophone +sayings +scamper +scenic +school +science +scoop +scrub +scuba +seasons +second +sedan +seeded +segments +seismic +selfish +semifinal +sensible +september +sequence +serving +session +setup +seventh +sewage +shackles +shelter +shipped +shocking +shrugged +shuffled +shyness +siblings +sickness +sidekick +sieve +sifting +sighting +silk +simplest +sincerely +sipped +siren +situated +sixteen +sizes +skater +skew +skirting +skulls +skydive +slackens +sleepless +slid +slower +slug +smash +smelting +smidgen +smog +smuggled +snake +sneeze +sniff +snout +snug +soapy +sober +soccer +soda +software +soggy +soil +solved +somewhere +sonic +soothe +soprano +sorry +southern +sovereign +sowed +soya +space +speedy +sphere +spiders +splendid +spout +sprig +spud +spying +square +stacking +stellar +stick +stockpile +strained +stunning +stylishly +subtly +succeed +suddenly +suede +suffice +sugar +suitcase +sulking +summon +sunken +superior +surfer +sushi +suture +swagger +swept +swiftly +sword +swung +syllabus +symptoms +syndrome +syringe +system +taboo +tacit +tadpoles +tagged +tail +taken +talent +tamper +tanks +tapestry +tarnished +tasked +tattoo +taunts +tavern +tawny +taxi +teardrop +technical +tedious +teeming +tell +template +tender +tepid +tequila +terminal +testing +tether +textbook +thaw +theatrics +thirsty +thorn +threaten +thumbs +thwart +ticket +tidy +tiers +tiger +tilt +timber +tinted +tipsy +tirade +tissue +titans +toaster +tobacco +today +toenail +toffee +together +toilet +token +tolerant +tomorrow +tonic +toolbox +topic +torch +tossed +total +touchy +towel +toxic +toyed +trash +trendy +tribal +trolling +truth +trying +tsunami +tubes +tucks +tudor +tuesday +tufts +tugs +tuition +tulips +tumbling +tunnel +turnip +tusks +tutor +tuxedo +twang +tweezers +twice +twofold +tycoon +typist +tyrant +ugly +ulcers +ultimate +umbrella +umpire +unafraid +unbending +uncle +under +uneven +unfit +ungainly +unhappy +union +unjustly +unknown +unlikely +unmask +unnoticed +unopened +unplugs +unquoted +unrest +unsafe +until +unusual +unveil +unwind +unzip +upbeat +upcoming +update +upgrade +uphill +upkeep +upload +upon +upper +upright +upstairs +uptight +upwards +urban +urchins +urgent +usage +useful +usher +using +usual +utensils +utility +utmost +utopia +uttered +vacation +vague +vain +value +vampire +vane +vapidly +vary +vastness +vats +vaults +vector +veered +vegan +vehicle +vein +velvet +venomous +verification +vessel +veteran +vexed +vials +vibrate +victim +video +viewpoint +vigilant +viking +village +vinegar +violin +vipers +virtual +visited +vitals +vivid +vixen +vocal +vogue +voice +volcano +vortex +voted +voucher +vowels +voyage +vulture +wade +waffle +wagtail +waist +waking +wallets +wanted +warped +washing +water +waveform +waxing +wayside +weavers +website +wedge +weekday +weird +welders +went +wept +were +western +wetsuit +whale +when +whipped +whole +wickets +width +wield +wife +wiggle +wildly +winter +wipeout +wiring +wise +withdrawn +wives +wizard +wobbly +woes +woken +wolf +womanly +wonders +woozy +worry +wounded +woven +wrap +wrist +wrong +yacht +yahoo +yanks +yard +yawning +yearbook +yellow +yesterday +yeti +yields +yodel +yoga +younger +yoyo +zapped +zeal +zebra +zero +zesty +zigzags +zinger +zippers +zodiac +zombie +zones +zoom \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 013f581d4..19027f038 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,10 @@ import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; import perfectionist from 'eslint-plugin-perfectionist'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -export default tseslint.config( +export default defineConfig( { files: ['**/*.{ts,tsx,cts,mts,js,cjs,mjs}'], }, diff --git a/github/actions/fetch-allure-history/action.yml b/github/actions/fetch-allure-history/action.yml index f0558369f..ddd246c31 100644 --- a/github/actions/fetch-allure-history/action.yml +++ b/github/actions/fetch-allure-history/action.yml @@ -18,6 +18,8 @@ runs: echo "Clearing temp clone" rm -rf "$CLONE_DIR" + git config --global credential.helper "" + echo "Cloning gh-pages branch" GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 --branch gh-pages "$GH_REPO" "$CLONE_DIR" diff --git a/github/actions/generate-publish-test-report/action.yml b/github/actions/generate-publish-test-report/action.yml index 05bed59de..86b41fc68 100644 --- a/github/actions/generate-publish-test-report/action.yml +++ b/github/actions/generate-publish-test-report/action.yml @@ -15,6 +15,8 @@ inputs: required: true GITHUB_RUN_ATTEMPT: required: true + NETWORK_TARGET: + required: true runs: using: 'composite' @@ -27,24 +29,27 @@ runs: PLATFORM: ${{ inputs.PLATFORM }} BUILD_NUMBER: ${{ inputs.BUILD_NUMBER }} GH_TOKEN: ${{ inputs.GH_TOKEN }} - APK_URL: ${{inputs.APK_URL}} - RISK: ${{inputs.RISK}} + APK_URL: ${{ inputs.APK_URL}} + RISK: ${{ inputs.RISK}} GITHUB_RUN_NUMBER: ${{ inputs.GITHUB_RUN_NUMBER}} GITHUB_RUN_ATTEMPT: ${{ inputs.GITHUB_RUN_ATTEMPT}} + NETWORK_TARGET: ${{ inputs.NETWORK_TARGET }} - name: Publish report to GitHub Pages if: ${{ success() }} id: publish shell: bash run: | + git config --global lfs.locksverify false npx ts-node run/test/utils/allure/publishReport.ts env: PLATFORM: ${{ inputs.PLATFORM }} BUILD_NUMBER: ${{ inputs.BUILD_NUMBER }} GH_TOKEN: ${{ inputs.GH_TOKEN }} - RISK: ${{inputs.RISK}} + RISK: ${{ inputs.RISK}} GITHUB_RUN_NUMBER: ${{ inputs.GITHUB_RUN_NUMBER}} GITHUB_RUN_ATTEMPT: ${{ inputs.GITHUB_RUN_ATTEMPT}} + NETWORK_TARGET: ${{ inputs.NETWORK_TARGET }} - name: Annotate run summary with report link if: ${{ success() }} diff --git a/github/actions/list-tests/action.yml b/github/actions/list-tests/action.yml index 5afd9fe49..7d666c6a2 100644 --- a/github/actions/list-tests/action.yml +++ b/github/actions/list-tests/action.yml @@ -10,6 +10,10 @@ inputs: RISK: description: "Risk level to filter tests 'high-risk'|'medium-risk'|'low-risk'|''" required: false + PRO_GREP_INVERT: + description: 'Optional --grep-invert flag to exclude @pro tests' + required: false + default: '' runs: using: 'composite' @@ -18,4 +22,4 @@ runs: shell: bash run: | pwd - npx playwright test --list --reporter list --grep "(?=.*@${{ inputs.PLATFORM }})(?=.*@${{ inputs.RISK }})" + npx playwright test --list --reporter list --grep "(?=.*@${{ inputs.PLATFORM }})(?=.*@${{ inputs.RISK }})" ${{ inputs.PRO_GREP_INVERT }} diff --git a/github/actions/setup/action.yml b/github/actions/setup/action.yml index 46d20d6f2..a799df457 100644 --- a/github/actions/setup/action.yml +++ b/github/actions/setup/action.yml @@ -6,15 +6,17 @@ runs: using: 'composite' steps: - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'pnpm' - name: Install test dependencies shell: bash + env: + NODE_OPTIONS: '--dns-result-order=ipv4first' run: | ls git status diff --git a/package.json b/package.json index ba75d6df8..052538dab 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "scripts": { "cleanup-simulators": "npx ts-node scripts/cleanup_ios_simulators.ts", "create-simulators": "pnpm cleanup-simulators && npx ts-node scripts/create_ios_simulators.ts", + "recover-emulators": "npx ts-node scripts/emulator_health.ts", + "setup-virtual-scene": "npx ts-node scripts/setup_virtual_scene.ts", "lint": "pnpm prettier . --write --cache && pnpm eslint . --cache ", "lint-check": "pnpm prettier . --check && pnpm eslint .", "tsc": "tsc", @@ -19,32 +21,33 @@ "test-high-risk-ios": "_TESTING=1 npx playwright test --grep '@ios @high-risk'", "start-server": "./node_modules/.bin/appium server --use-drivers=uiautomator2,xcuitest --port 8110 --allow-cors", "allure-generate": "allure generate allure/allure-results --clean", - "allure-open": "allure open" + "allure-open": "allure open", + "mock-pro": "npx ts-node run/test/utils/mock_pro.ts" }, "devDependencies": { "@appium/execute-driver-plugin": "^5.1.0", "@appium/images-plugin": "^4.1.0", "@appium/opencv": "4.1.0", "@appium/types": "^1.2.0", - "@eslint/js": "^9.39.2", + "@eslint/js": "^10.0.1", "@types/fs-extra": "^11.0.4", "@types/gh-pages": "^6.1.0", - "@types/lodash": "^4.17.23", - "@types/node": "^25.2.0", + "@types/lodash": "^4.17.24", + "@types/node": "^25.3.3", "@types/sinon": "^21.0.0", - "@typescript-eslint/eslint-plugin": "^8.54.0", - "@typescript-eslint/parser": "^8.54.0", - "@wdio/types": "^9.23.3", - "allure-commandline": "^2.36.0", - "allure-js-commons": "^3.4.5", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "@wdio/types": "^9.24.0", + "allure-commandline": "^2.37.0", + "allure-js-commons": "^3.5.0", "allure-playwright": "^3.4.5", - "eslint": "^9.39.2", - "eslint-plugin-perfectionist": "^5.4.0", + "eslint": "^10.0.2", + "eslint-plugin-perfectionist": "^5.6.0", "fs-extra": "^11.3.3", "fuse.js": "^7.1.0", "gh-pages": "^6.3.0", - "glob": "^13.0.1", - "globals": "^17.3.0", + "glob": "^13.0.6", + "globals": "^17.4.0", "lodash": "^4.17.23", "looks-same": "^10.0.1", "png-js": "^1.0.0", @@ -55,8 +58,8 @@ "ssim.js": "^3.5.0", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0", - "undici": "^7.20.0", + "typescript-eslint": "^8.56.1", + "undici": "^7.22.0", "uuid": "^13.0.0" }, "license": "MIT", @@ -66,18 +69,21 @@ }, "dependencies": { "@appium/support": "^7.0.5", - "@playwright/test": "^1.58.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@playwright/test": "^1.58.2", "@session-foundation/playwright-reporter": "^0.0.8", "@session-foundation/qa-seeder": "^0.1.26", "appium": "^3.2.0", - "appium-uiautomator2-driver": "^6.8.0", - "appium-xcuitest-driver": "^10.19.1", - "dotenv": "^17.2.3" + "appium-uiautomator2-driver": "^7.0.0", + "appium-xcuitest-driver": "^10.24.1", + "dotenv": "^17.3.1" }, "packageManager": "pnpm@10.28.1", "pnpm": { "patchedDependencies": { - "appium-uiautomator2-driver": "patches/appium-uiautomator2-driver.patch" + "appium-uiautomator2-driver": "patches/appium-uiautomator2-driver.patch", + "allure-playwright@3.4.5": "patches/allure-playwright@3.4.5.patch" }, "ignoredBuiltDependencies": [ "appium-ios-tuntap", diff --git a/patches/allure-playwright@3.4.5.patch b/patches/allure-playwright@3.4.5.patch new file mode 100644 index 000000000..41c3e6267 --- /dev/null +++ b/patches/allure-playwright@3.4.5.patch @@ -0,0 +1,38 @@ +diff --git a/dist/cjs/index.js b/dist/cjs/index.js +index e09edfdc7f8ddc07f8865a9df6a8895b62f7998c..8af3d392a9a70b971f8fdb0f88e4ed336c5719e0 100644 +--- a/dist/cjs/index.js ++++ b/dist/cjs/index.js +@@ -504,12 +504,12 @@ var AllureReporter = exports.AllureReporter = /*#__PURE__*/function () { + testResult.stage = _allureJsCommons.Stage.FINISHED; + }); + if (result.stdout.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stdout", Buffer.from((0, _sdk.stripAnsi)(result.stdout.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner logs", Buffer.from((0, _sdk.stripAnsi)(result.stdout.join("")), "utf-8"), { + contentType: _allureJsCommons.ContentType.TEXT + }); + } + if (result.stderr.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stderr", Buffer.from((0, _sdk.stripAnsi)(result.stderr.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner errors", Buffer.from((0, _sdk.stripAnsi)(result.stderr.join("")), "utf-8"), { + contentType: _allureJsCommons.ContentType.TEXT + }); + } +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 84ab12679c08c2738e7d9fb53267f950c60cfb86..b55ef16e6900f444aee1944cfa286baa37e6369c 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -486,12 +486,12 @@ export var AllureReporter = /*#__PURE__*/function () { + testResult.stage = Stage.FINISHED; + }); + if (result.stdout.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stdout", Buffer.from(stripAnsi(result.stdout.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner logs", Buffer.from(stripAnsi(result.stdout.join("")), "utf-8"), { + contentType: ContentType.TEXT + }); + } + if (result.stderr.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stderr", Buffer.from(stripAnsi(result.stderr.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner errors", Buffer.from(stripAnsi(result.stderr.join("")), "utf-8"), { + contentType: ContentType.TEXT + }); + } diff --git a/playwright.config.ts b/playwright.config.ts index 4794411a8..5e0582a4f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,6 +17,7 @@ const baseReporter: ReporterDescription = [ const allureReporter: ReporterDescription = [ 'allure-playwright', { + detail: false, // No Playwright internal steps in the test body resultsDir: allureResultsDir, categories: [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eddae006b..e5934380f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ overrides: tar-fs@>=3.0.0 <3.0.7: '>=3.0.7' patchedDependencies: + allure-playwright@3.4.5: + hash: 8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305 + path: patches/allure-playwright@3.4.5.patch appium-uiautomator2-driver: hash: 8226be3d8d63cd3e3963f8450fc068a726a9a71eddecad1a612f92bdbd92d121 path: patches/appium-uiautomator2-driver.patch @@ -22,9 +25,15 @@ importers: '@appium/support': specifier: ^7.0.5 version: 7.0.5 + '@noble/curves': + specifier: ^2.0.1 + version: 2.0.1 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 '@playwright/test': - specifier: ^1.58.1 - version: 1.58.1 + specifier: ^1.58.2 + version: 1.58.2 '@session-foundation/playwright-reporter': specifier: ^0.0.8 version: 0.0.8 @@ -35,14 +44,14 @@ importers: specifier: ^3.2.0 version: 3.2.0 appium-uiautomator2-driver: - specifier: ^6.8.0 - version: 6.8.0(patch_hash=8226be3d8d63cd3e3963f8450fc068a726a9a71eddecad1a612f92bdbd92d121)(appium@3.2.0) + specifier: ^7.0.0 + version: 7.0.0(patch_hash=8226be3d8d63cd3e3963f8450fc068a726a9a71eddecad1a612f92bdbd92d121)(appium@3.2.0) appium-xcuitest-driver: - specifier: ^10.19.1 - version: 10.19.1(appium@3.2.0) + specifier: ^10.24.1 + version: 10.24.1(appium@3.2.0) dotenv: - specifier: ^17.2.3 - version: 17.2.3 + specifier: ^17.3.1 + version: 17.3.1 devDependencies: '@appium/execute-driver-plugin': specifier: ^5.1.0 @@ -57,8 +66,8 @@ importers: specifier: ^1.2.0 version: 1.2.0 '@eslint/js': - specifier: ^9.39.2 - version: 9.39.2 + specifier: ^10.0.1 + version: 10.0.1(eslint@10.0.2) '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -66,38 +75,38 @@ importers: specifier: ^6.1.0 version: 6.1.0 '@types/lodash': - specifier: ^4.17.23 - version: 4.17.23 + specifier: ^4.17.24 + version: 4.17.24 '@types/node': - specifier: ^25.2.0 - version: 25.2.0 + specifier: ^25.3.3 + version: 25.3.3 '@types/sinon': specifier: ^21.0.0 version: 21.0.0 '@typescript-eslint/eslint-plugin': - specifier: ^8.54.0 - version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.56.1 + version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.54.0 - version: 8.54.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.56.1 + version: 8.56.1(eslint@10.0.2)(typescript@5.9.3) '@wdio/types': - specifier: ^9.23.3 - version: 9.23.3 + specifier: ^9.24.0 + version: 9.24.0 allure-commandline: - specifier: ^2.36.0 - version: 2.36.0 + specifier: ^2.37.0 + version: 2.37.0 allure-js-commons: - specifier: ^3.4.5 - version: 3.4.5(allure-playwright@3.4.5(@playwright/test@1.58.1)) + specifier: ^3.5.0 + version: 3.5.0(allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2)) allure-playwright: specifier: ^3.4.5 - version: 3.4.5(@playwright/test@1.58.1) + version: 3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2) eslint: - specifier: ^9.39.2 - version: 9.39.2 + specifier: ^10.0.2 + version: 10.0.2 eslint-plugin-perfectionist: - specifier: ^5.4.0 - version: 5.4.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^5.6.0 + version: 5.6.0(eslint@10.0.2)(typescript@5.9.3) fs-extra: specifier: ^11.3.3 version: 11.3.3 @@ -108,11 +117,11 @@ importers: specifier: ^6.3.0 version: 6.3.0 glob: - specifier: ^13.0.1 - version: 13.0.1 + specifier: ^13.0.6 + version: 13.0.6 globals: - specifier: ^17.3.0 - version: 17.3.0 + specifier: ^17.4.0 + version: 17.4.0 lodash: specifier: ^4.17.23 version: 4.17.23 @@ -139,16 +148,16 @@ importers: version: 3.5.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.2.0)(typescript@5.9.3) + version: 10.9.2(@types/node@25.3.3)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.54.0 - version: 8.54.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.56.1 + version: 8.56.1(eslint@10.0.2)(typescript@5.9.3) undici: - specifier: ^7.20.0 - version: 7.20.0 + specifier: ^7.22.0 + version: 7.22.0 uuid: specifier: ^13.0.0 version: 13.0.0 @@ -240,33 +249,34 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.2': + resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.2': + resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.1.0': + resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.2': + resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.6.0': + resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -421,14 +431,6 @@ packages: cpu: [x64] os: [win32] - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -446,6 +448,14 @@ packages: '@jsquash/png@3.1.1': resolution: {integrity: sha512-C10pc+0H6j0h8fENOfnGOvkXCmvpSQTDGlfGd0sHphZhPSGTyLjIrHba0FaZZdsKqA/wlmhYicUHb92vfZphaw==} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -462,8 +472,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.58.1': - resolution: {integrity: sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} hasBin: true @@ -537,6 +547,9 @@ packages: '@tsconfig/node20@20.1.8': resolution: {integrity: sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -552,14 +565,14 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - '@types/lodash@4.17.23': - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - '@types/node@20.19.31': - resolution: {integrity: sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==} + '@types/node@20.19.35': + resolution: {integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==} - '@types/node@25.2.0': - resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/node@25.3.3': + resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -585,63 +598,63 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@wdio/config@9.23.2': @@ -663,8 +676,8 @@ packages: resolution: {integrity: sha512-ryfrERGsNp+aCcrTE1rFU6cbmDj8GHZ04R9k52KNt2u1a6bv3Eh5A/cUA0hXuMdEUfsc8ePLYdwQyOLFydZ0ig==} engines: {node: '>=18.20.0'} - '@wdio/types@9.23.3': - resolution: {integrity: sha512-Ufjh06DAD7cGTMORUkq5MTZLw1nAgBSr2y8OyiNNuAfPGCwHEU3EwEfhG/y0V7S7xT5pBxliqWi7AjRrCgGcIA==} + '@wdio/types@9.24.0': + resolution: {integrity: sha512-PYYunNl8Uq1r8YMJAK6ReRy/V/XIrCSyj5cpCtR5EqCL6heETOORFj7gt4uPnzidfgbtMBcCru0LgjjlMiH1UQ==} engines: {node: '>=18.20.0'} '@wdio/utils@9.23.2': @@ -705,6 +718,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -717,14 +735,14 @@ packages: ajv: optional: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - allure-commandline@2.36.0: - resolution: {integrity: sha512-ls/4fk2Psv2Tu2PbWFrQPmUnm3gmmO9MBan4MuPWwqdkJPEmln2KRwtvtWYr9Av+e5AnFK1fGXWVyxqJIPiPwA==} + allure-commandline@2.37.0: + resolution: {integrity: sha512-s3zZ8zjqo2U3i5Lb3iLOCjwWQCtGK58GVpScTnZddOpgTXBDXAbXn+pT7QXN4NiY7pho6xw+UgyREyCRnx/9ug==} hasBin: true allure-js-commons@3.4.5: @@ -735,6 +753,14 @@ packages: allure-playwright: optional: true + allure-js-commons@3.5.0: + resolution: {integrity: sha512-iBVFNQkX5i48QGlb5U3iWm+NiNOl/ucxv6dvEJBNeJTPMI8t0Dn0CuXMQEiv4forSSAppD7FB9uGal2JwunH/A==} + peerDependencies: + allure-playwright: 3.5.0 + peerDependenciesMeta: + allure-playwright: + optional: true + allure-playwright@3.4.5: resolution: {integrity: sha512-pVewTpU9Z4qgT14VJdtYLAfF8rWROuESmvDkvyu/QnFWhRFrcDBnomynj84yx/QpXyMjJL+qu1yMU2z4Mq1YnA==} peerDependencies: @@ -756,64 +782,64 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - appium-adb@14.2.0: - resolution: {integrity: sha512-gT3eg+ZIG+xnNrmVja5BQy0yZLILlJnkF4pFwOgoPKf3e77fBRAo8CdzaYs2/oXs5YY7Tzx3w5ASvaHi1yAPmA==} + appium-adb@14.3.0: + resolution: {integrity: sha512-S1ZKK3R/nRlTMML+G5QliomDtbIYOxna6jOfJeX6X1fvN5Kg4dJo8GQW0+4Y6zHTA5cURWGiDpju+L7ohmJj6Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-android-driver@12.6.6: - resolution: {integrity: sha512-P4qkey5RbiGuuvi0ufS/GizX6MntdessACoqbgL7ieHufnQYkCY3fTHQM8JoIvqFvjFe95UMvjHyHuHJqshDqA==} + appium-android-driver@13.0.0: + resolution: {integrity: sha512-+q7+jPthCLFr4fYQeYV6eKQv4giKj6Pp1Y7qBI87B2+mHn0JgIDZXIyH9Xgt14e0Mj/sYOJN8VS/xPojA9Iang==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} peerDependencies: appium: ^3.0.0-rc.2 - appium-chromedriver@8.2.6: - resolution: {integrity: sha512-WYZ/QYbMy7rPOLNHAhML36/3IjXbyHX2ksWnbq0p9i3dMRUlJEfbYMFfImSleoPFEY3yNXwmDfgGM9rifr2liA==} + appium-chromedriver@8.2.14: + resolution: {integrity: sha512-yTDF+OjsgHdsTRTl/AJsAwwTGdAT41rBaj/+S5IrLTkY2gISdI2A+z6AUeN/gWVXit6y/pGQnlwW5+QqfRPCLg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-idb@2.0.8: - resolution: {integrity: sha512-SlJ3c8XcSKFnGydDr7CFKX7gxsWl/yW65/GO6ql2pIk56XjRLpv5Z4+RZQMzGp2GtOaw+KDRyjTezIIzcGWOOw==} + appium-idb@2.0.9: + resolution: {integrity: sha512-K1puvoS7VjkDhUSr9RDrXt7hZ6+JNBPEjthAohHdhdDHHk4NCBNfb6wGdcX20p/E//8x/kLnydNAzTDQdn3VGg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-ios-device@3.1.9: - resolution: {integrity: sha512-j4zNwszDvBHqyZKqX99RLcif3CnuYDpVKA9E2DBM/4mOFYT+o3bPAMrPZRx2Tt3RImoTCUrTCUinYopsPqX2Eg==} + appium-ios-device@3.1.10: + resolution: {integrity: sha512-2oE7yQtLSdrcZ9YArqgGguzDuiplHj0GXSMlTfwTXl0n22DEzkV0M1mXdaNaWNuzVBJ5VDc1EuYv38p1ruuk2g==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-ios-remotexpc@0.28.1: - resolution: {integrity: sha512-UzrIsD+EpWS4GttJqQcrySyMgQvltkne7wBM1SDXldkiFufKx+RYy+ldnjFi1rMOo66IrvAtL4KhxjBDzxgJ4Q==} + appium-ios-remotexpc@0.30.0: + resolution: {integrity: sha512-I4CPI+U5wvzAa2P92CGtdP5ZClauebYMYvAc7Sx45Nw1JhygpqksQ9y3sJbB1PR8tqyWQYIqEoHSoe7UaaUT6g==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-ios-simulator@8.0.11: - resolution: {integrity: sha512-DMm+XyS4o4iNPJwcGI7eecfmH6B7E8Q/1pANjWIOQhac3hOdUdo1KZxbnIQ4lUbSLzvLWL1T5Rts1UP//Jtwtg==} + appium-ios-simulator@8.0.12: + resolution: {integrity: sha512-ZIq9k0PJTq7MtttQmu8pBkQE7i1TNw/itqklAkwmstld8vTAn2RXv3+hAwFo1aZt01gdSPjky5EFgizNu72//Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} appium-ios-tuntap@0.1.3: resolution: {integrity: sha512-UZYWTIQrdKU3nwL9YjlQG19LWIzTs0CeG1FdgZXUZRu699z7rTJJu/d6JOuHNf/akj3BXRmbItOvllrDqEIz6A==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-remote-debugger@15.3.2: - resolution: {integrity: sha512-7NQpoq0NNEWpxKMUXs3Yhd2dO6dH1mGsVLnIRJpBI2QyidDxgK1DX4ixwCv1Oh9BXeux+3N32gDE2cXxcOZfdQ==} + appium-remote-debugger@15.5.0: + resolution: {integrity: sha512-2b2E/O00IDLvYIqdWA36qYCMRuS6i+BLZGB6A5SYvvc4DcDUBHP4SKgy+k+LLxj8xOfEFyawtwYd207ljLxMMg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-uiautomator2-driver@6.8.0: - resolution: {integrity: sha512-41xUQ04qs6SW3r9bYoqdAZyFHTBbVnfnEzFvsPCCeeZSzQwlQToO+u0hrdgcudFR5VZWwTvl07EIH4mWqrlhsg==} + appium-uiautomator2-driver@7.0.0: + resolution: {integrity: sha512-ct+X87CVbKkXTEzl/LG4WXpw26U5jT50cFTCQ6NxnSbJi4JX5Trn1EMsSdO0OxAL3qg3rk9zL7j1JBnIzj+KBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} peerDependencies: appium: ^3.0.0-rc.2 - appium-uiautomator2-server@9.11.0: - resolution: {integrity: sha512-D5v67oJ75WGZ7eYffiWwLnRxqdFVSavIU6QPDxxl3mLvgkic4vksJ1i6xh+l98yyKpz+nSJbxu6oxbrP9IM0hw==} + appium-uiautomator2-server@9.11.1: + resolution: {integrity: sha512-MAlnHFhUdQ/gdpzXcJlK5chuMQLjhOqeoD1gPGmsr3raAElPHdKDYzSiaxqvDFu7XRYug6JfWmBsTSfdASy/RQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-webdriveragent@11.1.4: - resolution: {integrity: sha512-5NmVn2Qi7jezSXEHAOLt3E5qnp/9Mv9v1nzdbvjn8YN0O15pACEz3eqN1SBpjQ8A3pR8jBW7h4olJLoTs/+Y+A==} + appium-webdriveragent@11.1.6: + resolution: {integrity: sha512-7Ga9qqfZWtCXa+G50TFhpH8cMzA07yNtBDTZgfJehYFGzO4qx35sabzNGQ4z6GkxvuUvbHXvu8KTFlVNhgXR3w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-xcode@6.1.8: - resolution: {integrity: sha512-nk86u0wo4ZPCxQsiF/1PR/HewpB5NxSkNxUttRLiWEbMTA8FPqDlbUfrD/xaywMYUYkYIk+W9ufz3Gg0CKMBAA==} + appium-xcode@6.1.9: + resolution: {integrity: sha512-m7bQPXMUitycAvPNmNQ/UdoZJhtcH2zCjxXcvQYi4uZTHqexcjy76MpMrVFsESJ7Qd8+0U2vmnMNpfB/M/BupQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - appium-xcuitest-driver@10.19.1: - resolution: {integrity: sha512-cfFMsNnhcGmwFLhJZswQ2hgXdAWLs/AU+/NY2xoItqMftFN6jj+TjmIKzeGVFfQwi7d7nctP1iDPkvLJU2cR7A==} + appium-xcuitest-driver@10.24.1: + resolution: {integrity: sha512-f2Ml3LrQFOGmumUle8DJQwVHlIy1XFXK8H1HRozZfFV0YFG3KQypO3XtMNhWuPtp5q6Mja6cOhUiHLcrtQy5jw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} peerDependencies: appium: ^3.0.0-rc.2 @@ -872,8 +898,8 @@ packages: axios@1.13.3: resolution: {integrity: sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==} - axios@1.13.4: - resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} @@ -886,6 +912,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -937,6 +967,7 @@ packages: basic-ftp@5.1.0: resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} @@ -962,12 +993,13 @@ packages: resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} engines: {node: '>= 5.10.0'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1000,10 +1032,6 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1106,9 +1134,6 @@ packages: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1280,8 +1305,8 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -1383,27 +1408,27 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-plugin-perfectionist@5.4.0: - resolution: {integrity: sha512-XxpUMpeVaSJF5rpF6NHmhj3xavHZrflKcRbDssAUWrHUU/+l3l7PPYnVJ6IOpR2KjQ1Blucaeb0cFL3LIBis0A==} + eslint-plugin-perfectionist@5.6.0: + resolution: {integrity: sha512-pxrLrfRp5wl1Vol1fAEa/G5yTXxefTPJjz07qC7a8iWFXcOZNuWBItMQ2OtTzfQIvMq6bMyYcrzc3Wz++na55Q==} engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: - eslint: '>=8.45.0' + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.1: + resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@10.0.2: + resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -1411,9 +1436,9 @@ packages: jiti: optional: true - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.1.1: + resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} @@ -1662,16 +1687,12 @@ packages: resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} engines: {node: 20 || >=22} - glob@13.0.1: - resolution: {integrity: sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==} - engines: {node: 20 || >=22} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@17.3.0: - resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} engines: {node: '>=18'} globby@11.1.0: @@ -1760,10 +1781,6 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -1782,8 +1799,8 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} - io.appium.settings@7.0.18: - resolution: {integrity: sha512-1JJcSRtvTZGlX8NQG+2Zf/0qZ6EQSIioeiUfnhy6uUej8MlybAhXR6rNJw5TAvMr4oX06vNyHD/+YvFHWg0Hhw==} + io.appium.settings@7.0.20: + resolution: {integrity: sha512-s/IWqO8oWwoZYKsgUAaZWOjFm2WspLSTrtY0H7M/+DS1EHJi8qNW3NMdb0SIjwGfVB4OdmvIONWOivRV2hT/2w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} ip-address@10.1.0: @@ -1860,16 +1877,20 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - js2xmlparser2@0.2.0: resolution: {integrity: sha512-SzFGc1hQqzpDcalKmrM5gobSMGRSRg2lgaZrHGIfowrmd8+uaI+PWW62jcCGIqI+b4wdyYK0VKMhvVtJfkD0cg==} @@ -1957,9 +1978,6 @@ packages: lodash.isfinite@3.3.2: resolution: {integrity: sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.zip@4.2.0: resolution: {integrity: sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==} @@ -1996,6 +2014,10 @@ packages: resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} engines: {node: 20 || >=22} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} @@ -2065,23 +2087,20 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} - engines: {node: 20 || >=22} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} mitt@3.0.1: @@ -2132,8 +2151,8 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - node-addon-api@8.5.0: - resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + node-addon-api@8.6.0: + resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} engines: {node: ^18 || ^20 || >= 21} node-devicectl@1.1.4: @@ -2149,8 +2168,8 @@ packages: encoding: optional: true - node-simctl@8.1.5: - resolution: {integrity: sha512-8lQlne56cXGpPHjv49QXLQSOJuH+onlxHemlguSsutwbSdW+/ChC+xX932BEoG3qx62fpMPzRj3v2I1wVT4Ezw==} + node-simctl@8.1.6: + resolution: {integrity: sha512-SSwNzq4Tl575EaVFCIotDvDDV5XYR7676aN78lv/fhdxOQ+ZM6QZdIa/ZTXiDMc/Jd3wQ4L24E0d4Cqb6jy+Ew==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} normalize-package-data@8.0.0: @@ -2259,10 +2278,6 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - parse-color@1.0.0: resolution: {integrity: sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==} @@ -2299,9 +2314,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -2333,13 +2348,13 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - playwright-core@1.58.1: - resolution: {integrity: sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} hasBin: true - playwright@1.58.1: - resolution: {integrity: sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==} + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true @@ -2455,10 +2470,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2481,8 +2492,8 @@ packages: rgb2hex@0.2.5: resolution: {integrity: sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==} - rimraf@6.1.2: - resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} hasBin: true @@ -2528,6 +2539,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -2697,10 +2713,6 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - strip-outer@1.0.1: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} @@ -2726,12 +2738,12 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - teen_process@4.0.8: - resolution: {integrity: sha512-0DTX2KfgVOr6+8TVmheEdiJHZ/bPOPeJuX0yvv5VOX3x+OFteNkmWkI+hX6zTkzxjddrktsrXkacfS2Gom1YyA==} + teen_process@4.0.10: + resolution: {integrity: sha512-xEQ0UCeUoprhDDADFKaxv9nzE+PlDTw/mgG0aX7ccxg+EGx8bCEiX25qQ0JPSjSS66sQyXEPFQR3nV5ZxiOcmw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} - teen_process@4.0.9: - resolution: {integrity: sha512-AdH4nuHQTTiFEnib3wWnepnfa7Vz8QzOZ7EsLM8iz8pOlZmshjnODmWTt/8OA6v6A9gACURKc0OddGX28UoxFQ==} + teen_process@4.0.8: + resolution: {integrity: sha512-0DTX2KfgVOr6+8TVmheEdiJHZ/bPOPeJuX0yvv5VOX3x+OFteNkmWkI+hX6zTkzxjddrktsrXkacfS2Gom1YyA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} text-decoder@1.2.3: @@ -2820,11 +2832,11 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' typescript@5.9.3: @@ -2838,15 +2850,15 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} undici@6.23.0: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} - undici@7.20.0: - resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} unicorn-magic@0.3.0: @@ -2952,6 +2964,11 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + winston-transport@4.9.0: resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} engines: {node: '>= 12.0.0'} @@ -3240,50 +3257,38 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2)': dependencies: - eslint: 9.39.2 + eslint: 10.0.2 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.23.2': dependencies: - '@eslint/object-schema': 2.1.7 + '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': + '@eslint/config-helpers@0.5.2': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.0 - '@eslint/core@0.17.0': + '@eslint/core@1.1.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': - dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.2': {} + '@eslint/js@10.0.1(eslint@10.0.2)': + optionalDependencies: + eslint: 10.0.2 - '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@3.0.2': {} - '@eslint/plugin-kit@0.4.1': + '@eslint/plugin-kit@0.6.0': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.0 levn: 0.4.1 '@humanfs/core@0.19.1': {} @@ -3393,12 +3398,6 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3419,6 +3418,12 @@ snapshots: '@jsquash/png@3.1.1': {} + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3434,9 +3439,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.58.1': + '@playwright/test@1.58.2': dependencies: - playwright: 1.58.1 + playwright: 1.58.2 '@promptbook/utils@0.69.5': dependencies: @@ -3464,7 +3469,7 @@ snapshots: extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 - semver: 7.7.3 + semver: 7.7.4 tar-fs: 3.1.1 yargs: 17.7.2 transitivePeerDependencies: @@ -3538,12 +3543,14 @@ snapshots: '@tsconfig/node20@20.1.8': {} + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 25.2.0 + '@types/node': 25.3.3 '@types/gh-pages@6.1.0': {} @@ -3551,17 +3558,17 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.3.3 - '@types/lodash@4.17.23': {} + '@types/lodash@4.17.24': {} - '@types/node@20.19.31': + '@types/node@20.19.35': dependencies: undici-types: 6.21.0 - '@types/node@25.2.0': + '@types/node@25.3.3': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 '@types/normalize-package-data@2.4.4': {} @@ -3579,22 +3586,22 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.3.3 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.3.3 optional: true - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2 + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 10.0.2 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -3602,80 +3609,80 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.1(eslint@10.0.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - eslint: 9.39.2 + eslint: 10.0.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.54.0': + '@typescript-eslint/scope-manager@8.56.1': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2 + eslint: 10.0.2 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.54.0': {} + '@typescript-eslint/types@8.56.1': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.3 + minimatch: 10.2.4 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.1(eslint@10.0.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 10.0.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.54.0': + '@typescript-eslint/visitor-keys@8.56.1': dependencies: - '@typescript-eslint/types': 8.54.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 '@wdio/config@9.23.2': dependencies: @@ -3703,15 +3710,15 @@ snapshots: '@wdio/repl@9.16.2': dependencies: - '@types/node': 20.19.31 + '@types/node': 20.19.35 '@wdio/types@9.23.2': dependencies: - '@types/node': 20.19.31 + '@types/node': 20.19.35 - '@wdio/types@9.23.3': + '@wdio/types@9.24.0': dependencies: - '@types/node': 20.19.31 + '@types/node': 20.19.35 '@wdio/utils@9.23.2': dependencies: @@ -3751,9 +3758,9 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk@8.3.4: dependencies: @@ -3761,13 +3768,15 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + agent-base@7.1.4: {} ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -3781,18 +3790,24 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - allure-commandline@2.36.0: {} + allure-commandline@2.37.0: {} + + allure-js-commons@3.4.5(allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2)): + dependencies: + md5: 2.3.0 + optionalDependencies: + allure-playwright: 3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2) - allure-js-commons@3.4.5(allure-playwright@3.4.5(@playwright/test@1.58.1)): + allure-js-commons@3.5.0(allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2)): dependencies: md5: 2.3.0 optionalDependencies: - allure-playwright: 3.4.5(@playwright/test@1.58.1) + allure-playwright: 3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2) - allure-playwright@3.4.5(@playwright/test@1.58.1): + allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2): dependencies: - '@playwright/test': 1.58.1 - allure-js-commons: 3.4.5(allure-playwright@3.4.5(@playwright/test@1.58.1)) + '@playwright/test': 1.58.2 + allure-js-commons: 3.4.5(allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2)) ansi-regex@5.0.1: {} @@ -3804,7 +3819,7 @@ snapshots: ansi-styles@6.2.3: {} - appium-adb@14.2.0: + appium-adb@14.3.0: dependencies: '@appium/support': 7.0.5 async-lock: 1.4.1 @@ -3812,32 +3827,32 @@ snapshots: bluebird: 3.7.2 ini: 6.0.0 lodash: 4.17.23 - lru-cache: 11.2.5 - semver: 7.7.3 - teen_process: 4.0.9 + lru-cache: 11.2.6 + semver: 7.7.4 + teen_process: 4.0.10 transitivePeerDependencies: - bare-abort-controller - debug - react-native-b4a - appium-android-driver@12.6.6(appium@3.2.0): + appium-android-driver@13.0.0(appium@3.2.0): dependencies: '@appium/support': 7.0.5 '@colors/colors': 1.6.0 appium: 3.2.0 - appium-adb: 14.2.0 - appium-chromedriver: 8.2.6 + appium-adb: 14.3.0 + appium-chromedriver: 8.2.14 asyncbox: 6.1.0 - axios: 1.13.4 + axios: 1.13.6 bluebird: 3.7.2 - io.appium.settings: 7.0.18 + io.appium.settings: 7.0.20 lodash: 4.17.23 - lru-cache: 11.2.5 + lru-cache: 11.2.6 moment: 2.30.1 moment-timezone: 0.6.0 portscanner: 2.2.0 - semver: 7.7.3 - teen_process: 4.0.9 + semver: 7.7.4 + teen_process: 4.0.10 ws: 8.19.0 transitivePeerDependencies: - bare-abort-controller @@ -3847,19 +3862,19 @@ snapshots: - supports-color - utf-8-validate - appium-chromedriver@8.2.6: + appium-chromedriver@8.2.14: dependencies: '@appium/base-driver': 10.2.0 '@appium/support': 7.0.5 '@xmldom/xmldom': 0.8.11 - appium-adb: 14.2.0 + appium-adb: 14.3.0 asyncbox: 6.1.0 - axios: 1.13.4 + axios: 1.13.6 bluebird: 3.7.2 compare-versions: 6.1.1 lodash: 4.17.23 - semver: 7.7.3 - teen_process: 4.0.9 + semver: 7.7.4 + teen_process: 4.0.10 xpath: 0.0.34 transitivePeerDependencies: - bare-abort-controller @@ -3867,42 +3882,42 @@ snapshots: - react-native-b4a - supports-color - appium-idb@2.0.8: + appium-idb@2.0.9: dependencies: '@appium/support': 7.0.5 asyncbox: 6.1.0 bluebird: 3.7.2 lodash: 4.17.23 - teen_process: 4.0.9 + teen_process: 4.0.10 transitivePeerDependencies: - bare-abort-controller - debug - react-native-b4a - appium-ios-device@3.1.9: + appium-ios-device@3.1.10: dependencies: '@appium/support': 7.0.5 asyncbox: 6.1.0 - axios: 1.13.4 + axios: 1.13.6 bluebird: 3.7.2 bplist-creator: 0.1.1 bplist-parser: 0.3.2 lodash: 4.17.23 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - bare-abort-controller - debug - react-native-b4a - appium-ios-remotexpc@0.28.1: + appium-ios-remotexpc@0.30.0: dependencies: '@appium/strongbox': 1.0.1 '@appium/support': 7.0.5 - '@types/node': 25.2.0 + '@types/node': 25.3.3 '@xmldom/xmldom': 0.9.8 appium-ios-tuntap: 0.1.3 - axios: 1.13.4 - minimatch: 10.1.2 + axios: 1.13.6 + minimatch: 10.2.4 npm-run-all2: 8.0.4 transitivePeerDependencies: - bare-abort-controller @@ -3910,18 +3925,18 @@ snapshots: - react-native-b4a optional: true - appium-ios-simulator@8.0.11: + appium-ios-simulator@8.0.12: dependencies: '@appium/support': 7.0.5 '@xmldom/xmldom': 0.8.11 - appium-xcode: 6.1.8 + appium-xcode: 6.1.9 async-lock: 1.4.1 asyncbox: 6.1.0 bluebird: 3.7.2 lodash: 4.17.23 - node-simctl: 8.1.5 - semver: 7.7.3 - teen_process: 4.0.9 + node-simctl: 8.1.6 + semver: 7.7.4 + teen_process: 4.0.10 transitivePeerDependencies: - bare-abort-controller - debug @@ -3930,7 +3945,7 @@ snapshots: appium-ios-tuntap@0.1.3: dependencies: '@appium/support': 7.0.5 - node-addon-api: 8.5.0 + node-addon-api: 8.6.0 typescript: 5.9.3 transitivePeerDependencies: - bare-abort-controller @@ -3938,37 +3953,37 @@ snapshots: - react-native-b4a optional: true - appium-remote-debugger@15.3.2: + appium-remote-debugger@15.5.0: dependencies: '@appium/base-driver': 10.2.0 '@appium/support': 7.0.5 - appium-ios-device: 3.1.9 + appium-ios-device: 3.1.10 async-lock: 1.4.1 asyncbox: 6.1.0 bluebird: 3.7.2 - glob: 13.0.1 + glob: 13.0.6 lodash: 4.17.23 - teen_process: 4.0.9 + teen_process: 4.0.10 transitivePeerDependencies: - bare-abort-controller - debug - react-native-b4a - supports-color - appium-uiautomator2-driver@6.8.0(patch_hash=8226be3d8d63cd3e3963f8450fc068a726a9a71eddecad1a612f92bdbd92d121)(appium@3.2.0): + appium-uiautomator2-driver@7.0.0(patch_hash=8226be3d8d63cd3e3963f8450fc068a726a9a71eddecad1a612f92bdbd92d121)(appium@3.2.0): dependencies: appium: 3.2.0 - appium-adb: 14.2.0 - appium-android-driver: 12.6.6(appium@3.2.0) - appium-uiautomator2-server: 9.11.0 + appium-adb: 14.3.0 + appium-android-driver: 13.0.0(appium@3.2.0) + appium-uiautomator2-server: 9.11.1 asyncbox: 6.1.0 - axios: 1.13.4 + axios: 1.13.6 bluebird: 3.7.2 css-selector-parser: 3.3.0 - io.appium.settings: 7.0.18 + io.appium.settings: 7.0.20 lodash: 4.17.23 portscanner: 2.2.0 - teen_process: 4.0.9 + teen_process: 4.0.10 transitivePeerDependencies: - bare-abort-controller - bufferutil @@ -3977,52 +3992,52 @@ snapshots: - supports-color - utf-8-validate - appium-uiautomator2-server@9.11.0: {} + appium-uiautomator2-server@9.11.1: {} - appium-webdriveragent@11.1.4: + appium-webdriveragent@11.1.6: dependencies: '@appium/base-driver': 10.2.0 '@appium/strongbox': 1.0.1 '@appium/support': 7.0.5 - appium-ios-device: 3.1.9 - appium-ios-simulator: 8.0.11 + appium-ios-device: 3.1.10 + appium-ios-simulator: 8.0.12 async-lock: 1.4.1 asyncbox: 6.1.0 - axios: 1.13.4 + axios: 1.13.6 bluebird: 3.7.2 lodash: 4.17.23 - teen_process: 4.0.9 + teen_process: 4.0.10 transitivePeerDependencies: - bare-abort-controller - debug - react-native-b4a - supports-color - appium-xcode@6.1.8: + appium-xcode@6.1.9: dependencies: '@appium/support': 7.0.5 asyncbox: 6.1.0 bluebird: 3.7.2 lodash: 4.17.23 plist: 3.1.0 - semver: 7.7.3 - teen_process: 4.0.9 + semver: 7.7.4 + teen_process: 4.0.10 transitivePeerDependencies: - bare-abort-controller - debug - react-native-b4a - appium-xcuitest-driver@10.19.1(appium@3.2.0): + appium-xcuitest-driver@10.24.1(appium@3.2.0): dependencies: '@appium/strongbox': 1.0.1 '@colors/colors': 1.6.0 appium: 3.2.0 - appium-idb: 2.0.8 - appium-ios-device: 3.1.9 - appium-ios-simulator: 8.0.11 - appium-remote-debugger: 15.3.2 - appium-webdriveragent: 11.1.4 - appium-xcode: 6.1.8 + appium-idb: 2.0.9 + appium-ios-device: 3.1.10 + appium-ios-simulator: 8.0.12 + appium-remote-debugger: 15.5.0 + appium-webdriveragent: 11.1.6 + appium-xcode: 6.1.9 async-lock: 1.4.1 asyncbox: 6.1.0 bluebird: 3.7.2 @@ -4030,18 +4045,18 @@ snapshots: css-selector-parser: 3.3.0 js2xmlparser2: 0.2.0 lodash: 4.17.23 - lru-cache: 11.2.5 + lru-cache: 11.2.6 moment: 2.30.1 moment-timezone: 0.6.0 node-devicectl: 1.1.4 - node-simctl: 8.1.5 + node-simctl: 8.1.6 portscanner: 2.2.0 - semver: 7.7.3 - teen_process: 4.0.9 + semver: 7.7.4 + teen_process: 4.0.10 winston: 3.19.0 ws: 8.19.0 optionalDependencies: - appium-ios-remotexpc: 0.28.1 + appium-ios-remotexpc: 0.30.0 transitivePeerDependencies: - bare-abort-controller - bufferutil @@ -4145,7 +4160,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.13.4: + axios@1.13.6: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 @@ -4157,6 +4172,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.2: {} bare-fs@4.5.3: @@ -4238,14 +4255,13 @@ snapshots: dependencies: big-integer: 1.6.52 - brace-expansion@1.1.12: + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@5.0.4: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -4279,8 +4295,6 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - callsites@3.1.0: {} - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4310,7 +4324,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.20.0 + undici: 7.22.0 whatwg-mimetype: 4.0.0 chromium-bidi@0.5.8(devtools-protocol@0.0.1232444): @@ -4389,8 +4403,6 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 - concat-map@0.0.1: {} - consola@3.4.2: {} console-control-strings@1.1.0: {} @@ -4524,7 +4536,7 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - dotenv@17.2.3: {} + dotenv@17.3.1: {} dunder-proto@1.0.1: dependencies: @@ -4550,7 +4562,7 @@ snapshots: fast-xml-parser: 5.3.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - which: 6.0.0 + which: 6.0.1 transitivePeerDependencies: - supports-color @@ -4618,46 +4630,45 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-plugin-perfectionist@5.4.0(eslint@9.39.2)(typescript@5.9.3): + eslint-plugin-perfectionist@5.6.0(eslint@10.0.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + eslint: 10.0.2 natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color - typescript - eslint-scope@8.4.0: + eslint-scope@9.1.1: dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} - eslint@9.39.2: + eslint@10.0.2: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 - '@eslint/plugin-kit': 0.4.1 + '@eslint/config-array': 0.23.2 + '@eslint/config-helpers': 0.5.2 + '@eslint/core': 1.1.0 + '@eslint/plugin-kit': 0.6.0 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 - chalk: 4.1.2 + ajv: 6.14.0 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 + eslint-scope: 9.1.1 + eslint-visitor-keys: 5.0.1 + espree: 11.1.1 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -4668,18 +4679,17 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: - supports-color - espree@10.4.0: + espree@11.1.1: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 esprima@4.0.1: {} @@ -4956,26 +4966,24 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 + minimatch: 9.0.9 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 glob@13.0.0: dependencies: - minimatch: 10.1.2 - minipass: 7.1.2 - path-scurry: 2.0.1 + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 - glob@13.0.1: + glob@13.0.6: dependencies: - minimatch: 10.1.2 - minipass: 7.1.2 - path-scurry: 2.0.1 - - globals@14.0.0: {} + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 - globals@17.3.0: {} + globals@17.4.0: {} globby@11.1.0: dependencies: @@ -5009,7 +5017,7 @@ snapshots: hosted-git-info@9.0.2: dependencies: - lru-cache: 11.2.5 + lru-cache: 11.2.6 hpack.js@2.1.6: dependencies: @@ -5071,11 +5079,6 @@ snapshots: immediate@3.0.6: {} - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: {} @@ -5086,14 +5089,14 @@ snapshots: ini@6.0.0: {} - io.appium.settings@7.0.18: + io.appium.settings@7.0.20: dependencies: '@appium/logger': 2.0.4 asyncbox: 6.1.0 bluebird: 3.7.2 lodash: 4.17.23 - semver: 7.7.3 - teen_process: 4.0.9 + semver: 7.7.4 + teen_process: 4.0.10 ip-address@10.1.0: {} @@ -5139,6 +5142,11 @@ snapshots: isexe@3.1.1: {} + isexe@3.1.5: + optional: true + + isexe@4.0.0: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -5147,10 +5155,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - js2xmlparser2@0.2.0: {} jsftp@2.1.3(supports-color@10.2.2): @@ -5243,8 +5247,6 @@ snapshots: lodash.isfinite@3.3.2: {} - lodash.merge@4.6.2: {} - lodash.zip@4.2.0: {} lodash@4.17.23: {} @@ -5284,6 +5286,8 @@ snapshots: lru-cache@11.2.5: {} + lru-cache@11.2.6: {} + lru-cache@7.18.3: {} make-dir@3.1.0: @@ -5342,23 +5346,19 @@ snapshots: minimalistic-assert@1.0.1: optional: true - minimatch@10.1.2: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@3.1.2: + minimatch@10.2.4: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 5.0.4 - minimatch@5.1.6: + minimatch@5.1.9: dependencies: brace-expansion: 2.0.2 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: brace-expansion: 2.0.2 - minipass@7.1.2: {} + minipass@7.1.3: {} mitt@3.0.1: {} @@ -5399,31 +5399,31 @@ snapshots: netmask@2.0.2: {} - node-addon-api@8.5.0: + node-addon-api@8.6.0: optional: true node-devicectl@1.1.4: dependencies: '@appium/logger': 2.0.4 lodash: 4.17.23 - teen_process: 4.0.9 + teen_process: 4.0.10 node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 optional: true - node-simctl@8.1.5: + node-simctl@8.1.6: dependencies: '@appium/logger': 2.0.4 asyncbox: 6.1.0 bluebird: 3.7.2 lodash: 4.17.23 - rimraf: 6.1.2 - semver: 7.7.3 - teen_process: 4.0.9 + rimraf: 6.1.3 + semver: 7.7.4 + teen_process: 4.0.10 uuid: 13.0.0 - which: 6.0.0 + which: 6.0.1 normalize-package-data@8.0.0: dependencies: @@ -5554,10 +5554,6 @@ snapshots: pako@1.0.11: {} - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - parse-color@1.0.0: dependencies: color-convert: 0.5.3 @@ -5592,12 +5588,12 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 - path-scurry@2.0.1: + path-scurry@2.0.2: dependencies: - lru-cache: 11.2.5 - minipass: 7.1.2 + lru-cache: 11.2.6 + minipass: 7.1.3 path-to-regexp@8.3.0: {} @@ -5618,11 +5614,11 @@ snapshots: dependencies: find-up: 4.1.0 - playwright-core@1.58.1: {} + playwright-core@1.58.2: {} - playwright@1.58.1: + playwright@1.58.2: dependencies: - playwright-core: 1.58.1 + playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 @@ -5659,7 +5655,7 @@ snapshots: proxy-agent@6.3.1: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.3.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -5774,14 +5770,12 @@ snapshots: readdir-glob@1.1.3: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.9 require-directory@2.1.1: {} require-from-string@2.0.2: {} - resolve-from@4.0.0: {} - resolve-from@5.0.0: {} resq@1.11.0: @@ -5799,9 +5793,9 @@ snapshots: rgb2hex@0.2.5: {} - rimraf@6.1.2: + rimraf@6.1.3: dependencies: - glob: 13.0.1 + glob: 13.0.6 package-json-from-dist: 1.0.1 router@2.2.0: @@ -5843,6 +5837,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -6090,8 +6086,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-json-comments@3.1.1: {} - strip-outer@1.0.1: dependencies: escape-string-regexp: 1.0.5 @@ -6127,12 +6121,12 @@ snapshots: - bare-abort-controller - react-native-b4a - teen_process@4.0.8: + teen_process@4.0.10: dependencies: lodash: 4.17.23 shell-quote: 1.8.3 - teen_process@4.0.9: + teen_process@4.0.8: dependencies: lodash: 4.17.23 shell-quote: 1.8.3 @@ -6175,14 +6169,14 @@ snapshots: dependencies: typescript: 5.9.3 - ts-node@10.9.2(@types/node@25.2.0)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.3.3)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.2.0 + '@types/node': 25.3.3 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -6217,13 +6211,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.54.0(eslint@9.39.2)(typescript@5.9.3): + typescript-eslint@8.56.1(eslint@10.0.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + eslint: 10.0.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6238,11 +6232,11 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: {} + undici-types@7.18.2: {} undici@6.23.0: {} - undici@7.20.0: {} + undici@7.22.0: {} unicorn-magic@0.3.0: {} @@ -6297,7 +6291,7 @@ snapshots: webdriver@9.23.2: dependencies: - '@types/node': 20.19.31 + '@types/node': 20.19.35 '@types/ws': 8.18.1 '@wdio/config': 9.23.2 '@wdio/logger': 9.18.0 @@ -6318,7 +6312,7 @@ snapshots: webdriverio@9.23.2(puppeteer-core@21.11.0): dependencies: - '@types/node': 20.19.31 + '@types/node': 20.19.35 '@types/sinonjs__fake-timers': 8.1.5 '@wdio/config': 9.23.2 '@wdio/logger': 9.18.0 @@ -6374,13 +6368,17 @@ snapshots: which@5.0.0: dependencies: - isexe: 3.1.1 + isexe: 3.1.5 optional: true which@6.0.0: dependencies: isexe: 3.1.1 + which@6.0.1: + dependencies: + isexe: 4.0.0 + winston-transport@4.9.0: dependencies: logform: 2.7.0 diff --git a/run/constants/community.ts b/run/constants/community.ts index 47d38483d..54a2b7d50 100644 --- a/run/constants/community.ts +++ b/run/constants/community.ts @@ -1,2 +1,33 @@ -export const testCommunityLink = `https://test-chat.session.codes/testing-all-the-things?public_key=1d7e7f92b1ed3643855c98ecac02fc7274033a3467653f047d6e433540c03f17`; -export const testCommunityName = `Testing All The Things!`; +type CommunityConfig = { + link: string; + name: string; + roomName?: string; +}; + +export const communities: Record = { + testCommunity: { + link: 'https://test-chat.session.codes/testing-all-the-things?public_key=1d7e7f92b1ed3643855c98ecac02fc7274033a3467653f047d6e433540c03f17', + name: 'Testing All The Things!', + roomName: 'testing-all-the-things', + }, + testOmg: { + link: 'https://test-chat.session.codes/omg?public_key=1d7e7f92b1ed3643855c98ecac02fc7274033a3467653f047d6e433540c03f17', + name: 'omg', + }, + lokinetUpdates: { + link: 'https://open.getsession.org/lokinet-updates?public_key=a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238', + name: 'Lokinet Updates', + }, + sessionNetworkUpdates: { + link: 'https://open.getsession.org/oxen-updates?public_key=a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238', + name: 'Session Network Updates', + }, + session: { + link: 'https://open.getsession.org/session?public_key=a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238', + name: 'Session', + }, + sessionDev: { + link: 'https://open.getsession.org/session-dev?public_key=a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238', + name: 'Session Developers Chat', + }, +}; diff --git a/run/constants/index.ts b/run/constants/index.ts index 1e9ba83a0..504d6b910 100644 --- a/run/constants/index.ts +++ b/run/constants/index.ts @@ -22,6 +22,8 @@ export const testLink = `https://getsession.org/`; export const DEVNET_URL = 'http://sesh-net.local:1280'; +export const PRO_BACKEND_URL = 'https://pro-backend-dev.getsession.org'; + export const ONS_MAPPINGS = { TESTQA: { ons: 'testqa', diff --git a/run/constants/testfiles.ts b/run/constants/testfiles.ts index 8f66e8cd7..ba5d230b6 100644 --- a/run/constants/testfiles.ts +++ b/run/constants/testfiles.ts @@ -3,3 +3,4 @@ export const testFile = 'test_file.pdf'; export const testVideo = 'test_video.mp4'; export const testVideoThumbnail = 'test_video_thumbnail.png'; export const profilePicture = 'profile_picture.jpg'; +export const animatedProfilePicture = 'animated_profile_picture.gif'; diff --git a/run/localizer/englishStrippedStr.ts b/run/localizer/englishStrippedStr.ts deleted file mode 100644 index e90253ada..000000000 --- a/run/localizer/englishStrippedStr.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { LocalizedStringBuilder, MergedLocalizerTokens } from "./lib"; - -export function englishStrippedStr(token: T) { - const builder = new LocalizedStringBuilder(token).stripIt().forceEnglish(); - return builder; -} diff --git a/run/localizer/lib b/run/localizer/lib index c0714a891..cb321331c 160000 --- a/run/localizer/lib +++ b/run/localizer/lib @@ -1 +1 @@ -Subproject commit c0714a8916a38672584323e6084e8cedc36d7243 +Subproject commit cb321331ce4f258da82aff0002387c1812175258 diff --git a/run/screenshots/android/conversation_alice.png b/run/screenshots/android/conversation_alice.png index 0ee18c700..52a0f131b 100644 --- a/run/screenshots/android/conversation_alice.png +++ b/run/screenshots/android/conversation_alice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:075707255598a95252744a10f63b009aab26f51261fd1886b30bfe8ec37e8873 -size 88351 +oid sha256:bea9e1f602d30a64762d769bb2dceef500c936393390b21ad6125772c9637404 +size 86966 diff --git a/run/screenshots/android/conversation_bob.png b/run/screenshots/android/conversation_bob.png index cb618ed78..e7e816180 100644 --- a/run/screenshots/android/conversation_bob.png +++ b/run/screenshots/android/conversation_bob.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f4197c0c9eea91fcbc5bdf200a18b6b3701a2d5e78ed6b8f22aebaa696fb43b -size 93516 +oid sha256:91ab543ac7f08b23d4073ac4b5c42f7f930eb6792518b3b433b22ea871ad7ee8 +size 94022 diff --git a/run/screenshots/android/cta_donate.png b/run/screenshots/android/cta_donate.png index 4902856eb..02f166a55 100644 --- a/run/screenshots/android/cta_donate.png +++ b/run/screenshots/android/cta_donate.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b5561d790a9d73b1afbf9c61262ce89b5e1cba85cecb8f445cd9634876943e4 -size 1146466 +oid sha256:9ad60a2e05dddf51445ebd7ecee2bc55397b77ef4a5a9630254146eb2b452d58 +size 731877 diff --git a/run/screenshots/android/cta_pro_activated.png b/run/screenshots/android/cta_pro_activated.png new file mode 100644 index 000000000..c47bd6839 --- /dev/null +++ b/run/screenshots/android/cta_pro_activated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35fbdb49f97531ef2cb25b3027846d03ec4dc3590a02be30057770da80db8a27 +size 428879 diff --git a/run/screenshots/android/landingpage_new_account.png b/run/screenshots/android/landingpage_new_account.png index 5a5de8b36..9a40983b4 100644 --- a/run/screenshots/android/landingpage_new_account.png +++ b/run/screenshots/android/landingpage_new_account.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aec8609e7d0013725d3be235b552a9399e94e2143f3edbeda4c7172c1a29f0ff -size 103496 +oid sha256:f0dcb7982a90edb0ea6b5de654429c670073dff99be415d9499d33bcacfc6868 +size 101390 diff --git a/run/screenshots/android/settings.png b/run/screenshots/android/settings.png index e4bbcc15f..2c2885f24 100644 --- a/run/screenshots/android/settings.png +++ b/run/screenshots/android/settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e290aecdb8ba4e13606c65c3cf549b381c20abcdb3dbbfb882c7bceb328b41d0 -size 134845 +oid sha256:acbb60f750ef4b52e222cebddda610eae9ae143e38617a4f318286a6f7f3956d +size 139070 diff --git a/run/screenshots/android/settings_conversations.png b/run/screenshots/android/settings_conversations.png index 983c94eab..45534e20b 100644 --- a/run/screenshots/android/settings_conversations.png +++ b/run/screenshots/android/settings_conversations.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:183f7aac856c61eb5f46638fc25dbb1755215712752b50f721698f3301a232d8 -size 156289 +oid sha256:c92c63b479821ffda45eba99884e4bd2cfa6fbe77e8d188508c43d9de35dfbfa +size 151360 diff --git a/run/screenshots/android/settings_notifications.png b/run/screenshots/android/settings_notifications.png index 0cdc813c4..01c7e0578 100644 --- a/run/screenshots/android/settings_notifications.png +++ b/run/screenshots/android/settings_notifications.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:846ea6ce60f9014e0fa36823e176da62f6f27fc63026ea1291f66b69cebf0730 -size 142963 +oid sha256:a7dd7ac62c92ad833db6e916fb7fef1f2abb7e2a08905d8c909accaa47949b69 +size 189299 diff --git a/run/screenshots/android/settings_privacy.png b/run/screenshots/android/settings_privacy.png index 90b58f6b9..7da23a6d8 100644 --- a/run/screenshots/android/settings_privacy.png +++ b/run/screenshots/android/settings_privacy.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e37c1174f4651cf2adb148517fe86ae36a6006bc50b71c9f2a1a329ac38f2940 -size 197778 +oid sha256:a464b633f442efc4a4515e9e6b52222de79b5a1ecc08c37966ab1df8eff1017d +size 209443 diff --git a/run/screenshots/android/upm_home.png b/run/screenshots/android/upm_home.png index 05138c368..579ddc6d0 100644 --- a/run/screenshots/android/upm_home.png +++ b/run/screenshots/android/upm_home.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a13186f1e5cec7e4d9507b81b48dada7237609c570713490582269f039fc1b09 -size 103795 +oid sha256:8be4b42a49a946a48c0f1b9885ab24438d3bc0263d994ef0f937fb727f9d9fa2 +size 104018 diff --git a/run/screenshots/ios/cta_donate.png b/run/screenshots/ios/cta_donate.png index 3f5c823d3..5d22106be 100644 --- a/run/screenshots/ios/cta_donate.png +++ b/run/screenshots/ios/cta_donate.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b37a27b043bdcb46cb529411b4e28ac2f5c064fad123888278d67a7599c7d719 -size 2262335 +oid sha256:d1430c35b3084b712251e5c1e5a208e2e861fde023d4356fd330e30ac7a567f8 +size 1694886 diff --git a/run/screenshots/ios/cta_pro_activated.png b/run/screenshots/ios/cta_pro_activated.png new file mode 100644 index 000000000..7b21ed551 --- /dev/null +++ b/run/screenshots/ios/cta_pro_activated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:315676b8601144108703f484f350fde62b081edb3df956b787dab4039c60aeb2 +size 1420623 diff --git a/run/screenshots/ios/settings.png b/run/screenshots/ios/settings.png index bc61e279d..6617e2b8a 100644 --- a/run/screenshots/ios/settings.png +++ b/run/screenshots/ios/settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75f75aae9c68a9408622dac844f0a2d98541c8fa938156d85833187ea36b1448 -size 199765 +oid sha256:6de0b55dd8591b86184b0595db0d0936227201de59e1678a590e9378de24d58b +size 194857 diff --git a/run/test/locators/conversation.ts b/run/test/locators/conversation.ts index 7efc47b7d..f132ce34d 100644 --- a/run/test/locators/conversation.ts +++ b/run/test/locators/conversation.ts @@ -1,11 +1,20 @@ import type { DeviceWrapper } from '../../types/DeviceWrapper'; -import { testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { tStripped } from '../../localizer/lib'; import { StrategyExtractionObj } from '../../types/testing'; import { getAppDisplayName } from '../utils/devnet'; import { LocatorsInterface } from './index'; +export class AcceptMessageRequestButton extends LocatorsInterface { + public build() { + return { + strategy: 'accessibility id', + selector: 'Accept message request', + } as const; + } +} + export class AttachmentsButton extends LocatorsInterface { public build() { return { @@ -14,7 +23,6 @@ export class AttachmentsButton extends LocatorsInterface { } as const; } } - export class BlockedBanner extends LocatorsInterface { public build() { switch (this.platform) { @@ -56,13 +64,13 @@ export class CommunityInvitation extends LocatorsInterface { return { strategy: 'id', selector: 'network.loki.messenger:id/openGroupTitleTextView', - text: testCommunityName, + text: communities.testCommunity.name, } as const; case 'ios': return { strategy: 'accessibility id', selector: 'Community invitation', - text: testCommunityName, + text: communities.testCommunity.name, } as const; } } @@ -149,6 +157,7 @@ export class ConversationSettings extends LocatorsInterface { } } } + export class DeleteContactConfirmButton extends LocatorsInterface { public build() { switch (this.platform) { @@ -165,7 +174,6 @@ export class DeleteContactConfirmButton extends LocatorsInterface { } } } - export class DeleteContactMenuItem extends LocatorsInterface { public build() { switch (this.platform) { @@ -182,6 +190,7 @@ export class DeleteContactMenuItem extends LocatorsInterface { } } } + export class DeleteConversationMenuItem extends LocatorsInterface { public build() { switch (this.platform) { @@ -198,7 +207,6 @@ export class DeleteConversationMenuItem extends LocatorsInterface { } } } - export class DeleteConversationModalConfirm extends LocatorsInterface { public build() { switch (this.platform) { @@ -215,6 +223,7 @@ export class DeleteConversationModalConfirm extends LocatorsInterface { } } } + export class DeletedMessage extends LocatorsInterface { public build() { return { @@ -223,7 +232,6 @@ export class DeletedMessage extends LocatorsInterface { } as const; } } - export class DocumentMessage extends LocatorsInterface { public build() { switch (this.platform) { @@ -386,6 +394,7 @@ export class HideNoteToSelfConfirmButton extends LocatorsInterface { } } } + export class HideNoteToSelfMenuOption extends LocatorsInterface { public build() { switch (this.platform) { @@ -402,7 +411,6 @@ export class HideNoteToSelfMenuOption extends LocatorsInterface { } } } - export class ImagesFolderButton extends LocatorsInterface { public build() { return { @@ -412,6 +420,60 @@ export class ImagesFolderButton extends LocatorsInterface { } } +export class LongPressBanAndDelete extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'network.loki.messenger:id/context_menu_item_title', + text: tStripped('banDeleteAll'), + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Ban and Delete All', + } as const; + } + } +} + +export class LongPressBanUser extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'network.loki.messenger:id/context_menu_item_title', + text: tStripped('banUser'), + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Ban User', + } as const; + } + } +} + +export class LongPressUnBan extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'network.loki.messenger:id/context_menu_item_title', + text: tStripped('banUnbanUser'), + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Unban User', + } as const; + } + } +} + export class MediaMessage extends LocatorsInterface { public build() { switch (this.platform) { diff --git a/run/test/locators/global.ts b/run/test/locators/global.ts index 481e69211..19fabccc0 100644 --- a/run/test/locators/global.ts +++ b/run/test/locators/global.ts @@ -100,8 +100,6 @@ export class CopyURLButton extends LocatorsInterface { } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA -// See SES-4930 export class CTABody extends LocatorsInterface { public build() { switch (this.platform) { @@ -112,15 +110,13 @@ export class CTABody extends LocatorsInterface { } as const; case 'ios': return { - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Powerful forces are trying to')]`, + strategy: 'accessibility id', + selector: 'cta-body', } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA -// See SES-4930 export class CTAButtonNegative extends LocatorsInterface { public build() { switch (this.platform) { @@ -133,14 +129,12 @@ export class CTAButtonNegative extends LocatorsInterface { case 'ios': return { strategy: 'accessibility id', - selector: 'Maybe Later', + selector: 'cta-button-negative', } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA -// See SES-4930 export class CTAButtonPositive extends LocatorsInterface { public build() { switch (this.platform) { @@ -153,14 +147,12 @@ export class CTAButtonPositive extends LocatorsInterface { case 'ios': return { strategy: 'accessibility id', - selector: 'Donate', + selector: 'cta-button-positive', } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is not available -// See SES-4930 export class CTAFeature extends LocatorsInterface { private index: number; @@ -173,17 +165,19 @@ export class CTAFeature extends LocatorsInterface { switch (this.platform) { case 'android': return { - strategy: 'id', - selector: `cta-feature-${this.index}`, + strategy: '-android uiautomator', + selector: `new UiSelector().resourceId("cta-feature-${this.index}").childSelector(new UiSelector().className("android.widget.TextView"))`, } as const; case 'ios': - throw new Error('CTAFeature locator is not available on iOS'); + // iOS feature indexing starts at 1, Android at 0 + return { + strategy: 'accessibility id', + selector: `cta-feature-${this.index + 1}`, + } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA -// See SES-4930 export class CTAHeading extends LocatorsInterface { public build() { switch (this.platform) { @@ -194,8 +188,8 @@ export class CTAHeading extends LocatorsInterface { } as const; case 'ios': return { - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Session Needs')]`, + strategy: 'accessibility id', + selector: 'cta-heading', } as const; } } diff --git a/run/test/locators/groups.ts b/run/test/locators/groups.ts index bc3570c74..1802fdfcf 100644 --- a/run/test/locators/groups.ts +++ b/run/test/locators/groups.ts @@ -6,6 +6,20 @@ import { DeviceWrapper } from '../../types/DeviceWrapper'; import { StrategyExtractionObj } from '../../types/testing'; import { GROUPNAME } from '../../types/testing'; +export class ConfirmPromotionModalButton extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'Confirm', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } + } +} + export class ConfirmRemovalButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -62,13 +76,14 @@ export class DeleteGroupMenuItem extends LocatorsInterface { switch (this.platform) { case 'android': return { - strategy: 'id', - selector: 'delete-group-menu-option', + strategy: '-android uiautomator', + selector: + 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("delete-group-menu-option"))', } as const; case 'ios': return { strategy: 'accessibility id', - selector: 'Leave group', // yep this is leave even for the delete option + selector: 'Delete group', } as const; } } @@ -152,7 +167,6 @@ export class GroupMember extends LocatorsInterface { } } } - export class GroupNameInput extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -185,6 +199,19 @@ export class InviteContactConfirm extends LocatorsInterface { } } } +export class InviteContactSendInviteButton extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'Send Invite', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } + } +} export class LatestReleaseBanner extends LocatorsInterface { public build() { switch (this.platform) { @@ -205,13 +232,32 @@ export class LatestReleaseBanner extends LocatorsInterface { } } } +export class LeaveGroupCancel extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: '-android uiautomator', + selector: + 'new UiSelector().resourceId("leave-group-cancel-button").childSelector(new UiSelector().className("android.widget.TextView"))', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Cancel', + }; + } + } +} + export class LeaveGroupConfirm extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { case 'android': return { - strategy: 'id', - selector: 'leave-group-confirm-button', + strategy: '-android uiautomator', + selector: + 'new UiSelector().resourceId("leave-group-confirm-button").childSelector(new UiSelector().className("android.widget.TextView"))', } as const; case 'ios': return { @@ -221,6 +267,7 @@ export class LeaveGroupConfirm extends LocatorsInterface { } } } + export class LeaveGroupMenuItem extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -237,6 +284,21 @@ export class LeaveGroupMenuItem extends LocatorsInterface { } } } + +export class ManageAdminsMenuItem extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'manage-admins-menu-option', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } + } +} + export class ManageMembersMenuItem extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -273,39 +335,43 @@ export class MemberStatus extends LocatorsInterface { } } -export class RecreateGroupBannerAdmin extends LocatorsInterface { +export class PromoteMemberFooterButton extends LocatorsInterface { public build(): StrategyExtractionObj { - return { - strategy: 'accessibility id', - selector: 'Legacy group banner', - text: tStripped('legacyGroupAfterDeprecationAdmin'), - } as const; + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'qa-collapsing-footer-action_promote', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } } } -export class RecreateGroupBannerMember extends LocatorsInterface { +export class PromoteMemberModalConfirm extends LocatorsInterface { public build(): StrategyExtractionObj { - return { - strategy: 'accessibility id', - selector: 'Legacy group banner', - text: tStripped('legacyGroupAfterDeprecationMember'), - } as const; + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'Promote', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } } } - -export class RecreateGroupButton extends LocatorsInterface { +export class PromoteMembersMenuItem extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { - case 'ios': - return { - strategy: 'accessibility id', - selector: 'Legacy Groups Recreate Button', - } as const; case 'android': return { - strategy: 'accessibility id', - selector: 'Accept message request', + strategy: 'id', + selector: 'promote-members-menu-option', } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); } } } @@ -316,7 +382,7 @@ export class RemoveMemberButton extends LocatorsInterface { case 'android': return { strategy: 'id', - selector: 'Remove contact button', + selector: 'qa-collapsing-footer-action_remove', } as const; case 'ios': return { @@ -326,6 +392,32 @@ export class RemoveMemberButton extends LocatorsInterface { } } } +export class RemoveMemberMessagesRadial extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'remove-member-messages-option', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } + } +} +export class RemoveMemberRadial extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'remove-member-option', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } + } +} export class SaveGroupNameChangeButton extends LocatorsInterface { public build(): StrategyExtractionObj { @@ -343,6 +435,35 @@ export class SaveGroupNameChangeButton extends LocatorsInterface { } } } + +export class ShareMessageHistoryRadial extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'share-message-history-option', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } + } +} + +export class ShareNewMessagesRadial extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'share-new-messages-option', + } as const; + case 'ios': + throw new Error('Manage Members not available on iOS'); + } + } +} + export class UpdateGroupInformation extends LocatorsInterface { private groupName?: GROUPNAME; @@ -366,7 +487,8 @@ export class UpdateGroupInformation extends LocatorsInterface { } return { strategy: 'accessibility id', - selector: groupName, + selector: 'Username', + text: groupName, }; } } diff --git a/run/test/locators/home.ts b/run/test/locators/home.ts index 81cbfff87..d9ba6d6c8 100644 --- a/run/test/locators/home.ts +++ b/run/test/locators/home.ts @@ -3,6 +3,34 @@ import type { DeviceWrapper } from '../../types/DeviceWrapper'; import { StrategyExtractionObj } from '../../types/testing'; import { LocatorsInterface } from './index'; +export class BackgroundPermsAllowButton extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'whitelist-confirm-button', + } as const; + case 'ios': + throw new Error('Not implemented'); + } + } +} + +export class BackgroundPermsCancelButton extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'whitelist-cancel-button', + } as const; + case 'ios': + throw new Error('Not implemented'); + } + } +} + export class ConversationItem extends LocatorsInterface { public text: string | undefined; constructor(device: DeviceWrapper, text?: string) { @@ -22,6 +50,27 @@ export class ConversationItem extends LocatorsInterface { } } +// Find pin icon belonging to a specific conversation +export class ConversationPinnedIcon extends LocatorsInterface { + constructor( + device: DeviceWrapper, + private name: string + ) { + super(device); + } + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'xpath', + selector: `//android.view.ViewGroup[android.widget.TextView[@content-desc='Conversation list item' and @text='${this.name}']]/android.widget.ImageView[@resource-id='network.loki.messenger:id/iconPinned']`, + } as const; + case 'ios': + throw new Error('ConversationPinnedIcon: iOS not yet implemented'); + } + } +} + export class EmptyLandingPage extends LocatorsInterface { public build() { switch (this.platform) { @@ -112,6 +161,18 @@ export class MessageSnippet extends LocatorsInterface { } } } +export class PinConversationOption extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Pin', + } as const; + } + } +} export class PlusButton extends LocatorsInterface { public build() { @@ -121,6 +182,7 @@ export class PlusButton extends LocatorsInterface { } as const; } } + export class ReviewPromptItsGreatButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -222,3 +284,20 @@ export class SearchButton extends LocatorsInterface { } } } + +export class UnpinConversationOption extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'network.loki.messenger:id/unpinTextView', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Unpin', + } as const; + } + } +} diff --git a/run/test/locators/index.ts b/run/test/locators/index.ts index 2efd043ac..f61272126 100644 --- a/run/test/locators/index.ts +++ b/run/test/locators/index.ts @@ -1,4 +1,5 @@ import { ANDROID_XPATHS, IOS_XPATHS } from '../../constants'; +import { tStripped } from '../../localizer/lib'; import { DeviceWrapper } from '../../types/DeviceWrapper'; import { StrategyExtractionObj } from '../../types/testing'; import { getAppDisplayName } from '../utils/devnet'; @@ -51,8 +52,8 @@ export class BlockedContactsSettings extends LocatorsInterface { switch (this.platform) { case 'android': return { - strategy: 'accessibility id', - selector: 'qa-blocked-contacts-settings-item', + strategy: 'id', + selector: 'preferences-option-blocked-contacts', }; case 'ios': return { @@ -311,10 +312,39 @@ export class FirstGif extends LocatorsInterface { } } +export class GIFName extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + // Dates can wildly differ between emulators but it will begin with "X taken on" on Android + case 'android': + return { + strategy: 'xpath', + selector: `//*[starts-with(@content-desc, "GIF taken on")]`, + }; + case 'ios': + throw new Error(`No such element on iOS`); + } + } +} + +export class GrantCameraAccessButton extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: '-android uiautomator', + selector: `new UiSelector().text("${tStripped('cameraGrantAccess')}")`, + } as const; + case 'ios': + throw new Error('Not implemented on iOS'); + } + } +} + export class ImageName extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { - // Dates can wildly differ between emulators but it will begin with "Photo taken on" on Android + // Dates can wildly differ between emulators but it will begin with "X taken on" on Android case 'android': return { strategy: 'xpath', @@ -340,6 +370,20 @@ export class ImagePermissionsModalAllow extends LocatorsInterface { } } +export class InviteAccountIDOrONS extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'invite-accountid-menu-option', + } as const; + case 'ios': + throw new Error('Manage Members not implemented yet on iOS'); + } + } +} + export class InviteContactsButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -442,8 +486,7 @@ export class ReadReceiptsButton extends LocatorsInterface { case 'android': return { strategy: 'id', - selector: 'android:id/summary', - text: 'Show read receipts for all messages you send and receive.', + selector: 'preferences-option-read-receipt', } as const; case 'ios': return { @@ -454,6 +497,20 @@ export class ReadReceiptsButton extends LocatorsInterface { } } +export class ScanQRTab extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: '-android uiautomator', + selector: `new UiSelector().text("${tStripped('qrScan')}")`, + } as const; + case 'ios': + throw new Error('Not implemented on iOS'); + } + } +} + export class ShareExtensionIcon extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -522,6 +579,14 @@ export function describeLocator(locator: StrategyExtractionObj & { text?: string ? `${selector.substring(0, halfLength)}…${selector.substring(selector.length - halfLength)}` : selector; + // Trim text if too long, show beginning and end + const maxTextLength = 100; + const trimmedText = text + ? text.length > maxTextLength + ? `${text.substring(0, maxTextLength / 2)}…${text.substring(text.length - maxTextLength / 2)}` + : text + : undefined; + const base = `${strategy} "${trimmedSelector}"`; - return text ? `${base} and text "${text}"` : base; + return trimmedText ? `${base} and text "${trimmedText}"` : base; } diff --git a/run/test/locators/onboarding.ts b/run/test/locators/onboarding.ts index ef6544b98..9f312e492 100644 --- a/run/test/locators/onboarding.ts +++ b/run/test/locators/onboarding.ts @@ -132,7 +132,7 @@ export class PrivacyPolicyButton extends LocatorsInterface { case 'ios': return { strategy: 'accessibility id', - selector: 'Privacy Policy', + selector: 'https://getsession.org/privacy-policy', } as const; } } @@ -200,7 +200,7 @@ export class TermsOfServiceButton extends LocatorsInterface { case 'ios': return { strategy: 'accessibility id', - selector: 'Terms of Service', + selector: 'https://getsession.org/terms-of-service', } as const; } } diff --git a/run/test/locators/settings.ts b/run/test/locators/settings.ts index 04b3f933c..6db4471ce 100644 --- a/run/test/locators/settings.ts +++ b/run/test/locators/settings.ts @@ -1,3 +1,4 @@ +import { tStripped } from '../../localizer/lib'; import { StrategyExtractionObj } from '../../types/testing'; import { LocatorsInterface } from './index'; @@ -153,13 +154,15 @@ export class HideRecoveryPasswordButton extends LocatorsInterface { } } } + export class NotificationsMenuItem extends LocatorsInterface { public build() { switch (this.platform) { case 'android': return { - strategy: 'id', - selector: 'Notifications', + strategy: '-android uiautomator', + selector: + 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("Notifications"))', } as const; case 'ios': return { @@ -169,7 +172,6 @@ export class NotificationsMenuItem extends LocatorsInterface { } } } - export class PathMenuItem extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -202,13 +204,32 @@ export class PrivacyMenuItem extends LocatorsInterface { } } -export class RecoveryPasswordMenuItem extends LocatorsInterface { +export class ProAnimatedDisplayPictureModalDescription extends LocatorsInterface { public build() { switch (this.platform) { case 'android': return { strategy: 'id', - selector: 'Recovery password menu item', + selector: 'pro-badge-text', + text: tStripped('proAnimatedDisplayPictureModalDescription'), + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: ' users can upload GIFs', // Yes this is an intentional whitespace + } as const; + } + } +} + +export class RecoveryPasswordMenuItem extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: '-android uiautomator', + selector: + 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("Recovery password menu item"))', } as const; case 'ios': return { @@ -303,6 +324,22 @@ export class SelectAppIcon extends LocatorsInterface { } } +export class SettingsModalsEnableButton extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'preferences-dialog-option-enable', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Continue', + } as const; + } + } +} export class UserAvatar extends LocatorsInterface { public build() { switch (this.platform) { @@ -315,10 +352,12 @@ export class UserAvatar extends LocatorsInterface { return { strategy: 'accessibility id', selector: 'User settings', + text: 'Profile picture', // There's more than one User settings so this is to specify the avatar } as const; } } } + export class UserSettings extends LocatorsInterface { public build() { return { @@ -346,6 +385,20 @@ export class VersionNumber extends LocatorsInterface { } } +export class ViewQR extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: '-android uiautomator', + selector: `new UiSelector().text("${tStripped('qrView')}")`, + } as const; + case 'ios': + throw new Error('Not implemented on iOS'); + } + } +} + export class YesButton extends LocatorsInterface { public build() { switch (this.platform) { diff --git a/run/test/media/animated_profile_picture.gif b/run/test/media/animated_profile_picture.gif new file mode 100644 index 000000000..e609338d1 --- /dev/null +++ b/run/test/media/animated_profile_picture.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cbfc07234028c655e51d9a3d218061c2363b314b4ce59101ce0bfac6e085d77 +size 33041 diff --git a/run/test/specs/app_disguise_set.spec.ts b/run/test/specs/app_disguise_set.spec.ts index c610fadd9..c0abcbf5a 100644 --- a/run/test/specs/app_disguise_set.spec.ts +++ b/run/test/specs/app_disguise_set.spec.ts @@ -13,13 +13,13 @@ import { UserSettings, } from '../locators/settings'; import { sleepFor } from '../utils'; -import { getAdbFullPath } from '../utils/binaries'; -import { androidAppPackage } from '../utils/capabilities_android'; -import { iOSBundleId } from '../utils/capabilities_ios'; import { newUser } from '../utils/create_account'; -import { openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; -import { closeApp } from '../utils/open_app'; -import { runScriptAndLog } from '../utils/utilities'; +import { + closeApp, + openAppOnPlatformSingleDevice, + SupportedPlatformsType, + uninstallApp, +} from '../utils/open_app'; bothPlatformsItSeparate({ title: 'App disguise set icon', @@ -68,7 +68,7 @@ async function appDisguiseSetIconIOS(platform: SupportedPlatformsType, testInfo: // The disguised app must be uninstalled otherwise every following test will fail await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); - await runScriptAndLog(`xcrun simctl uninstall ${device.udid} ${iOSBundleId}`, true); + await uninstallApp(device, platform); }); } }); @@ -88,7 +88,7 @@ async function appDisguiseSetIconAndroid(platform: SupportedPlatformsType, testI await device.clickOnElementAll(new SelectAppIcon(device)); try { await device.clickOnElementAll(new AppDisguiseMeetingIcon(device)); - await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('app disgusie'), async () => { + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('app disguise'), async () => { await device.checkModalStrings( tStripped('appIconAndNameChange'), tStripped('appIconAndNameChangeConfirmation') @@ -105,10 +105,7 @@ async function appDisguiseSetIconAndroid(platform: SupportedPlatformsType, testI // The disguised app must be uninstalled otherwise every following test will fail await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); - await runScriptAndLog( - `${getAdbFullPath()} -s ${device.udid} uninstall ${androidAppPackage}`, - true - ); + await uninstallApp(device, platform); }); } }); diff --git a/run/test/specs/community_ban.spec.ts b/run/test/specs/community_ban.spec.ts new file mode 100644 index 000000000..0495dfd41 --- /dev/null +++ b/run/test/specs/community_ban.spec.ts @@ -0,0 +1,181 @@ +import test, { type TestInfo } from '@playwright/test'; + +import { communities } from '../../constants/community'; +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { User } from '../../types/testing'; +import { + LongPressBanAndDelete, + LongPressBanUser, + LongPressUnBan, + MessageBody, + MessageInput, + OutgoingMessageStatusSent, + SendButton, +} from '../locators/conversation'; +import { ConversationItem } from '../locators/home'; +import { assertAdminIsKnown, joinCommunity } from '../utils/community'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppTwoDevices, SupportedPlatformsType } from '../utils/open_app'; +import { restoreAccount } from '../utils/restore_account'; + +bothPlatformsIt({ + title: 'Ban and unban user in community', + risk: 'medium', + countOfDevicesNeeded: 2, + testCb: banUserCommunity, + allureSuites: { + parent: 'User Actions', + suite: 'Ban/Unban', + }, + allureDescription: `Verifies that a community admin can ban a user. + Banned user cannot send messages anymore. + Admin then can unban a user and they can send messages again. `, +}); + +bothPlatformsIt({ + title: 'Ban and delete in community', + risk: 'medium', + countOfDevicesNeeded: 2, + testCb: banAndDelete, + allureSuites: { + parent: 'User Actions', + suite: 'Ban/Unban', + }, + allureDescription: + 'Verifies that a community admin can ban a user and delete their messages. Banned user cannot send messages anymore.', +}); + +async function banUserCommunity(platform: SupportedPlatformsType, testInfo: TestInfo) { + assertAdminIsKnown(); + const msgSig = `${new Date().getTime()} - ${platform}`; + const msg1 = `Ban and unban me - ${msgSig}`; + const msg2 = `Am I banned? - ${msgSig}`; + const msg3 = `Freedom! - ${msgSig}`; + const alice: User = { + userName: 'Alice', + accountID: '', // Mandatory property of User type but not needed for this test + recoveryPhrase: process.env.SOGS_ADMIN_SEED!, + }; + const { device1: alice1, device2: bob1 } = await openAppTwoDevices(platform, testInfo); + const [, bob] = + await test.step('Restore admin account, create new account to be banned', async () => { + return Promise.all([ + restoreAccount(alice1, alice, 'alice1'), + newUser(bob1, 'Bob', { saveUserData: false }), + ]); + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + const adminJoined = await alice1.doesElementExist( + new ConversationItem(alice1, communities.testCommunity.name) + ); + if (!adminJoined) { + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); + } else { + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); + await alice1.scrollToBottom(); + } + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); + }); + await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { + await bob1.sendMessage(msg1); + }); + await test.step('Admin bans Bob from community', async () => { + await alice1.longPressMessage(new MessageBody(alice1, msg1)); + await alice1.clickOnElementAll(new LongPressBanUser(alice1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Ban User'), async () => { + await alice1.checkModalStrings( + tStripped('banUser'), + tStripped('communityBanUserDescription', { name: bob.userName }) + ); + }); + await alice1.clickOnByAccessibilityID('Continue'); + }); + await test.step('Verify Bob cannot send messages in community', async () => { + await bob1.inputText(msg2, new MessageInput(bob1)); + await bob1.clickOnElementAll(new SendButton(bob1)); + await bob1.verifyElementNotPresent({ + ...new OutgoingMessageStatusSent(bob1).build(), + maxWait: 10_000, + }); + await alice1.verifyElementNotPresent(new MessageBody(alice1, msg2)); + }); + await test.step('Admin unbans Bob, Bob can send a third message', async () => { + await alice1.longPressMessage(new MessageBody(alice1, msg1)); + await alice1.clickOnElementAll(new LongPressUnBan(alice1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Unban User'), async () => { + await alice1.checkModalStrings( + tStripped('banUnbanUser'), + tStripped('communityUnbanUserDescription', { name: bob.userName }) + ); + }); + await alice1.clickOnByAccessibilityID('Continue'); + await bob1.sendMessage(msg3); + await alice1.waitForTextElementToBePresent(new MessageBody(alice1, msg3)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} + +async function banAndDelete(platform: SupportedPlatformsType, testInfo: TestInfo) { + assertAdminIsKnown(); + const msgSig = `${new Date().getTime()} - ${platform}`; + const msg1 = `Ban and delete - ${msgSig}`; + const msg2 = `Am I banned? - ${msgSig}`; + const alice: User = { + userName: 'Alice', + accountID: '', // Mandatory property of User type but not needed for this test + recoveryPhrase: process.env.SOGS_ADMIN_SEED!, + }; + const { device1: alice1, device2: bob1 } = await openAppTwoDevices(platform, testInfo); + await test.step('Restore admin account, create new account to be banned', async () => { + await Promise.all([ + restoreAccount(alice1, alice, 'alice1'), + newUser(bob1, 'Bob', { saveUserData: false }), + ]); + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + const adminJoined = await alice1.doesElementExist( + new ConversationItem(alice1, communities.testCommunity.name) + ); + if (!adminJoined) { + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); + } else { + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); + await alice1.scrollToBottom(); + } + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); + }); + await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { + await bob1.sendMessage(msg1); + }); + await test.step('Admin bans Bob and deletes all from community', async () => { + await alice1.longPressMessage(new MessageBody(alice1, msg1)); + await alice1.clickOnElementAll(new LongPressBanAndDelete(alice1)); + await alice1.checkModalStrings( + tStripped('banDeleteAll'), + tStripped('communityBanDeleteDescription') + ); + await alice1.clickOnByAccessibilityID('Continue'); + }); + await test.step(`Verify Bob's first message has been deleted`, async () => { + await alice1.verifyElementNotPresent({ + ...new MessageBody(alice1, msg1).build(), + maxWait: 5_000, + }); + }); + await test.step('Verify Bob cannot send messages in community', async () => { + await bob1.inputText(msg2, new MessageInput(bob1)); + await bob1.clickOnElementAll(new SendButton(bob1)); + await bob1.verifyElementNotPresent({ + ...new OutgoingMessageStatusSent(bob1).build(), + maxWait: 10_000, + }); + await alice1.verifyElementNotPresent(new MessageBody(alice1, msg2)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} diff --git a/run/test/specs/community_emoji_react.spec.ts b/run/test/specs/community_emoji_react.spec.ts index fb8081836..2c1b4b3c2 100644 --- a/run/test/specs/community_emoji_react.spec.ts +++ b/run/test/specs/community_emoji_react.spec.ts @@ -1,11 +1,11 @@ import { test, type TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { EmojiReactsPill, FirstEmojiReact, MessageBody } from '../locators/conversation'; import { open_Alice1_Bob1_friends } from '../state_builder'; -import { joinCommunity } from '../utils/join_community'; +import { joinCommunity } from '../utils/community'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ @@ -18,9 +18,6 @@ bothPlatformsIt({ suite: 'Emoji reacts', }, allureDescription: 'Verifies that an emoji reaction can be sent and is received in a community', - allureLinks: { - android: 'SES-4608', - }, }); async function sendEmojiReactionCommunity(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -36,11 +33,16 @@ async function sendEmojiReactionCommunity(platform: SupportedPlatformsType, test }); }); await Promise.all( - [alice1, bob1].map(device => joinCommunity(device, testCommunityLink, testCommunityName)) + [alice1, bob1].map(device => + joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name) + ) + ); + await test.step( + TestSteps.SEND.MESSAGE(alice.userName, communities.testCommunity.name), + async () => { + await alice1.sendMessage(message); + } ); - await test.step(TestSteps.SEND.MESSAGE(alice.userName, testCommunityName), async () => { - await alice1.sendMessage(message); - }); await test.step(TestSteps.SEND.EMOJI_REACT, async () => { await bob1.scrollToBottom(); await bob1.longPressMessage(new MessageBody(bob1, message)); diff --git a/run/test/specs/community_links.spec.ts b/run/test/specs/community_links.spec.ts new file mode 100644 index 000000000..ed4721030 --- /dev/null +++ b/run/test/specs/community_links.spec.ts @@ -0,0 +1,200 @@ +import { test, TestInfo } from '@playwright/test'; +import { USERNAME } from '@session-foundation/qa-seeder'; + +import { communities } from '../../constants/community'; +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { JoinCommunityModalButton } from '../locators'; +import { ConversationHeaderName, MessageBody } from '../locators/conversation'; +import { CreateGroupButton, GroupNameInput } from '../locators/groups'; +import { PlusButton } from '../locators/home'; +import { + CreateGroupOption, + EnterAccountID, + NewMessageOption, + NextButton, +} from '../locators/start_conversation'; +import { joinCommunity } from '../utils/community'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; + +androidIt({ + title: 'Community URL on New Message - not member', + risk: 'low', + countOfDevicesNeeded: 1, + testCb: communityURLNewConvo, + allureSuites: { + parent: 'New Conversation', + suite: 'Join Community', + }, +}); + +androidIt({ + title: 'Join Community URL on Create Group - not member', + risk: 'low', + countOfDevicesNeeded: 1, + testCb: communityURLGroup, + allureSuites: { + parent: 'New Conversation', + suite: 'Join Community', + }, +}); + +androidIt({ + title: 'Community URL on New Message - member', + risk: 'low', + countOfDevicesNeeded: 1, + testCb: communityURLNewConvoMember, + allureSuites: { + parent: 'New Conversation', + suite: 'Join Community', + }, +}); + +androidIt({ + title: 'Join Community URL on Create Group - member', + risk: 'low', + countOfDevicesNeeded: 1, + testCb: communityURLGroupMember, + allureSuites: { + parent: 'New Conversation', + suite: 'Join Community', + }, +}); + +async function communityURLNewConvo(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Type Community URL in Create Group screen', async () => { + await device.clickOnElementAll(new PlusButton(device)); + await device.clickOnElementAll(new NewMessageOption(device)); + await device.inputText(communities.testCommunity.link, new EnterAccountID(device)); + await device.clickOnElementAll(new NextButton(device)); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Join Community'), async () => { + await device.checkModalStrings( + tStripped('communityJoin'), + tStripped('communityUrlJoinEntered') + ); + }); + await test.step('Verify Community can be joined', async () => { + await device.clickOnElementAll(new JoinCommunityModalButton(device)); + await device.waitForTextElementToBePresent( + new ConversationHeaderName(device, communities.testCommunity.name) + ); + await device.waitForTextElementToBePresent(new MessageBody(device)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function communityURLGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Type Community URL in New Message screen', async () => { + await device.clickOnElementAll(new PlusButton(device)); + await device.clickOnElementAll(new CreateGroupOption(device)); + await device.inputText(communities.testCommunity.link, new GroupNameInput(device)); + await device.clickOnElementAll(new CreateGroupButton(device)); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Join Community'), async () => { + await device.checkModalStrings( + tStripped('communityJoin'), + tStripped('groupNameContainedUrlJoinCommunity') + ); + }); + await test.step('Verify Community can be joined', async () => { + await device.clickOnElementAll(new JoinCommunityModalButton(device)); + await device.waitForTextElementToBePresent( + new ConversationHeaderName(device, communities.testCommunity.name) + ); + await device.waitForTextElementToBePresent(new MessageBody(device)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function communityURLNewConvoMember(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + await joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name); + await device.navigateBack(); + }); + await test.step('Type Community URL in Create Group screen', async () => { + await device.clickOnElementAll(new PlusButton(device)); + await device.clickOnElementAll(new NewMessageOption(device)); + await device.inputText(communities.testCommunity.link, new EnterAccountID(device)); + await device.clickOnElementAll(new NextButton(device)); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Join Community'), async () => { + await device.checkModalStrings( + tStripped('openCommunity'), + tStripped('communityUrlOpenEntered', { community_name: communities.testCommunity.name }) + ); + }); + await test.step('Verify Community can be opened', async () => { + await device.clickOnElementAll({ + strategy: '-android uiautomator', + selector: `new UiSelector().text("Open")`, + }); + await device.waitForTextElementToBePresent( + new ConversationHeaderName(device, communities.testCommunity.name) + ); + await device.waitForTextElementToBePresent(new MessageBody(device)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function communityURLGroupMember(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + await joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name); + await device.navigateBack(); + }); + await test.step('Type Community URL in New Message screen', async () => { + await device.clickOnElementAll(new PlusButton(device)); + await device.clickOnElementAll(new CreateGroupOption(device)); + await device.inputText(communities.testCommunity.link, new GroupNameInput(device)); + await device.clickOnElementAll(new CreateGroupButton(device)); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Join Community'), async () => { + await device.checkModalStrings( + tStripped('openCommunity'), + tStripped('groupNameContainedUrlOpenCommunity', { + community_name: communities.testCommunity.name, + }) + ); + }); + await test.step('Verify Community can be opened', async () => { + await device.clickOnElementAll({ + strategy: '-android uiautomator', + selector: `new UiSelector().text("Open")`, + }); + await device.waitForTextElementToBePresent( + new ConversationHeaderName(device, communities.testCommunity.name) + ); + await device.waitForTextElementToBePresent(new MessageBody(device)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} diff --git a/run/test/specs/community_requests_off.spec.ts b/run/test/specs/community_requests_off.spec.ts index 83f598b19..9a72f2b0d 100644 --- a/run/test/specs/community_requests_off.spec.ts +++ b/run/test/specs/community_requests_off.spec.ts @@ -1,13 +1,13 @@ import { test, type TestInfo } from '@playwright/test'; import { USERNAME } from '@session-foundation/qa-seeder'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { CommunityMessageAuthor, UPMMessageButton } from '../locators/conversation'; import { sleepFor } from '../utils'; +import { joinCommunity } from '../utils/community'; import { newUser } from '../utils/create_account'; -import { joinCommunity } from '../utils/join_community'; import { closeApp, openAppTwoDevices, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ @@ -33,13 +33,16 @@ async function blindedMessageRequests(platform: SupportedPlatformsType, testInfo await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { await Promise.all( [device1, device2].map(async device => { - await joinCommunity(device, testCommunityLink, testCommunityName); + await joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name); }) ); }); - await test.step(TestSteps.SEND.MESSAGE(USERNAME.BOB, testCommunityName), async () => { - await device2.sendMessage(message); - }); + await test.step( + TestSteps.SEND.MESSAGE(USERNAME.BOB, communities.testCommunity.name), + async () => { + await device2.sendMessage(message); + } + ); await device1.clickOnElementAll(new CommunityMessageAuthor(device1, message)); await test.step(`Verify the 'Message' button in the User Profile Modal is disabled`, async () => { // brief sleep to let the UI settle diff --git a/run/test/specs/community_requests_on.spec.ts b/run/test/specs/community_requests_on.spec.ts index 6238127f3..caefd37a8 100644 --- a/run/test/specs/community_requests_on.spec.ts +++ b/run/test/specs/community_requests_on.spec.ts @@ -1,7 +1,7 @@ import { test, type TestInfo } from '@playwright/test'; import { USERNAME } from '@session-foundation/qa-seeder'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { CloseSettings } from '../locators'; @@ -13,11 +13,11 @@ import { MessageRequestPendingDescription, UPMMessageButton, } from '../locators/conversation'; -import { MessageRequestsBanner } from '../locators/home'; +import { MessageRequestItem, MessageRequestsBanner } from '../locators/home'; import { CommunityMessageRequestSwitch, PrivacyMenuItem, UserSettings } from '../locators/settings'; import { sleepFor } from '../utils'; +import { joinCommunity } from '../utils/community'; import { newUser } from '../utils/create_account'; -import { joinCommunity } from '../utils/join_community'; import { closeApp, openAppTwoDevices, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ @@ -55,19 +55,23 @@ async function blindedMessageRequests(platform: SupportedPlatformsType, testInfo await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { await Promise.all( [device1, device2].map(async device => { - await joinCommunity(device, testCommunityLink, testCommunityName); + await joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name); }) ); }); - await test.step(TestSteps.SEND.MESSAGE(bob.userName, testCommunityName), async () => { - // brief sleep to let the UI settle - await sleepFor(1000); - await device2.sendMessage(message); - await device2.navigateBack(); - }); + await test.step( + TestSteps.SEND.MESSAGE(bob.userName, communities.testCommunity.name), + async () => { + // brief sleep to let the UI settle + await sleepFor(1000); + await device2.sendMessage(message); + await device2.navigateBack(); + } + ); await test.step(TestSteps.SEND.MESSAGE(alice.userName, bob.userName), async () => { await device1.clickOnElementAll(new CommunityMessageAuthor(device1, message)); + await sleepFor(500); // brief sleep to let the UI settle await device1.clickOnElementAll(new UPMMessageButton(device1)); await device1.clickOnElementAll(new ConversationHeaderName(device1, bob.userName)); await device1.waitForTextElementToBePresent(new MessageRequestPendingDescription(device1)); @@ -76,7 +80,7 @@ async function blindedMessageRequests(platform: SupportedPlatformsType, testInfo await test.step(`${bob.userName} accepts message request from ${alice.userName}`, async () => { await device2.clickOnElementAll(new MessageRequestsBanner(device2)); // Bob clicks on request conversation item - await device2.clickOnByAccessibilityID('Message request'); + await device2.clickOnElementAll(new MessageRequestItem(device2)); await device2.waitForTextElementToBePresent( new ConversationHeaderName(device2, alice.userName) ); diff --git a/run/test/specs/community_tests_image.spec.ts b/run/test/specs/community_tests_image.spec.ts index 3858870c3..6cc65028b 100644 --- a/run/test/specs/community_tests_image.spec.ts +++ b/run/test/specs/community_tests_image.spec.ts @@ -1,12 +1,12 @@ import { test, type TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { MessageBody } from '../locators/conversation'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils'; -import { joinCommunity } from '../utils/join_community'; +import { joinCommunity } from '../utils/community'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ @@ -31,7 +31,9 @@ async function sendImageCommunity(platform: SupportedPlatformsType, testInfo: Te const testImageMessage = `Image message + ${new Date().getTime()} - ${platform}`; await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { await Promise.all( - [alice1, bob1].map(device => joinCommunity(device, testCommunityLink, testCommunityName)) + [alice1, bob1].map(device => + joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name) + ) ); }); await test.step(TestSteps.SEND.IMAGE, async () => { @@ -40,7 +42,7 @@ async function sendImageCommunity(platform: SupportedPlatformsType, testInfo: Te await test.step(TestSteps.VERIFY.MESSAGE_RECEIVED, async () => { await sleepFor(2000); // Give bob some time to receive the image await bob1.scrollToBottom(); - await bob1.onAndroid().trustAttachments(testCommunityName); + await bob1.onAndroid().trustAttachments(communities.testCommunity.name); await bob1.onAndroid().scrollToBottom(); // Trusting attachments scrolls the viewport up a bit so gotta scroll to bottom again await bob1.waitForTextElementToBePresent(new MessageBody(bob1, testImageMessage)); }); diff --git a/run/test/specs/community_tests_join.spec.ts b/run/test/specs/community_tests_join.spec.ts index d07d4d985..dd9bc8b4f 100644 --- a/run/test/specs/community_tests_join.spec.ts +++ b/run/test/specs/community_tests_join.spec.ts @@ -1,12 +1,12 @@ import { test, type TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { ConversationItem } from '../locators/home'; import { open_Alice2 } from '../state_builder'; import { sleepFor } from '../utils'; -import { joinCommunity } from '../utils/join_community'; +import { joinCommunity } from '../utils/community'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ @@ -31,16 +31,21 @@ async function joinCommunityTest(platform: SupportedPlatformsType, testInfo: Tes }); const testMessage = `Test message + ${new Date().getTime()}`; await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); await sleepFor(5000); }); - await test.step(TestSteps.SEND.MESSAGE(alice.userName, testCommunityName), async () => { - await alice1.scrollToBottom(); - await alice1.sendMessage(testMessage); - }); + await test.step( + TestSteps.SEND.MESSAGE(alice.userName, communities.testCommunity.name), + async () => { + await alice1.scrollToBottom(); + await alice1.sendMessage(testMessage); + } + ); await test.step(TestSteps.VERIFY.MESSAGE_SYNCED, async () => { // Has community synced to device 2? - await alice2.waitForTextElementToBePresent(new ConversationItem(alice2, testCommunityName)); + await alice2.waitForTextElementToBePresent( + new ConversationItem(alice2, communities.testCommunity.name) + ); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(alice1, alice2); diff --git a/run/test/specs/cta_donate_review.spec.ts b/run/test/specs/cta_donate_review.spec.ts index 6000863d3..39971cb75 100644 --- a/run/test/specs/cta_donate_review.spec.ts +++ b/run/test/specs/cta_donate_review.spec.ts @@ -1,16 +1,14 @@ import test, { TestInfo } from '@playwright/test'; -import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { CloseSettings } from '../locators'; -import { CTAButtonPositive } from '../locators/global'; import { ReviewPromptItsGreatButton } from '../locators/home'; import { PathMenuItem, UserSettings } from '../locators/settings'; import { newUser } from '../utils/create_account'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; -import { forceStopAndRestart as forceStopAndRestartApp } from '../utils/utilities'; +import { forceStopAndRestart } from '../utils/utilities'; import { verifyPageScreenshot } from '../utils/verify_screenshots'; bothPlatformsIt({ @@ -26,7 +24,6 @@ bothPlatformsIt({ }); async function donateCTAReview(platform: SupportedPlatformsType, testInfo: TestInfo) { - const donateURL = 'https://getsession.org/donate#app'; const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); await newUser(device, USERNAME.ALICE, { saveUserData: false }); @@ -41,26 +38,15 @@ async function donateCTAReview(platform: SupportedPlatformsType, testInfo: TestI await test.step('Dismiss review prompt and restart the app', async () => { await device.clickOnElementAll(new ReviewPromptItsGreatButton(device)); await device.clickOnElementAll(new CloseSettings(device)); - await forceStopAndRestartApp(device); + await forceStopAndRestart(device); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Donate CTA'), async () => { - await device.checkCTAStrings( - tStripped('donateSessionHelp'), - tStripped('donateSessionDescription'), - [tStripped('donate'), tStripped('maybeLater')] - ); + await device.checkCTA('donate'); }); // There *is* supposed to be a blur on Android but there is a bug on API 34 emulators preventing it from showing await test.step(TestSteps.VERIFY.SCREENSHOT('Donate CTA'), async () => { await verifyPageScreenshot(device, platform, 'cta_donate', testInfo); }); - await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Open URL'), async () => { - await device.clickOnElementAll(new CTAButtonPositive(device)); - await device.checkModalStrings( - tStripped('urlOpen'), - tStripped('urlOpenDescription', { url: donateURL }) - ); - }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); }); diff --git a/run/test/specs/cta_donate_time.spec.ts b/run/test/specs/cta_donate_time.spec.ts index 06844e0f0..c7e1f46c2 100644 --- a/run/test/specs/cta_donate_time.spec.ts +++ b/run/test/specs/cta_donate_time.spec.ts @@ -1,18 +1,12 @@ import test, { TestInfo } from '@playwright/test'; -import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { iosIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { CTAButtonPositive } from '../locators/global'; import { PlusButton } from '../locators/home'; +import { IOSTestContext } from '../utils/capabilities_ios'; import { newUser } from '../utils/create_account'; -import { - closeApp, - IOSTestContext, - openAppOnPlatformSingleDevice, - SupportedPlatformsType, -} from '../utils/open_app'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; import { setIOSFirstInstallDate } from '../utils/time_travel'; // iOS uses app-level time override (customFirstInstallDateTime capability). @@ -42,11 +36,7 @@ async function donateCTAShowsSevenDaysAgo(platform: SupportedPlatformsType, test return { device }; }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Donate CTA'), async () => { - await device.checkCTAStrings( - tStripped('donateSessionHelp'), - tStripped('donateSessionDescription'), - [tStripped('donate'), tStripped('maybeLater')] - ); + await device.checkCTA('donate'); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); @@ -77,7 +67,7 @@ async function donateCTADoesntShowSixDaysAgo(platform: SupportedPlatformsType, t await test.step('Verify Donate CTA does not show', async () => { await Promise.all([ device.waitForTextElementToBePresent(new PlusButton(device)), - device.verifyElementNotPresent(new CTAButtonPositive(device)), + device.verifyNoCTAShows(), ]); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { diff --git a/run/test/specs/disappear_after_read.spec.ts b/run/test/specs/disappear_after_read.spec.ts index 1b9773eba..68964e545 100644 --- a/run/test/specs/disappear_after_read.spec.ts +++ b/run/test/specs/disappear_after_read.spec.ts @@ -1,11 +1,11 @@ import { test, type TestInfo } from '@playwright/test'; +import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { DISAPPEARING_TIMES, DisappearModes } from '../../types/testing'; import { MessageBody } from '../locators/conversation'; import { open_Alice1_Bob1_friends } from '../state_builder'; -import { checkDisappearingControlMessage } from '../utils/disappearing_control_messages'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -41,24 +41,22 @@ async function disappearAfterRead(platform: SupportedPlatformsType, testInfo: Te let sentTimestamp: number; // Click conversation options menu (three dots) await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage( - platform, - alice1, - ['1:1', `Disappear after ${mode} option`, time], - bob1 - ); + await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); }); - // Check control message is correct on device 2 + // Check control messages on both devices await test.step(TestSteps.VERIFY.DISAPPEARING_CONTROL_MESSAGES, async () => { - await checkDisappearingControlMessage( - platform, - alice.userName, - bob.userName, - alice1, - bob1, - time, - mode - ); + await Promise.all([ + alice1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { time, disappearing_messages_type: mode }) + ), + bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: alice.userName, + time, + disappearing_messages_type: mode, + }) + ), + ]); }); // Send message to verify that deletion is working await test.step(TestSteps.SEND.MESSAGE(alice.userName, bob.userName), async () => { diff --git a/run/test/specs/disappear_after_send.spec.ts b/run/test/specs/disappear_after_send.spec.ts index e1c6b6d70..7e5638c4a 100644 --- a/run/test/specs/disappear_after_send.spec.ts +++ b/run/test/specs/disappear_after_send.spec.ts @@ -1,10 +1,10 @@ import type { TestInfo } from '@playwright/test'; +import { tStripped } from '../../localizer/lib'; import { bothPlatformsIt } from '../../types/sessionIt'; import { DisappearActions, DISAPPEARING_TIMES, DisappearModes } from '../../types/testing'; import { MessageBody } from '../locators/conversation'; import { open_Alice1_Bob1_friends } from '../state_builder'; -import { checkDisappearingControlMessage } from '../utils/disappearing_control_messages'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -23,7 +23,7 @@ bothPlatformsIt({ async function disappearAfterSend(platform: SupportedPlatformsType, testInfo: TestInfo) { const { devices: { alice1, bob1 }, - prebuilt: { alice, bob }, + prebuilt: { alice }, } = await open_Alice1_Bob1_friends({ platform, focusFriendsConvo: true, @@ -35,22 +35,20 @@ async function disappearAfterSend(platform: SupportedPlatformsType, testInfo: Te const time = DISAPPEARING_TIMES.THIRTY_SECONDS; const maxWait = 35_000; // 30s plus buffer // Select disappearing messages option - await setDisappearingMessage( - platform, - alice1, - ['1:1', `Disappear after ${mode} option`, time], - bob1 - ); - // Get control message based on key from json file - await checkDisappearingControlMessage( - platform, - alice.userName, - bob.userName, - alice1, - bob1, - time, - controlMode - ); + await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); + // Check control messages on both devices + await Promise.all([ + alice1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { time, disappearing_messages_type: controlMode }) + ), + bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: alice.userName, + time, + disappearing_messages_type: controlMode, + }) + ), + ]); // Send message to verify that deletion is working const sentTimestamp = await alice1.sendMessage(testMessage); // Wait for message to disappear diff --git a/run/test/specs/disappear_after_send_groups.spec.ts b/run/test/specs/disappear_after_send_groups.spec.ts index 0ad50a7e1..dc16293b0 100644 --- a/run/test/specs/disappear_after_send_groups.spec.ts +++ b/run/test/specs/disappear_after_send_groups.spec.ts @@ -41,7 +41,7 @@ async function disappearAfterSendGroups(platform: SupportedPlatformsType, testIn }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['Group', `Disappear after send option`, time]); + await setDisappearingMessage(alice1, ['Group', `Disappear after send option`, time]); }); await test.step(TestSteps.VERIFY.DISAPPEARING_CONTROL_MESSAGES, async () => { // Get correct control message for You setting disappearing messages diff --git a/run/test/specs/disappear_after_send_note_to_self.spec.ts b/run/test/specs/disappear_after_send_note_to_self.spec.ts index f5d77399d..42199065d 100644 --- a/run/test/specs/disappear_after_send_note_to_self.spec.ts +++ b/run/test/specs/disappear_after_send_note_to_self.spec.ts @@ -46,11 +46,7 @@ async function disappearAfterSendNoteToSelf(platform: SupportedPlatformsType, te }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { // Enable disappearing messages - await setDisappearingMessage(platform, device, [ - 'Note to Self', - 'Disappear after send option', - time, - ]); + await setDisappearingMessage(device, ['Note to Self', 'Disappear after send option', time]); await sleepFor(1000); await device.waitForControlMessageToBePresent( `You set messages to disappear ${time} after they have been ${controlMode}.` diff --git a/run/test/specs/disappear_after_send_off_1o1.spec.ts b/run/test/specs/disappear_after_send_off_1o1.spec.ts index fcef4670d..b9c1eafe4 100644 --- a/run/test/specs/disappear_after_send_off_1o1.spec.ts +++ b/run/test/specs/disappear_after_send_off_1o1.spec.ts @@ -8,12 +8,10 @@ import { DisableDisappearingMessages, DisappearingMessagesMenuOption, DisappearingMessagesSubtitle, - FollowSettingsButton, SetDisappearMessagesButton, } from '../locators/disappearing_messages'; +import { ConversationItem } from '../locators/home'; import { open_Alice2_Bob1_friends } from '../state_builder'; -import { sleepFor } from '../utils'; -import { checkDisappearingControlMessage } from '../utils/disappearing_control_messages'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -40,23 +38,25 @@ async function disappearAfterSendOff1o1(platform: SupportedPlatformsType, testIn const controlMode: DisappearActions = 'sent'; const time = DISAPPEARING_TIMES.THIRTY_SECONDS; // Select disappearing messages option - await setDisappearingMessage( - platform, - alice1, - ['1:1', `Disappear after ${mode} option`, time], - bob1 - ); - // Get control message based on key from json file - await checkDisappearingControlMessage( - platform, - alice.userName, - bob.userName, - alice1, - bob1, + await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); + // Check control messages on both devices and sync to linked device + const setYouMsg = tStripped('disappearingMessagesSetYou', { time, - controlMode, + disappearing_messages_type: controlMode, + }); + await Promise.all([ + alice1.waitForControlMessageToBePresent(setYouMsg), + bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: alice.userName, + time, + disappearing_messages_type: controlMode, + }) + ), alice2 - ); + .clickOnElementAll(new ConversationItem(alice2, bob.userName)) + .then(() => alice2.waitForControlMessageToBePresent(setYouMsg)), + ]); // Turn off disappearing messages on device 1 await alice1.clickOnElementAll(new ConversationSettings(alice1)); @@ -76,14 +76,6 @@ async function disappearAfterSendOff1o1(platform: SupportedPlatformsType, testIn bob1.waitForControlMessageToBePresent(disappearingMessagesTurnedOff), alice2.waitForControlMessageToBePresent(disappearingMessagesTurnedOffYou), ]); - // Follow setting on device 2 - await bob1.clickOnElementAll(new FollowSettingsButton(bob1)); - await sleepFor(500); - await bob1.checkModalStrings( - tStripped('disappearingMessagesFollowSetting'), - tStripped('disappearingMessagesFollowSettingOff') - ); - await bob1.clickOnElementAll({ strategy: 'accessibility id', selector: 'Confirm' }); // Check conversation subtitle? await Promise.all( [alice1, bob1, alice2].map(device => diff --git a/run/test/specs/disappearing_call.spec.ts b/run/test/specs/disappearing_call.spec.ts index e7c16ac42..42caceccc 100644 --- a/run/test/specs/disappearing_call.spec.ts +++ b/run/test/specs/disappearing_call.spec.ts @@ -5,7 +5,8 @@ import { TestSteps } from '../../types/allure'; import { bothPlatformsItSeparate } from '../../types/sessionIt'; import { DISAPPEARING_TIMES } from '../../types/testing'; import { CloseSettings } from '../locators'; -import { CallButton, NotificationsModalButton, NotificationSwitch } from '../locators/conversation'; +import { CallButton, NotificationSwitch } from '../locators/conversation'; +import { SettingsModalsEnableButton } from '../locators/settings'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; @@ -27,6 +28,9 @@ bothPlatformsItSeparate({ }, allureDescription: 'Verifies that a call control message disappears as expected in a 1:1 conversation', + allureLinks: { + android: 'SES-5265', + }, }); const time = DISAPPEARING_TIMES.THIRTY_SECONDS; @@ -43,7 +47,7 @@ async function disappearingCallMessage1o1Ios(platform: SupportedPlatformsType, t focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); await alice1.clickOnElementAll(new CallButton(alice1)); // Alice turns on all calls perms necessary (without checking every modal string) await alice1.clickOnByAccessibilityID('Settings'); @@ -126,18 +130,18 @@ async function disappearingCallMessage1o1Android( focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); await alice1.clickOnElementAll(new CallButton(alice1)); // Alice turns on all calls perms necessary (without checking every modal string) await alice1.clickOnElementAll({ strategy: 'accessibility id', selector: 'Settings', }); - await alice1.clickOnByAccessibilityID('Enable'); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); await alice1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' ); - await alice1.clickOnElementAll(new NotificationsModalButton(alice1)); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); await alice1.clickOnElementAll(new NotificationSwitch(alice1)); // Return to conversation await alice1.navigateBack(false); diff --git a/run/test/specs/disappearing_community_invite.spec.ts b/run/test/specs/disappearing_community_invite.spec.ts index d7c9ddcf3..d529f8546 100644 --- a/run/test/specs/disappearing_community_invite.spec.ts +++ b/run/test/specs/disappearing_community_invite.spec.ts @@ -1,6 +1,6 @@ import type { TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { bothPlatformsIt } from '../../types/sessionIt'; import { DISAPPEARING_TIMES } from '../../types/testing'; import { InviteContactsMenuItem } from '../locators'; @@ -13,7 +13,7 @@ import { GroupMember } from '../locators/groups'; import { ConversationItem } from '../locators/home'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils'; -import { joinCommunity } from '../utils/join_community'; +import { joinCommunity } from '../utils/community'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -47,10 +47,10 @@ async function disappearingCommunityInviteMessage( focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); // await alice1.navigateBack(); await alice1.navigateBack(); - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); await alice1.clickOnElementAll(new ConversationSettings(alice1)); await sleepFor(1000); await alice1.clickOnElementAll(new InviteContactsMenuItem(alice1)); @@ -68,7 +68,7 @@ async function disappearingCommunityInviteMessage( // Leave Invite Contacts, Conversation Settings, Community, and open convo with Bob await alice1.navigateBack(); await alice1.navigateBack(); - await alice1.navigateBack(); + await alice1.onIOS().navigateBack(); // Android only needs to go back twice await alice1.clickOnElementAll(new ConversationItem(alice1, bob.userName)); // At this point the invite should have disappeared already so we just check it's not there await alice1.verifyElementNotPresent(new CommunityInvitation(alice1)); diff --git a/run/test/specs/disappearing_gif.spec.ts b/run/test/specs/disappearing_gif.spec.ts index 812e47773..7a0a3853c 100644 --- a/run/test/specs/disappearing_gif.spec.ts +++ b/run/test/specs/disappearing_gif.spec.ts @@ -33,7 +33,7 @@ async function disappearingGifMessage1o1(platform: SupportedPlatformsType, testI focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); const sentTimestamp = await alice1.sendGIF(); await bob1.trustAttachments(USERNAME.ALICE); await Promise.all( diff --git a/run/test/specs/disappearing_image.spec.ts b/run/test/specs/disappearing_image.spec.ts index afa7d4313..cdd35573e 100644 --- a/run/test/specs/disappearing_image.spec.ts +++ b/run/test/specs/disappearing_image.spec.ts @@ -33,7 +33,7 @@ async function disappearingImageMessage1o1(platform: SupportedPlatformsType, tes focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); const sentTimestamp = await alice1.sendImage(testMessage); await bob1.trustAttachments(alice.userName); if (platform === 'ios') { diff --git a/run/test/specs/disappearing_link.spec.ts b/run/test/specs/disappearing_link.spec.ts index adf01dc06..4ac26ff1b 100644 --- a/run/test/specs/disappearing_link.spec.ts +++ b/run/test/specs/disappearing_link.spec.ts @@ -51,7 +51,7 @@ async function disappearingLinkMessage1o1Ios(platform: SupportedPlatformsType, t }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); }); await test.step(TestSteps.SEND.LINK, async () => { await alice1.inputText(testLink, new MessageInput(alice1)); @@ -105,7 +105,7 @@ async function disappearingLinkMessage1o1Android( }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time]); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); }); await test.step(TestSteps.SEND.LINK, async () => { await alice1.inputText(testLink, new MessageInput(alice1)); diff --git a/run/test/specs/disappearing_messages_defaults.spec.ts b/run/test/specs/disappearing_messages_defaults.spec.ts new file mode 100644 index 000000000..a356de22e --- /dev/null +++ b/run/test/specs/disappearing_messages_defaults.spec.ts @@ -0,0 +1,126 @@ +import type { TestInfo } from '@playwright/test'; + +import { bothPlatformsIt } from '../../types/sessionIt'; +import { DISAPPEARING_TIMES, GROUPNAME, USERNAME } from '../../types/testing'; +import { ConversationSettings } from '../locators/conversation'; +import { + DisappearingMessagesMenuOption, + DisappearingMessagesTimerType, +} from '../locators/disappearing_messages'; +import { PlusButton } from '../locators/home'; +import { EnterAccountID, NewMessageOption, NextButton } from '../locators/start_conversation'; +import { + open_Alice1_Bob1_Charlie1_friends_group, + open_Alice1_Bob1_friends, +} from '../state_builder'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; + +bothPlatformsIt({ + title: 'Disappearing messages defaults 1:1', + risk: 'medium', + testCb: disappearingMessagesDefaults1o1, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: 'Verifies the default selected timer for each DM mode in a 1:1 conversation', +}); + +bothPlatformsIt({ + title: 'Disappearing messages defaults group', + risk: 'medium', + testCb: disappearingMessagesDefaultsGroup, + countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: 'Verifies the default selected timer in a group conversation', +}); + +bothPlatformsIt({ + title: 'Disappearing messages defaults note to self', + risk: 'medium', + testCb: disappearingMessagesDefaultsNoteToSelf, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: 'Verifies the default selected timer in Note to Self', +}); + +async function disappearingMessagesDefaults1o1( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const { + devices: { alice1, bob1 }, + } = await open_Alice1_Bob1_friends({ platform, focusFriendsConvo: true, testInfo }); + + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new DisappearingMessagesMenuOption(alice1)); + + // Disappear after read: default should be 12 hours + await alice1.clickOnElementAll( + new DisappearingMessagesTimerType(alice1, 'Disappear after read option') + ); + await alice1.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.TWELVE_HOURS); + + // Disappear after send: default should be 1 day + await alice1.clickOnElementAll( + new DisappearingMessagesTimerType(alice1, 'Disappear after send option') + ); + await alice1.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.ONE_DAY); + + await closeApp(alice1, bob1); +} + +async function disappearingMessagesDefaultsGroup( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const testGroupName: GROUPNAME = 'Testing disappearing messages'; + const { + devices: { alice1, bob1, charlie1 }, + } = await open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new DisappearingMessagesMenuOption(alice1)); + + // Group defaults: disappear after send should be OFF + await alice1.onIOS().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_IOS); + await alice1.onAndroid().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_ANDROID); + + await closeApp(alice1, bob1, charlie1); +} + +async function disappearingMessagesDefaultsNoteToSelf( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const alice = await newUser(device, USERNAME.ALICE); + + await device.clickOnElementAll(new PlusButton(device)); + await device.clickOnElementAll(new NewMessageOption(device)); + await device.inputText(alice.accountID, new EnterAccountID(device)); + await device.scrollDown(); + await device.clickOnElementAll(new NextButton(device)); + + await device.clickOnElementAll(new ConversationSettings(device)); + await device.clickOnElementAll(new DisappearingMessagesMenuOption(device)); + + // Note to Self defaults: disappear after send should be OFF + await device.onIOS().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_IOS); + await device.onAndroid().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_ANDROID); + + await closeApp(device); +} diff --git a/run/test/specs/disappearing_messages_follow_settings.spec.ts b/run/test/specs/disappearing_messages_follow_settings.spec.ts new file mode 100644 index 000000000..b668ad15a --- /dev/null +++ b/run/test/specs/disappearing_messages_follow_settings.spec.ts @@ -0,0 +1,78 @@ +import type { TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { DISAPPEARING_TIMES } from '../../types/testing'; +import { MessageBody } from '../locators/conversation'; +import { + DisappearingMessagesSubtitle, + FollowSettingsButton, + SetModalButton, +} from '../locators/disappearing_messages'; +import { open_Alice1_Bob1_friends } from '../state_builder'; +import { closeApp, SupportedPlatformsType } from '../utils/open_app'; +import { setDisappearingMessage } from '../utils/set_disappearing_messages'; + +bothPlatformsIt({ + title: 'Disappearing messages follow setting 1:1', + risk: 'medium', + testCb: disappearingMessagesFollowSetting1o1, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: + 'Verifies that Bob sees the Follow Setting banner when Alice sets disappearing messages in a 1:1 conversation, and that following applies the setting to both sides', +}); + +const time = DISAPPEARING_TIMES.THIRTY_SECONDS; +const timerType = 'Disappear after send option'; +const disappearMaxWait = 35_000; // 30s plus buffer + +async function disappearingMessagesFollowSetting1o1( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const { + devices: { alice1, bob1 }, + prebuilt: { alice, bob }, + } = await open_Alice1_Bob1_friends({ platform, focusFriendsConvo: true, testInfo }); + const aliceMsg = `${alice.userName}'s messages will disappear`; + const bobMsg = `${bob.userName}'s messages will disappear`; + await setDisappearingMessage(alice1, ['1:1', timerType, time]); + // Bob should see the follow settings banner after Alice sets DM + await bob1.clickOnElementAll(new FollowSettingsButton(bob1)); + await bob1.checkModalStrings( + tStripped('disappearingMessagesFollowSetting'), + tStripped('disappearingMessagesFollowSettingOn', { + time, + disappearing_messages_type: 'sent', + }) + ); + await bob1.clickOnElementAll(new SetModalButton(bob1)); + + // Both should now show the DM subtitle + await Promise.all( + [alice1, bob1].map(device => + device.waitForTextElementToBePresent(new DisappearingMessagesSubtitle(device)) + ) + ); + const aliceTimestamp = await alice1.sendMessage(aliceMsg); + const bobTimestamp = await bob1.sendMessage(bobMsg); + await Promise.all( + [alice1, bob1].flatMap(device => [ + device.hasElementDisappeared({ + ...new MessageBody(device, aliceMsg).build(), + actualStartTime: aliceTimestamp, + maxWait: disappearMaxWait, + }), + device.hasElementDisappeared({ + ...new MessageBody(device, bobMsg).build(), + actualStartTime: bobTimestamp, + maxWait: disappearMaxWait, + }), + ]) + ); + await closeApp(alice1, bob1); +} diff --git a/run/test/specs/disappearing_video.spec.ts b/run/test/specs/disappearing_video.spec.ts index 9c81c6d41..fa3639451 100644 --- a/run/test/specs/disappearing_video.spec.ts +++ b/run/test/specs/disappearing_video.spec.ts @@ -35,7 +35,7 @@ async function disappearingVideoMessage1o1(platform: SupportedPlatformsType, tes focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); let sentTimestamp: number; if (platform === 'ios') { sentTimestamp = await alice1.onIOS().sendVideoiOS(testMessage); diff --git a/run/test/specs/disappearing_voice.spec.ts b/run/test/specs/disappearing_voice.spec.ts index ba6718a82..26c218904 100644 --- a/run/test/specs/disappearing_voice.spec.ts +++ b/run/test/specs/disappearing_voice.spec.ts @@ -32,7 +32,7 @@ async function disappearingVoiceMessage1o1(platform: SupportedPlatformsType, tes focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); const sentTimestamp = await alice1.sendVoiceMessage(); await bob1.trustAttachments(alice.userName); await Promise.all( diff --git a/run/test/specs/donate.spec.ts b/run/test/specs/donate.spec.ts index 31bda2eea..65ae1ea41 100644 --- a/run/test/specs/donate.spec.ts +++ b/run/test/specs/donate.spec.ts @@ -9,7 +9,7 @@ import { DonationsMenuItem, UserSettings } from '../locators/settings'; import { newUser } from '../utils/create_account'; import { handleChromeFirstTimeOpen } from '../utils/handle_first_open'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; -import { assertUrlIsReachable, ensureHttpsURL } from '../utils/utilities'; +import { assertUrlIsReachable, ensureHttpsURL, verify } from '../utils/utilities'; bothPlatformsIt({ title: 'Donate Settings menu item', @@ -25,7 +25,7 @@ bothPlatformsIt({ async function donateLinkout(platform: SupportedPlatformsType, testInfo: TestInfo) { const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); - const linkURL = 'https://getsession.org/donate#app'; + const linkURL = 'https://getsession.org/donate'; await newUser(device, USERNAME.ALICE, { saveUserData: false }); await device.clickOnElementAll(new UserSettings(device)); await device.clickOnElementAll(new DonationsMenuItem(device)); @@ -45,11 +45,7 @@ async function donateLinkout(platform: SupportedPlatformsType, testInfo: TestInf const actualUrlField = await device.getTextFromElement(urlField); const fullRetrievedURL = ensureHttpsURL(actualUrlField); // Verify that it's the correct URL - if (fullRetrievedURL !== linkURL) { - throw new Error( - `The retrieved URL does not match the expected. The retrieved URL is ${fullRetrievedURL}` - ); - } + verify(fullRetrievedURL, 'The retrieved URL does not match the expected').toBe(linkURL); await assertUrlIsReachable(linkURL); // Close browser and app await device.backToSession(); diff --git a/run/test/specs/group_disappearing_messages_gif.spec.ts b/run/test/specs/group_disappearing_messages_gif.spec.ts index 9c9c70eef..cec83e9df 100644 --- a/run/test/specs/group_disappearing_messages_gif.spec.ts +++ b/run/test/specs/group_disappearing_messages_gif.spec.ts @@ -35,7 +35,7 @@ async function disappearingGifMessageGroup(platform: SupportedPlatformsType, tes focusGroupConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); // Click on attachments button const sentTimestamp = await alice1.sendGIF(); await Promise.all( diff --git a/run/test/specs/group_disappearing_messages_image.spec.ts b/run/test/specs/group_disappearing_messages_image.spec.ts index 47ffbddf6..762bced73 100644 --- a/run/test/specs/group_disappearing_messages_image.spec.ts +++ b/run/test/specs/group_disappearing_messages_image.spec.ts @@ -34,7 +34,7 @@ async function disappearingImageMessageGroup(platform: SupportedPlatformsType, t testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); const sentTimestamp = await alice1.sendImage(testMessage); if (platform === 'ios') { await Promise.all( diff --git a/run/test/specs/group_disappearing_messages_link.spec.ts b/run/test/specs/group_disappearing_messages_link.spec.ts index 226834583..ec9ef96f9 100644 --- a/run/test/specs/group_disappearing_messages_link.spec.ts +++ b/run/test/specs/group_disappearing_messages_link.spec.ts @@ -47,7 +47,7 @@ async function disappearingLinkMessageGroup(platform: SupportedPlatformsType, te }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); }); await test.step(TestSteps.SEND.LINK, async () => { await alice1.inputText(testLink, new MessageInput(alice1)); diff --git a/run/test/specs/group_disappearing_messages_video.spec.ts b/run/test/specs/group_disappearing_messages_video.spec.ts index 20d3d048c..1121e05a5 100644 --- a/run/test/specs/group_disappearing_messages_video.spec.ts +++ b/run/test/specs/group_disappearing_messages_video.spec.ts @@ -36,7 +36,7 @@ async function disappearingVideoMessageGroup(platform: SupportedPlatformsType, t focusGroupConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); let sentTimestamp: number; if (platform === 'ios') { sentTimestamp = await alice1.sendVideoiOS(testMessage); diff --git a/run/test/specs/group_disappearing_messages_voice.spec.ts b/run/test/specs/group_disappearing_messages_voice.spec.ts index 25862d0a7..b933dcc83 100644 --- a/run/test/specs/group_disappearing_messages_voice.spec.ts +++ b/run/test/specs/group_disappearing_messages_voice.spec.ts @@ -31,7 +31,7 @@ async function disappearingVoiceMessageGroup(platform: SupportedPlatformsType, t focusGroupConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); const sentTimestamp = await alice1.sendVoiceMessage(); await Promise.all( [bob1, charlie1].map(device => device.onAndroid().trustAttachments(testGroupName)) diff --git a/run/test/specs/group_message_voice.spec.ts b/run/test/specs/group_message_voice.spec.ts index 028cd5ffd..313bcf939 100644 --- a/run/test/specs/group_message_voice.spec.ts +++ b/run/test/specs/group_message_voice.spec.ts @@ -16,7 +16,7 @@ bothPlatformsIt({ suite: 'Message types', }, allureDescription: - 'Verifies that a voice message can be sent to a group, all members receive the document, and replying to a document works as expected', + 'Verifies that a voice message can be sent to a group, all members receive it, and replying to it works as expected', }); async function sendVoiceMessageGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -40,7 +40,9 @@ async function sendVoiceMessageGroup(platform: SupportedPlatformsType, testInfo: device.waitForTextElementToBePresent(new VoiceMessage(device)) ) ); - await bob1.longPressMessage(new VoiceMessage(bob1)); + // The voice message long tap must be offset so that it doesn't tap the scrubber + // As this starts playback and does not open the long press menu + await bob1.longPressMessage(new VoiceMessage(bob1), { offset: { x: 0, y: 100 } }); await bob1.clickOnByAccessibilityID('Reply to message'); await sleepFor(500); // Let the UI settle before finding message input and typing await bob1.sendMessage(replyMessage); diff --git a/run/test/specs/group_tests_add_accountid.spec.ts b/run/test/specs/group_tests_add_accountid.spec.ts new file mode 100644 index 000000000..283da19a0 --- /dev/null +++ b/run/test/specs/group_tests_add_accountid.spec.ts @@ -0,0 +1,117 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { InviteAccountIDOrONS } from '../locators'; +import { + AcceptMessageRequestButton, + ConversationSettings, + MessageBody, +} from '../locators/conversation'; +import { + InviteContactSendInviteButton, + ManageMembersMenuItem, + ShareNewMessagesRadial, +} from '../locators/groups'; +import { MessageRequestItem, MessageRequestsBanner } from '../locators/home'; +import { EnterAccountID, NextButton } from '../locators/start_conversation'; +import { open_Alice1_Bob1_Charlie1_Unknown1 } from '../state_builder'; +import { sleepFor } from '../utils'; +import { newUser } from '../utils/create_account'; +import { truncatePubkey } from '../utils/get_account_id'; +import { closeApp, SupportedPlatformsType } from '../utils/open_app'; + +androidIt({ + title: 'Invite Account ID to group', + risk: 'high', + testCb: addAccountIDToGroup, + countOfDevicesNeeded: 4, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: + 'Verifies that inviting a non-contact Account ID (without chat history) works as expected.', +}); + +async function addAccountIDToGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Group to test adding contact'; + const { + devices: { alice1, bob1, charlie1, unknown1 }, + prebuilt: { alice }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_Unknown1({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo: testInfo, + }); + }); + const userD = await test.step(TestSteps.SETUP.NEW_USER, async () => { + return newUser(unknown1, USERNAME.DRACULA); + }); + const aliceTruncatedPubkey = truncatePubkey(alice.accountID, platform); + const historicMsg = `Hello from ${alice.userName}`; + const userDTruncatedPubkey = truncatePubkey(userD.accountID, platform); + const userDMsg = `Hello from ${userD.userName}`; + await test.step(TestSteps.SEND.MESSAGE(alice.userName, 'group'), async () => { + await alice1.sendMessage(historicMsg); + await Promise.all( + [alice1, bob1, charlie1].map(device => + device.waitForTextElementToBePresent(new MessageBody(device, historicMsg)) + ) + ); + }); + await test.step(TestSteps.USER_ACTIONS.GROUPS_ADD_CONTACT(userD.userName), async () => { + // Click more options + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + // Select edit group + await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); + await sleepFor(1000); + // Add contact to group + await alice1.clickOnElementAll(new InviteAccountIDOrONS(alice1)); + await alice1.inputText(userD.accountID, new EnterAccountID(alice1)); + await alice1.clickOnElementAll(new NextButton(alice1)); + await alice1.clickOnElementAll(new ShareNewMessagesRadial(alice1)); + await alice1.clickOnElementAll(new InviteContactSendInviteButton(alice1)); + }); + // Leave Manage Members + await alice1.navigateBack(); + // Leave Conversation Settings + await alice1.navigateBack(); + // Check control messages + await test.step('Verify group invite control message for all members', async () => { + await Promise.all( + [alice1, bob1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('groupMemberNew', { name: userDTruncatedPubkey }), + 20_000 + ) + ) + ); + }); + await test.step(`${userD.userName} accepts group invite and sends a message`, async () => { + await unknown1.clickOnElementAll(new MessageRequestsBanner(unknown1)); + await unknown1.clickOnElementAll(new MessageRequestItem(unknown1)); + await unknown1.waitForControlMessageToBePresent( + tStripped('messageRequestGroupInvite', { + name: aliceTruncatedPubkey, + group_name: testGroupName, + }) + ); + await unknown1.clickOnElementAll(new AcceptMessageRequestButton(unknown1)); + await unknown1.waitForControlMessageToBePresent(tStripped('groupInviteYou')); + await unknown1.verifyElementNotPresent(new MessageBody(unknown1, historicMsg)); + await unknown1.sendMessage(userDMsg); + await Promise.all( + [alice1, bob1, charlie1, unknown1].map(device => + device.waitForTextElementToBePresent(new MessageBody(device, userDMsg)) + ) + ); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1, unknown1); + }); +} diff --git a/run/test/specs/group_tests_add_contact.spec.ts b/run/test/specs/group_tests_add_contact.spec.ts index 17d8ca6fe..7b803bb57 100644 --- a/run/test/specs/group_tests_add_contact.spec.ts +++ b/run/test/specs/group_tests_add_contact.spec.ts @@ -1,12 +1,18 @@ -import type { TestInfo } from '@playwright/test'; +import { test, type TestInfo } from '@playwright/test'; import { tStripped } from '../../localizer/lib'; -import { bothPlatformsIt } from '../../types/sessionIt'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { InviteContactsButton, InviteContactsMenuItem } from '../locators'; -import { ConversationSettings } from '../locators/conversation'; +import { InviteContactsMenuItem } from '../locators'; +import { ConversationSettings, MessageBody } from '../locators/conversation'; import { Contact } from '../locators/global'; -import { InviteContactConfirm, ManageMembersMenuItem } from '../locators/groups'; +import { + InviteContactConfirm, + InviteContactSendInviteButton, + ManageMembersMenuItem, + ShareMessageHistoryRadial, +} from '../locators/groups'; import { ConversationItem } from '../locators/home'; import { open_Alice1_Bob1_Charlie1_Unknown1 } from '../state_builder'; import { sleepFor } from '../utils'; @@ -14,66 +20,91 @@ import { newUser } from '../utils/create_account'; import { newContact } from '../utils/create_contact'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; -bothPlatformsIt({ - title: 'Add contact to group', +androidIt({ + title: 'Invite contact to group with chat history', risk: 'high', - testCb: addContactToGroup, + testCb: addContactToGroupHistory, countOfDevicesNeeded: 4, allureSuites: { parent: 'Groups', suite: 'Edit Group', }, - allureDescription: 'Create four accounts, create a group with three, add the fourth member', + allureDescription: + 'Verifies that inviting a contact to a group with message history works as expected.', }); -async function addContactToGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { - const testGroupName = 'Group to test adding contact'; + +async function addContactToGroupHistory(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Test group'; const { devices: { alice1, bob1, charlie1, unknown1 }, prebuilt: { alice, group }, - } = await open_Alice1_Bob1_Charlie1_Unknown1({ - platform, - groupName: testGroupName, - focusGroupConvo: true, - testInfo: testInfo, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_Unknown1({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo: testInfo, + }); + }); + const historicMsg = `Hello from ${alice.userName}`; + await test.step(TestSteps.SEND.MESSAGE(alice.userName, testGroupName), async () => { + await alice1.sendMessage(historicMsg); + await Promise.all( + [alice1, bob1, charlie1].map(device => + device.waitForTextElementToBePresent(new MessageBody(device, historicMsg)) + ) + ); + }); + const userD = await test.step(TestSteps.SETUP.NEW_USER, async () => { + return newUser(unknown1, USERNAME.DRACULA); }); - const userD = await newUser(unknown1, USERNAME.DRACULA); - await alice1.navigateBack(); - await newContact(platform, alice1, alice, unknown1, userD); - // Exit to conversation list - await alice1.navigateBack(); - // Select group conversation in list - await alice1.clickOnElementAll(new ConversationItem(alice1, testGroupName)); - // Click more options - await alice1.clickOnElementAll(new ConversationSettings(alice1)); - // Select edit group - await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); - await sleepFor(1000); - // Add contact to group - await alice1.onIOS().clickOnElementAll(new InviteContactsMenuItem(alice1)); - await alice1.onAndroid().clickOnElementAll(new InviteContactsButton(alice1)); - // Select new user - await alice1.clickOnElementAll({ - ...new Contact(alice1).build(), - text: USERNAME.DRACULA, + await test.step(TestSteps.SEND.MESSAGE(alice.userName, userD.userName), async () => { + await alice1.navigateBack(); + await newContact(platform, alice1, alice, unknown1, userD); + // Exit to conversation list + await alice1.navigateBack(); }); - await alice1.clickOnElementAll(new InviteContactConfirm(alice1)); - // Leave Manage Members - await alice1.navigateBack(); - // Leave Conversation Settings - await alice1.navigateBack(); - // Check control messages - await Promise.all( - [alice1, bob1, charlie1].map(device => - device.waitForControlMessageToBePresent( - tStripped('groupMemberNew', { name: USERNAME.DRACULA }) + await test.step(`${alice.userName} invites ${userD.userName} to the group (with message history)`, async () => { + // Select group conversation in list + await alice1.clickOnElementAll(new ConversationItem(alice1, testGroupName)); + // Click more options + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + // Select edit group + await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); + await sleepFor(1000); + // Add contact to group + await alice1.clickOnElementAll(new InviteContactsMenuItem(alice1)); + // Select new user + await alice1.clickOnElementAll({ + ...new Contact(alice1).build(), + text: USERNAME.DRACULA, + }); + await alice1.clickOnElementAll(new InviteContactConfirm(alice1)); + await alice1.clickOnElementAll(new ShareMessageHistoryRadial(alice1)); + await alice1.clickOnElementAll(new InviteContactSendInviteButton(alice1)); + }); + await test.step(`Verify ${userD.userName} becomes a fully-fledged member and sees historic messages`, async () => { + // Leave Manage Members + await alice1.navigateBack(); + // Leave Conversation Settings + await alice1.navigateBack(); + // Check control messages + await Promise.all( + [alice1, bob1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('groupMemberInvitedHistory', { name: USERNAME.DRACULA }) + ) ) - ) - ); - // Leave conversation - await unknown1.navigateBack(); - // Leave Message Requests screen (Android) - await unknown1.onAndroid().navigateBack(); - await unknown1.clickOnElementAll(new ConversationItem(unknown1, group.groupName)); // Check for control message on device 4 - await unknown1.waitForControlMessageToBePresent(tStripped('groupInviteYou')); - await closeApp(alice1, bob1, charlie1, unknown1); + ); + // Leave conversation + await unknown1.navigateBack(); + // Leave Message Requests screen + await unknown1.navigateBack(); + await unknown1.clickOnElementAll(new ConversationItem(unknown1, group.groupName)); // Check for control message on device 4 + await unknown1.waitForTextElementToBePresent(new MessageBody(unknown1, historicMsg)); + await unknown1.waitForControlMessageToBePresent(tStripped('groupInviteYouHistory')); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1, unknown1); + }); } diff --git a/run/test/specs/group_tests_add_contact_nohistory.spec.ts b/run/test/specs/group_tests_add_contact_nohistory.spec.ts new file mode 100644 index 000000000..a93ff6edd --- /dev/null +++ b/run/test/specs/group_tests_add_contact_nohistory.spec.ts @@ -0,0 +1,112 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { InviteContactsMenuItem } from '../locators'; +import { ConversationSettings, MessageBody } from '../locators/conversation'; +import { Contact } from '../locators/global'; +import { + InviteContactConfirm, + InviteContactSendInviteButton, + ManageMembersMenuItem, + ShareNewMessagesRadial, +} from '../locators/groups'; +import { ConversationItem } from '../locators/home'; +import { open_Alice1_Bob1_Charlie1_Unknown1 } from '../state_builder'; +import { sleepFor } from '../utils'; +import { newUser } from '../utils/create_account'; +import { newContact } from '../utils/create_contact'; +import { closeApp, SupportedPlatformsType } from '../utils/open_app'; + +bothPlatformsIt({ + title: 'Invite contact to group without chat history', + risk: 'high', + testCb: addContactToGroupNoHistory, + countOfDevicesNeeded: 4, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: + 'Verifies that inviting a contact (Android: without chat history) works as expected.', +}); + +async function addContactToGroupNoHistory(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Test group'; + const { + devices: { alice1, bob1, charlie1, unknown1 }, + prebuilt: { alice, group }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_Unknown1({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo: testInfo, + }); + }); + const historicMsg = `Hello from ${alice.userName}`; + await test.step(TestSteps.SEND.MESSAGE(alice.userName, testGroupName), async () => { + await alice1.sendMessage(historicMsg); + await Promise.all( + [alice1, bob1, charlie1].map(device => + device.waitForTextElementToBePresent(new MessageBody(device, historicMsg)) + ) + ); + }); + const userD = await test.step(TestSteps.SETUP.NEW_USER, async () => { + return newUser(unknown1, USERNAME.DRACULA); + }); + await test.step(TestSteps.SEND.MESSAGE(alice.userName, userD.userName), async () => { + await alice1.navigateBack(); + await newContact(platform, alice1, alice, unknown1, userD); + // Exit to conversation list + await alice1.navigateBack(); + }); + await test.step(`${alice.userName} invites ${userD.userName} to the group (without message history)`, async () => { + // Select group conversation in list + await alice1.clickOnElementAll(new ConversationItem(alice1, testGroupName)); + // Click more options + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + // Select edit group + await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); + await sleepFor(1000); + // Add contact to group + await alice1.clickOnElementAll(new InviteContactsMenuItem(alice1)); + // Select new user + await alice1.clickOnElementAll({ + ...new Contact(alice1).build(), + text: USERNAME.DRACULA, + }); + await alice1.clickOnElementAll(new InviteContactConfirm(alice1)); + if (platform === 'android') { + await alice1.clickOnElementAll(new ShareNewMessagesRadial(alice1)); + await alice1.clickOnElementAll(new InviteContactSendInviteButton(alice1)); + } + }); + await test.step(`Verify ${userD.userName} becomes a fully-fledged member and doesn't see historic messages`, async () => { + // Leave Manage Members + await alice1.navigateBack(); + // Leave Conversation Settings + await alice1.navigateBack(); + // Check control messages + await Promise.all( + [alice1, bob1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('groupMemberNew', { name: USERNAME.DRACULA }) + ) + ) + ); + // Leave conversation + await unknown1.navigateBack(); + // Leave Message Requests screen (Android) + await unknown1.onAndroid().navigateBack(); + await unknown1.clickOnElementAll(new ConversationItem(unknown1, group.groupName)); // Check for control message on device 4 + await unknown1.verifyElementNotPresent(new MessageBody(unknown1, historicMsg)); + await unknown1.waitForControlMessageToBePresent(tStripped('groupInviteYou')); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1, unknown1); + }); +} diff --git a/run/test/specs/group_tests_admin_leave_group.spec.ts b/run/test/specs/group_tests_admin_leave_group.spec.ts new file mode 100644 index 000000000..797128436 --- /dev/null +++ b/run/test/specs/group_tests_admin_leave_group.spec.ts @@ -0,0 +1,192 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { ConversationSettings, EmptyConversation, MessageBody } from '../locators/conversation'; +import { Contact } from '../locators/global'; +import { + ConfirmPromotionModalButton, + DeleteGroupConfirm, + LeaveGroupCancel, + LeaveGroupConfirm, + LeaveGroupMenuItem, + ManageAdminsMenuItem, + MemberStatus, + PromoteMemberFooterButton, + PromoteMemberModalConfirm, + PromoteMembersMenuItem, +} from '../locators/groups'; +import { ConversationItem, PlusButton } from '../locators/home'; +import { open_Alice1_Bob1_Charlie1_friends_group } from '../state_builder'; +import { sleepFor } from '../utils'; +import { closeApp, SupportedPlatformsType } from '../utils/open_app'; + +androidIt({ + title: 'Leave group as the only admin', + risk: 'high', + testCb: soloAdminLeave, + countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Leave/Delete Group', + }, + allureDescription: + "Verifies that a solo admin can't leave a group but is instead prompted to add admins or delete the group.", +}); + +androidIt({ + title: 'Leave group with more than one admin', + risk: 'medium', + testCb: multiAdminLeave, + countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Leave/Delete Group', + }, + allureDescription: + 'Verifies that an admin can leave a group if there is more than one admin in the group.', +}); + +async function soloAdminLeave(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Leave group'; + const { + devices: { alice1, bob1, charlie1 }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + }); + await test.step('Admin attempts to leave group', async () => { + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new LeaveGroupMenuItem(alice1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Leave Group'), async () => { + await alice1.checkModalStrings( + tStripped('groupLeave'), + tStripped('groupOnlyAdminLeave', { group_name: testGroupName }) + ); + // Seems like this modal still has the leave group qa-tags so we're making sure they're the right text + await alice1.waitForTextElementToBePresent({ + ...new LeaveGroupConfirm(alice1).build(), + text: tStripped('addAdmin', { count: 1 }), + }); + await alice1.waitForTextElementToBePresent({ + ...new LeaveGroupCancel(alice1).build(), + text: tStripped('groupDelete'), + }); + }); + await alice1.clickOnElementAll(new LeaveGroupCancel(alice1)); + }); + await test.step('Admin deletes group from Leave Group modal', async () => { + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Delete Group'), async () => { + await alice1.checkModalStrings( + tStripped('groupDelete'), + tStripped('groupDeleteDescription', { group_name: testGroupName }) + ); + }); + await alice1.clickOnElementAll(new DeleteGroupConfirm(alice1)); + }); + await test.step(TestSteps.VERIFY.GROUP_DELETED, async () => { + // Android uses the empty state for this "control message" + await Promise.all( + [bob1, charlie1].map(device => + device.waitForTextElementToBePresent({ + ...new EmptyConversation(device).build(), + text: tStripped('groupDeletedMemberDescription', { group_name: testGroupName }), + }) + ) + ); + await alice1.waitForTextElementToBePresent(new PlusButton(alice1)); // Ensure we're on the home screen + await alice1.verifyElementNotPresent(new ConversationItem(alice1, testGroupName).build()); + }); + + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1); + }); +} + +async function multiAdminLeave(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Test group'; + const { + devices: { alice1, bob1, charlie1 }, + prebuilt: { alice, bob }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + }); + const promoteMsg = `Gonna promote ${bob.userName} now`; + await alice1.sendMessage(promoteMsg); + await Promise.all( + [alice1, bob1, charlie1].map(device => + device.waitForTextElementToBePresent(new MessageBody(device, promoteMsg).build()) + ) + ); + await test.step(`${alice.userName} promotes ${bob.userName}`, async () => { + // Navigate to Manage Admins screen + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageAdminsMenuItem(alice1)); + await alice1.clickOnElementAll(new PromoteMembersMenuItem(alice1)); + await alice1.clickOnElementAll(new Contact(alice1, 'Bob')); + await alice1.clickOnElementAll(new PromoteMemberFooterButton(alice1)); + await alice1.clickOnElementAll(new PromoteMemberModalConfirm(alice1)); + await alice1.clickOnElementAll(new ConfirmPromotionModalButton(alice1)); + // This is not tied to Bob but they're the only admin this status can apply to + await alice1.waitForTextElementToBePresent( + new MemberStatus(alice1).build(tStripped('adminPromotionSent')) + ); + }); + await alice1.navigateBack(); + await alice1.navigateBack(); + // SES-5178 + await test.step('Verify every member sees the promotion control message', async () => { + await Promise.all( + [alice1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('adminPromotedToAdmin', { name: bob.userName }), + 30_000 + ) + ) + ); + await bob1.waitForControlMessageToBePresent(tStripped('groupPromotedYou')); + }); + await test.step('Verify promotion status is correct', async () => { + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageAdminsMenuItem(alice1)); + await alice1.verifyElementNotPresent( + new MemberStatus(alice1).build(tStripped('adminPromotionSent')) + ); + await sleepFor(1_000); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Leave Group'), async () => { + await alice1.navigateBack(); + await alice1.clickOnElementAll(new LeaveGroupMenuItem(alice1)); + await alice1.checkModalStrings( + tStripped('groupLeave'), + tStripped('groupLeaveDescription', { group_name: testGroupName }) + ); + }); + await test.step(`${alice.userName} leaves the group`, async () => { + await alice1.clickOnElementAll(new LeaveGroupConfirm(alice1)); + + await Promise.all( + [bob1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('groupMemberLeft', { name: alice.userName }), + 30_000 + ) + ) + ); + await alice1.waitForTextElementToBePresent(new PlusButton(alice1)); + await alice1.verifyElementNotPresent(new ConversationItem(alice1, testGroupName).build()); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1); + }); +} diff --git a/run/test/specs/group_tests_create_group_banner.spec.ts b/run/test/specs/group_tests_create_group_banner.spec.ts index 9d842c8a7..9075a312f 100644 --- a/run/test/specs/group_tests_create_group_banner.spec.ts +++ b/run/test/specs/group_tests_create_group_banner.spec.ts @@ -1,14 +1,13 @@ import type { TestInfo } from '@playwright/test'; -import { androidIt } from '../../types/sessionIt'; +import { bothPlatformsIt } from '../../types/sessionIt'; import { LatestReleaseBanner } from '../locators/groups'; import { PlusButton } from '../locators/home'; import { CreateGroupOption } from '../locators/start_conversation'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; -// This banner no longer exists on iOS -androidIt({ +bothPlatformsIt({ title: 'Create group banner', risk: 'high', testCb: createGroupBanner, @@ -18,7 +17,7 @@ androidIt({ suite: 'Create Group', }, allureDescription: - 'Verifies that the latest release banner is present on the Create Group screen', + 'Verifies that the latest release banner is no longer present on the Create Group screen', }); async function createGroupBanner(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -33,7 +32,7 @@ async function createGroupBanner(platform: SupportedPlatformsType, testInfo: Tes // Open the Create Group screen from home await alice1.clickOnElementAll(new PlusButton(alice1)); await alice1.clickOnElementAll(new CreateGroupOption(alice1)); - // Verify the banner is present - await alice1.waitForTextElementToBePresent(new LatestReleaseBanner(alice1)); + // Verify the banner is not present + await alice1.verifyElementNotPresent(new LatestReleaseBanner(alice1)); await closeApp(alice1, bob1); } diff --git a/run/test/specs/group_tests_delete_group.spec.ts b/run/test/specs/group_tests_delete_group.spec.ts index 1ec3ce4ec..d57b5b26d 100644 --- a/run/test/specs/group_tests_delete_group.spec.ts +++ b/run/test/specs/group_tests_delete_group.spec.ts @@ -3,7 +3,7 @@ import { test, type TestInfo } from '@playwright/test'; import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; -import { ConversationSettings } from '../locators/conversation'; +import { ConversationSettings, EmptyConversation } from '../locators/conversation'; import { DeleteGroupConfirm, DeleteGroupMenuItem } from '../locators/groups'; import { ConversationItem, PlusButton } from '../locators/home'; import { open_Alice2_Bob1_Charlie1_friends_group } from '../state_builder'; @@ -20,6 +20,9 @@ bothPlatformsIt({ }, allureDescription: `Verifies that an admin can delete a group successfully via the UI. The group members see the empty state control message, and the admin's conversation disappears from the home screen, even on a linked device.`, + allureLinks: { + android: 'SES-4883', + }, }); async function deleteGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -45,7 +48,7 @@ async function deleteGroup(platform: SupportedPlatformsType, testInfo: TestInfo) }); await alice1.clickOnElementAll(new DeleteGroupConfirm(alice1)); }); - await test.step('Verify group is deleted for all members', async () => { + await test.step(TestSteps.VERIFY.GROUP_DELETED, async () => { // Members if (platform === 'ios') { await Promise.all( @@ -60,8 +63,7 @@ async function deleteGroup(platform: SupportedPlatformsType, testInfo: TestInfo) await Promise.all( [bob1, charlie1].map(device => device.waitForTextElementToBePresent({ - strategy: 'accessibility id', - selector: 'Empty list', + ...new EmptyConversation(device).build(), text: tStripped('groupDeletedMemberDescription', { group_name: testGroupName, }), diff --git a/run/test/specs/group_tests_edit_group_banner.spec.ts b/run/test/specs/group_tests_edit_group_banner.spec.ts index 25b295f95..9d9147e98 100644 --- a/run/test/specs/group_tests_edit_group_banner.spec.ts +++ b/run/test/specs/group_tests_edit_group_banner.spec.ts @@ -1,13 +1,12 @@ import type { TestInfo } from '@playwright/test'; -import { androidIt } from '../../types/sessionIt'; +import { bothPlatformsIt } from '../../types/sessionIt'; import { ConversationSettings } from '../locators/conversation'; import { LatestReleaseBanner, ManageMembersMenuItem } from '../locators/groups'; import { open_Alice1_Bob1_Charlie1_friends_group } from '../state_builder'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; -// This banner no longer exists on iOS -androidIt({ +bothPlatformsIt({ title: 'Edit group banner', risk: 'medium', testCb: editGroupBanner, @@ -16,7 +15,8 @@ androidIt({ parent: 'Groups', suite: 'Edit Group', }, - allureDescription: 'Verifies that the latest release banner is present on the Edit Group screen', + allureDescription: + 'Verifies that the latest release banner is no longer present on the Edit Group screen', }); async function editGroupBanner(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -33,6 +33,6 @@ async function editGroupBanner(platform: SupportedPlatformsType, testInfo: TestI // Navigate to Edit Group screen await alice1.clickOnElementAll(new ConversationSettings(alice1)); await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); - await alice1.waitForTextElementToBePresent(new LatestReleaseBanner(alice1)); + await alice1.verifyElementNotPresent(new LatestReleaseBanner(alice1)); await closeApp(alice1, bob1, charlie1); } diff --git a/run/test/specs/group_tests_invite_contact_banner.spec.ts b/run/test/specs/group_tests_invite_contact_banner.spec.ts index 0ca8c6841..75d18f0f2 100644 --- a/run/test/specs/group_tests_invite_contact_banner.spec.ts +++ b/run/test/specs/group_tests_invite_contact_banner.spec.ts @@ -1,14 +1,13 @@ import type { TestInfo } from '@playwright/test'; -import { androidIt } from '../../types/sessionIt'; -import { InviteContactsButton } from '../locators'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { InviteContactsMenuItem } from '../locators'; import { ConversationSettings } from '../locators/conversation'; import { LatestReleaseBanner, ManageMembersMenuItem } from '../locators/groups'; import { open_Alice1_Bob1_Charlie1_friends_group } from '../state_builder'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; -// This banner no longer exists on iOS -androidIt({ +bothPlatformsIt({ title: 'Invite contacts banner', risk: 'medium', testCb: inviteContactGroupBanner, @@ -35,7 +34,7 @@ async function inviteContactGroupBanner(platform: SupportedPlatformsType, testIn // Navigate to Invite Contacts screen await alice1.clickOnElementAll(new ConversationSettings(alice1)); await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); - await alice1.clickOnElementAll(new InviteContactsButton(alice1)); - await alice1.waitForTextElementToBePresent(new LatestReleaseBanner(alice1)); + await alice1.clickOnElementAll(new InviteContactsMenuItem(alice1)); + await alice1.verifyElementNotPresent(new LatestReleaseBanner(alice1)); await closeApp(alice1, bob1, charlie1); } diff --git a/run/test/specs/group_tests_kick_member.spec.ts b/run/test/specs/group_tests_kick_member.spec.ts index 6c7074670..4a21bae13 100644 --- a/run/test/specs/group_tests_kick_member.spec.ts +++ b/run/test/specs/group_tests_kick_member.spec.ts @@ -1,9 +1,10 @@ -import type { TestInfo } from '@playwright/test'; +import { test, type TestInfo } from '@playwright/test'; import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { ConversationSettings, MessageInput } from '../locators/conversation'; +import { ConversationSettings, EmptyConversation, MessageInput } from '../locators/conversation'; import { ConfirmRemovalButton, GroupMember, @@ -11,7 +12,7 @@ import { RemoveMemberButton, } from '../locators/groups'; import { open_Alice1_Bob1_Charlie1_friends_group } from '../state_builder'; -import { SupportedPlatformsType } from '../utils/open_app'; +import { closeApp, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ title: 'Kick member', @@ -28,48 +29,53 @@ bothPlatformsIt({ async function kickMember(platform: SupportedPlatformsType, testInfo: TestInfo) { const testGroupName = 'Kick member'; - const { devices: { alice1, bob1, charlie1 }, - } = await open_Alice1_Bob1_Charlie1_friends_group({ - platform, - groupName: testGroupName, - focusGroupConvo: true, - testInfo, + prebuilt: { bob }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); }); - await alice1.clickOnElementAll(new ConversationSettings(alice1)); - await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); - await alice1.clickOnElementAll({ ...new GroupMember(alice1).build(USERNAME.BOB) }); - await alice1.clickOnElementAll(new RemoveMemberButton(alice1)); - await alice1.checkModalStrings( - tStripped('remove'), - tStripped('groupRemoveDescription', { - name: USERNAME.BOB, - group_name: testGroupName, - }) - ); - await alice1.clickOnElementAll(new ConfirmRemovalButton(alice1)); - // The Group Member element sometimes disappears slowly, sometimes quickly. - // hasElementBeenDeleted would be theoretically better but we just check if element is not there anymore - await alice1.verifyElementNotPresent({ - ...new GroupMember(alice1).build(USERNAME.BOB), - maxWait: 5_000, + await test.step(TestSteps.USER_ACTIONS.GROUPS_REMOVE_MEMBER(bob.userName), async () => { + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); + await alice1.clickOnElementAll({ ...new GroupMember(alice1).build(USERNAME.BOB) }); + await alice1.clickOnElementAll(new RemoveMemberButton(alice1)); + await alice1.checkModalStrings( + tStripped('remove'), + tStripped('groupRemoveDescription', { + name: USERNAME.BOB, + group_name: testGroupName, + }) + ); + await alice1.clickOnElementAll(new ConfirmRemovalButton(alice1)); + // The Group Member element sometimes disappears slowly, sometimes quickly. + // hasElementBeenDeleted would be theoretically better but we just check if element is not there anymore + await alice1.verifyElementNotPresent({ + ...new GroupMember(alice1).build(USERNAME.BOB), + maxWait: 5_000, + }); }); await alice1.navigateBack(); await alice1.navigateBack(); - await Promise.all([ - alice1.waitForControlMessageToBePresent(tStripped('groupRemoved', { name: USERNAME.BOB })), - charlie1.waitForControlMessageToBePresent(tStripped('groupRemoved', { name: USERNAME.BOB })), - ]); - await bob1.onAndroid().waitForTextElementToBePresent({ - strategy: 'accessibility id', - selector: 'Empty list', - text: tStripped('groupRemovedYou', { group_name: testGroupName }), + await test.step(`Verify ${bob.userName} has been kicked`, async () => { + await Promise.all([ + alice1.waitForControlMessageToBePresent(tStripped('groupRemoved', { name: USERNAME.BOB })), + charlie1.waitForControlMessageToBePresent(tStripped('groupRemoved', { name: USERNAME.BOB })), + ]); + await bob1.onAndroid().waitForTextElementToBePresent({ + ...new EmptyConversation(bob1).build(), + text: tStripped('groupRemovedYou', { group_name: testGroupName }), + }); + await bob1.onIOS().waitForTextElementToBePresent(new EmptyConversation(bob1)); + // Message input should not be present after being kicked + await bob1.verifyElementNotPresent({ ...new MessageInput(bob1).build(), maxWait: 1000 }); }); - await bob1.onIOS().waitForTextElementToBePresent({ - strategy: 'accessibility id', - selector: 'Empty list', + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1); }); - // Message input should not be present after being kicked - await bob1.verifyElementNotPresent({ ...new MessageInput(bob1).build(), maxWait: 1000 }); } diff --git a/run/test/specs/group_tests_kick_member_messages.spec.ts b/run/test/specs/group_tests_kick_member_messages.spec.ts new file mode 100644 index 000000000..e258e2603 --- /dev/null +++ b/run/test/specs/group_tests_kick_member_messages.spec.ts @@ -0,0 +1,113 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { + ConversationSettings, + DeletedMessage, + EmptyConversation, + MessageBody, + MessageInput, +} from '../locators/conversation'; +import { + ConfirmRemovalButton, + GroupMember, + ManageMembersMenuItem, + RemoveMemberButton, + RemoveMemberMessagesRadial, +} from '../locators/groups'; +import { open_Alice1_Bob1_Charlie1_friends_group } from '../state_builder'; +import { closeApp, SupportedPlatformsType } from '../utils/open_app'; + +// This functionality only exists on Android at the moment +androidIt({ + title: 'Kick and remove messages', + risk: 'medium', + testCb: kickMemberDeleteMsg, + countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: + 'Verifies that a group member can be kicked from a group and that the kicked member is removed from the group (with their messages deleted).', +}); + +async function kickMemberDeleteMsg(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Kick member'; + const { + devices: { alice1, bob1, charlie1 }, + prebuilt: { alice, bob }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + }); + const aliceMsg = `Hello I am ${alice.userName}`; + const bobMsg = `Hello I am ${bob.userName}`; + await test.step(`${alice.userName} and ${bob.userName} send a message to the group`, async () => { + await alice1.sendMessage(aliceMsg); + await bob1.sendMessage(bobMsg); + await Promise.all( + [alice1, bob1, charlie1].map(async device => { + await device.waitForTextElementToBePresent(new MessageBody(device, aliceMsg)); + await device.waitForTextElementToBePresent(new MessageBody(device, bobMsg)); + }) + ); + }); + await test.step(TestSteps.USER_ACTIONS.GROUPS_REMOVE_MEMBER(bob.userName), async () => { + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageMembersMenuItem(alice1)); + await alice1.clickOnElementAll({ ...new GroupMember(alice1).build(USERNAME.BOB) }); + await alice1.clickOnElementAll(new RemoveMemberButton(alice1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Remove Member'), async () => { + await alice1.checkModalStrings( + tStripped('remove'), + tStripped('groupRemoveDescription', { name: USERNAME.BOB, group_name: testGroupName }) + ); + }); + await alice1.clickOnElementAll(new RemoveMemberMessagesRadial(alice1)); + await alice1.clickOnElementAll(new ConfirmRemovalButton(alice1)); + // The Group Member element sometimes disappears slowly, sometimes quickly. + // hasElementBeenDeleted would be theoretically better but we just check if element is not there anymore + await alice1.verifyElementNotPresent({ + ...new GroupMember(alice1).build(USERNAME.BOB), + maxWait: 5_000, + }); + }); + await alice1.navigateBack(); + await alice1.navigateBack(); + await test.step(`Verify ${bob.userName} has been kicked and his message has been deleted`, async () => { + await Promise.all( + [alice1, charlie1].map(async device => { + await device.waitForControlMessageToBePresent( + tStripped('groupRemoved', { name: USERNAME.BOB }) + ); + await device.waitForTextElementToBePresent(new MessageBody(device, aliceMsg)); + await device.verifyElementNotPresent({ + ...new MessageBody(device, bobMsg).build(), + maxWait: 1_000, + }); + await device.waitForTextElementToBePresent(new DeletedMessage(device)); + }) + ); + await Promise.all([ + bob1.waitForTextElementToBePresent({ + ...new EmptyConversation(bob1).build(), + text: tStripped('groupRemovedYou', { group_name: testGroupName }), + }), + bob1.verifyElementNotPresent(new MessageBody(bob1, aliceMsg)), + bob1.verifyElementNotPresent(new MessageBody(bob1, bobMsg)), + bob1.verifyElementNotPresent(new DeletedMessage(bob1)), + bob1.verifyElementNotPresent({ ...new MessageInput(bob1).build(), maxWait: 1_000 }), + ]); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1); + }); +} diff --git a/run/test/specs/group_tests_promote.spec.ts b/run/test/specs/group_tests_promote.spec.ts new file mode 100644 index 000000000..5ad2cf797 --- /dev/null +++ b/run/test/specs/group_tests_promote.spec.ts @@ -0,0 +1,377 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { DISAPPEARING_TIMES, USERNAME } from '../../types/testing'; +import { ConversationSettings } from '../locators/conversation'; +import { Contact } from '../locators/global'; +import { + ConfirmPromotionModalButton, + ManageAdminsMenuItem, + MemberStatus, + PromoteMemberFooterButton, + PromoteMemberModalConfirm, + PromoteMembersMenuItem, +} from '../locators/groups'; +import { ConversationItem } from '../locators/home'; +import { open_Alice1_Bob1_Charlie1_friends_group } from '../state_builder'; +import { newUser } from '../utils/create_account'; +import { createGroup } from '../utils/create_group'; +import { sortByPubkey } from '../utils/get_account_id'; +import { closeApp, openAppFourDevices, SupportedPlatformsType } from '../utils/open_app'; +import { restoreAccount } from '../utils/restore_account'; +import { setDisappearingMessage } from '../utils/set_disappearing_messages'; + +androidIt({ + title: 'Promote to admin (one member)', + risk: 'medium', + testCb: promoteSoloToAdmin, + countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: 'Verifies that a group member can be promoted to Admin.', +}); + +androidIt({ + title: 'Promote to admin (linked device)', + risk: 'medium', + testCb: promoteSoloLinked, + countOfDevicesNeeded: 4, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: + 'Verifies that a previously promoted admin has admin powers on their linked device.', +}); + +androidIt({ + title: 'Promote to admin (multiple members)', + risk: 'medium', + testCb: promoteMultiToAdmin, + countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: 'Verifies that multiple members can be promoted to Admin in one action.', +}); + +// The newly promoted admin will set disappearing messages to verify they have admin powers +const time = DISAPPEARING_TIMES.ONE_MINUTE; +const timerType = 'Disappear after send option'; + +async function promoteSoloToAdmin(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Test group'; + const { + devices: { alice1, bob1, charlie1 }, + prebuilt: { alice, bob }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + }); + await test.step(`${alice.userName} promotes ${bob.userName}`, async () => { + // Navigate to Promote Members screen + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageAdminsMenuItem(alice1)); + await alice1.clickOnElementAll(new PromoteMembersMenuItem(alice1)); + await alice1.clickOnElementAll(new Contact(alice1, 'Bob')); + await alice1.clickOnElementAll(new PromoteMemberFooterButton(alice1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Promote'), async () => { + await alice1.checkModalStrings( + tStripped('promote'), + tStripped('adminPromoteDescription', { name: bob.userName }) + ); + // This is a string that's part of the modal but not part of the modal description element + await alice1.waitForTextElementToBePresent({ + strategy: '-android uiautomator', + selector: `new UiSelector().text("${tStripped('promoteAdminsWarning')}")`, + }); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Confirm Promotion'), async () => { + await alice1.clickOnElementAll(new PromoteMemberModalConfirm(alice1)); + await alice1.checkModalStrings( + tStripped('confirmPromotion'), + tStripped('confirmPromotionDescription') + ); + }); + await alice1.clickOnElementAll(new ConfirmPromotionModalButton(alice1)); + // This is not tied to Bob but they're the only admin this status can apply to + await alice1.waitForTextElementToBePresent( + new MemberStatus(alice1).build(tStripped('adminPromotionSent')) + ); + }); + await alice1.navigateBack(); + await alice1.navigateBack(); + await test.step('Verify every member sees the promotion control message', async () => { + await Promise.all( + [alice1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('adminPromotedToAdmin', { name: bob.userName }), + 30_000 + ) + ) + ); + await bob1.waitForControlMessageToBePresent(tStripped('groupPromotedYou')); + }); + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageAdminsMenuItem(alice1)); + await Promise.all([ + alice1.waitForTextElementToBePresent(new Contact(alice1, bob.userName)), + alice1.verifyElementNotPresent(new MemberStatus(alice1).build(tStripped('adminPromotionSent'))), + alice1.verifyElementNotPresent( + new MemberStatus(alice1).build(tStripped('adminPromotionFailed')) + ), + ]); + await alice1.navigateBack(); + await alice1.navigateBack(); + await test.step(`Verify ${bob.userName} has admin powers by setting disappearing messages`, async () => { + // Check to see if Bob has admin powers by setting disappearing messages + await setDisappearingMessage(bob1, ['Group', timerType, time]); + await Promise.all( + [alice1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: bob.userName, + time, + disappearing_messages_type: 'sent', + }), + 30_000 + ) + ) + ); + await bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { time, disappearing_messages_type: 'sent' }) + ); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1); + }); +} + +async function promoteSoloLinked(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Test group'; + const { device1, device2, device3, device4 } = await openAppFourDevices(platform, testInfo); + const [alice, bob, charlie] = await Promise.all([ + newUser(device1, USERNAME.ALICE), + newUser(device2, USERNAME.BOB), + newUser(device3, USERNAME.CHARLIE), + ]); + await createGroup(platform, device1, alice, device2, bob, device3, charlie, testGroupName); + await test.step(`${alice.userName} promotes ${bob.userName}`, async () => { + // Navigate to Promote Members screen + await device1.clickOnElementAll(new ConversationSettings(device1)); + await device1.clickOnElementAll(new ManageAdminsMenuItem(device1)); + await device1.clickOnElementAll(new PromoteMembersMenuItem(device1)); + await device1.clickOnElementAll(new Contact(device1, bob.userName)); + await device1.clickOnElementAll(new PromoteMemberFooterButton(device1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Promote'), async () => { + await device1.checkModalStrings( + tStripped('promote'), + tStripped('adminPromoteDescription', { name: bob.userName }) + ); + // This is a string that's part of the modal but not part of the modal description element + await device1.waitForTextElementToBePresent({ + strategy: '-android uiautomator', + selector: `new UiSelector().text("${tStripped('promoteAdminsWarning')}")`, + }); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Confirm Promotion'), async () => { + await device1.clickOnElementAll(new PromoteMemberModalConfirm(device1)); + await device1.checkModalStrings( + tStripped('confirmPromotion'), + tStripped('confirmPromotionDescription') + ); + }); + await device1.clickOnElementAll(new ConfirmPromotionModalButton(device1)); + // This is not tied to Bob but they're the only admin this status can apply to + await device1.waitForTextElementToBePresent( + new MemberStatus(device1).build(tStripped('adminPromotionSent')) + ); + }); + await device1.navigateBack(); + await device1.navigateBack(); + await test.step('Verify every member sees the promotion control message', async () => { + await Promise.all( + [device1, device3].map(device => + device.waitForControlMessageToBePresent( + tStripped('adminPromotedToAdmin', { name: bob.userName }), + 30_000 + ) + ) + ); + await device2.waitForControlMessageToBePresent(tStripped('groupPromotedYou')); + }); + await device1.clickOnElementAll(new ConversationSettings(device1)); + await device1.clickOnElementAll(new ManageAdminsMenuItem(device1)); + await Promise.all([ + device1.waitForTextElementToBePresent(new Contact(device1, bob.userName)), + device1.verifyElementNotPresent( + new MemberStatus(device1).build(tStripped('adminPromotionSent')) + ), + device1.verifyElementNotPresent( + new MemberStatus(device1).build(tStripped('adminPromotionFailed')) + ), + ]); + await device1.navigateBack(); + await device1.navigateBack(); + await test.step(`Verify ${bob.userName} has admin powers by setting disappearing messages`, async () => { + // Check to see if Bob has admin powers by setting disappearing messages + await setDisappearingMessage(device2, ['Group', timerType, time]); + await Promise.all( + [device1, device3].map(device => + device.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: bob.userName, + time, + disappearing_messages_type: 'sent', + }), + 30_000 + ) + ) + ); + await device2.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { time, disappearing_messages_type: 'sent' }) + ); + }); + await restoreAccount(device4, bob, 'bob2'); + await device4.clickOnElementAll(new ConversationItem(device4, testGroupName)); + await device4.clickOnElementAll(new ConversationSettings(device4)); + await device4.clickOnElementAll(new ManageAdminsMenuItem(device4)); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device1, device2, device3, device4); + }); +} + +async function promoteMultiToAdmin(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Test group'; + const { + devices: { alice1, bob1, charlie1 }, + prebuilt: { alice, bob, charlie }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + }); + const [firstUser, secondUser] = sortByPubkey(bob, charlie); + await test.step(`${alice.userName} promotes ${bob.userName} and ${charlie.userName}`, async () => { + // Navigate to Promote Members screen + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageAdminsMenuItem(alice1)); + await alice1.clickOnElementAll(new PromoteMembersMenuItem(alice1)); + await alice1.clickOnElementAll(new Contact(alice1, 'Bob')); + await alice1.clickOnElementAll(new Contact(alice1, 'Charlie')); + await alice1.clickOnElementAll(new PromoteMemberFooterButton(alice1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Promote'), async () => { + await alice1.checkModalStrings( + tStripped('promote'), + tStripped('adminPromoteTwoDescription', { name: firstUser, other_name: secondUser }) + ); + // This is a string that's part of the modal but not part of the modal description element + await alice1.waitForTextElementToBePresent({ + strategy: '-android uiautomator', + selector: `new UiSelector().text("${tStripped('promoteAdminsWarning')}")`, + }); + }); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Confirm Promotion'), async () => { + await alice1.clickOnElementAll(new PromoteMemberModalConfirm(alice1)); + await alice1.checkModalStrings( + tStripped('confirmPromotion'), + tStripped('confirmPromotionDescription') + ); + }); + await alice1.clickOnElementAll(new ConfirmPromotionModalButton(alice1)); + // This is not tied to Bob/Charlie but they're the only admin this status can apply to + await alice1.waitForTextElementToBePresent( + new MemberStatus(alice1).build(tStripped('adminPromotionSent')) + ); + }); + await alice1.navigateBack(); + await alice1.navigateBack(); + await test.step('Verify every member sees the promotion control message', async () => { + await Promise.all([ + alice1.waitForControlMessageToBePresent( + tStripped('adminTwoPromotedToAdmin', { name: firstUser, other_name: secondUser }), + 10_000 + ), + bob1.waitForControlMessageToBePresent( + tStripped('groupPromotedYouTwo', { other_name: charlie.userName }), + 45_000 + ), + charlie1.waitForControlMessageToBePresent( + tStripped('groupPromotedYouTwo', { other_name: bob.userName }), + 45_000 + ), + ]); + }); + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new ManageAdminsMenuItem(alice1)); + await Promise.all([ + alice1.waitForTextElementToBePresent(new Contact(alice1, bob.userName)), + alice1.waitForTextElementToBePresent(new Contact(alice1, charlie.userName)), + alice1.verifyElementNotPresent({ + ...new MemberStatus(alice1).build(tStripped('adminPromotionSent')), + maxWait: 10_000, + }), + alice1.verifyElementNotPresent( + new MemberStatus(alice1).build(tStripped('adminPromotionFailed')) + ), + ]); + await alice1.navigateBack(); + await alice1.navigateBack(); + await test.step(`Verify ${bob.userName} has admin powers by setting disappearing messages`, async () => { + // Check to see if Bob has admin powers by setting disappearing messages + await setDisappearingMessage(bob1, ['Group', timerType, time]); + await Promise.all( + [alice1, charlie1].map(device => + device.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: bob.userName, + time, + disappearing_messages_type: 'sent', + }), + 30_000 + ) + ) + ); + await bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { time, disappearing_messages_type: 'sent' }) + ); + }); + await test.step(`Verify ${charlie.userName} has admin powers by setting disappearing messages`, async () => { + // Check to see if Bob has admin powers by setting disappearing messages + const charlieTime = DISAPPEARING_TIMES.TWELVE_HOURS; + await setDisappearingMessage(charlie1, ['Group', timerType, charlieTime]); + await Promise.all( + [alice1, bob1].map(device => + device.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: charlie.userName, + time: charlieTime, + disappearing_messages_type: 'sent', + }), + 30_000 + ) + ) + ); + await charlie1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { + time: charlieTime, + disappearing_messages_type: 'sent', + }) + ); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1); + }); +} diff --git a/run/test/specs/linked_device_block_user.spec.ts b/run/test/specs/linked_device_block_user.spec.ts index 900e0c824..0f2f1091c 100644 --- a/run/test/specs/linked_device_block_user.spec.ts +++ b/run/test/specs/linked_device_block_user.spec.ts @@ -35,6 +35,7 @@ async function blockUserInConversationOptions( await alice1.clickOnElementAll(new ConversationSettings(alice1)); // Select Block option await sleepFor(500); + await alice1.onIOS().scrollDown(); // Blind scroll because Block option is obscured by system UI on iOS await alice1.clickOnElementAll(new BlockUser(alice1)); await alice1.checkModalStrings( tStripped('block'), @@ -42,23 +43,11 @@ async function blockUserInConversationOptions( ); // Confirm block option await alice1.clickOnElementAll(new BlockUserConfirmationModal(alice1)); - // On ios there is an alert that confirms that the user has been blocked - await sleepFor(1000); - // On ios, you need to navigate back to conversation screen to confirm block + await alice2.hasElementBeenDeleted(new ConversationItem(alice2, bob.userName)); await alice1.navigateBack(); - // Look for alert at top of screen (Bob is blocked. Unblock them?) - // Check device 1 for blocked status - const blockedStatus = await alice1.waitForTextElementToBePresent(new BlockedBanner(alice1)); - if (blockedStatus) { - // Check linked device for blocked status (if shown on alice1) - await alice2.onAndroid().clickOnElementAll(new ConversationItem(alice2, bob.userName)); - await alice2.onAndroid().waitForTextElementToBePresent(new BlockedBanner(alice2)); - alice2.info(`${bob.userName}` + ' has been blocked'); - } else { - alice2.info('Blocked banner not found'); - } + await alice1.waitForTextElementToBePresent(new BlockedBanner(alice1)); // Check settings for blocked user - await Promise.all([alice1.navigateBack(), alice2.onAndroid().navigateBack()]); + await alice1.navigateBack(); await Promise.all([ alice1.clickOnElementAll(new UserSettings(alice1)), alice2.clickOnElementAll(new UserSettings(alice2)), diff --git a/run/test/specs/linked_device_community_ban.spec.ts b/run/test/specs/linked_device_community_ban.spec.ts new file mode 100644 index 000000000..2725624ac --- /dev/null +++ b/run/test/specs/linked_device_community_ban.spec.ts @@ -0,0 +1,210 @@ +import test, { type TestInfo } from '@playwright/test'; + +import { communities } from '../../constants/community'; +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { User } from '../../types/testing'; +import { + EmptyConversation, + LongPressBanAndDelete, + LongPressBanUser, + LongPressUnBan, + MessageBody, + MessageInput, + OutgoingMessageStatusSent, + SendButton, +} from '../locators/conversation'; +import { ConversationItem } from '../locators/home'; +import { assertAdminIsKnown, joinCommunity } from '../utils/community'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppThreeDevices, SupportedPlatformsType } from '../utils/open_app'; +import { restoreAccount } from '../utils/restore_account'; + +bothPlatformsIt({ + title: 'Ban and unban user in community - linked device', + risk: 'medium', + countOfDevicesNeeded: 3, + testCb: banUnbanLinked, + allureSuites: { + parent: 'User Actions', + suite: 'Ban/Unban', + }, + allureDescription: `Verifies that a community admin can ban a user. + The banned user cannot send a message. + The banned account is restored on a second device. + Admin then unbans the user, and they can send messages on both devices.`, +}); + +bothPlatformsIt({ + title: 'Ban and delete in community - linked device', + risk: 'medium', + countOfDevicesNeeded: 3, + testCb: banAndDeleteLinked, + allureSuites: { + parent: 'User Actions', + suite: 'Ban/Unban', + }, + allureDescription: `Verifies that a community admin can ban a user and delete their messages. + Then, restore the banned account on a second device. + The banned user cannot send messages anymore on either of their linked devices.`, +}); + +// Bob 1 + Bob 2 get banned by Alice the admin +async function banUnbanLinked(platform: SupportedPlatformsType, testInfo: TestInfo) { + assertAdminIsKnown(); + const msgSig = `${new Date().getTime()} - ${platform}`; + const msg1 = `Ban, link, unban - ${msgSig}`; + const msg2 = `Am I banned? - ${msgSig}`; + const msg3 = `You'll never catch me alive! - ${msgSig}`; + const msg3Linked = `${msg3} - linked device`; + const alice: User = { + userName: 'Alice', + accountID: '', // Mandatory property of User type but not needed for this test + recoveryPhrase: process.env.SOGS_ADMIN_SEED!, + }; + const { + device1: alice1, + device2: bob1, + device3: bob2, + } = await openAppThreeDevices(platform, testInfo); + const [, bob] = + await test.step('Restore admin account, create new account to be banned', async () => { + return await Promise.all([restoreAccount(alice1, alice, 'alice1'), newUser(bob1, 'Bob')]); + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + const adminJoined = await alice1.doesElementExist( + new ConversationItem(alice1, communities.testCommunity.name) + ); + if (!adminJoined) { + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); + } else { + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); + await alice1.scrollToBottom(); + } + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); + }); + await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { + await bob1.sendMessage(msg1); + }); + await test.step('Admin bans Bob from community', async () => { + await alice1.longPressMessage(new MessageBody(alice1, msg1)); + await alice1.clickOnElementAll(new LongPressBanUser(alice1)); + await alice1.clickOnByAccessibilityID('Continue'); + }); + await test.step('Verify Bob cannot send messages to community', async () => { + await bob1.inputText(msg2, new MessageInput(bob1)); + await bob1.clickOnElementAll(new SendButton(bob1)); + await bob1.verifyElementNotPresent({ + ...new OutgoingMessageStatusSent(bob1).build(), + maxWait: 10_000, + }); + await alice1.verifyElementNotPresent(new MessageBody(alice1, msg2)); + }); + await test.step(TestSteps.SETUP.RESTORE_ACCOUNT('Bob'), async () => { + await restoreAccount(bob2, bob, 'bob2'); + await bob2.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.roomName)); // Since we're banned we don't get the "real" name + await bob2.waitForTextElementToBePresent(new EmptyConversation(bob2)); + await bob2.onIOS().waitForTextElementToBePresent({ + strategy: 'xpath', + selector: `//XCUIElementTypeStaticText`, + text: tStripped('permissionsWriteCommunity'), + }); + }); + await test.step('Admin unbans Bob, Bob can send a third message from both devices', async () => { + await alice1.longPressMessage(new MessageBody(alice1, msg1)); + await alice1.clickOnElementAll(new LongPressUnBan(alice1)); + await alice1.clickOnByAccessibilityID('Continue'); + await Promise.all([bob1.sendMessage(msg3), bob2.sendMessage(msg3Linked)]); + await Promise.all([ + alice1.waitForTextElementToBePresent(new MessageBody(alice1, msg3)), + alice1.waitForTextElementToBePresent(new MessageBody(alice1, msg3Linked)), + ]); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, bob2); + }); +} + +// Bob 1 + Bob 2 get banned by Alice the admin +async function banAndDeleteLinked(platform: SupportedPlatformsType, testInfo: TestInfo) { + assertAdminIsKnown(); + const msgSig = `${new Date().getTime()} - ${platform}`; + const msg1 = `Ban and delete linked - ${msgSig}`; + const msg2 = `Am I banned? - ${msgSig}`; + const alice: User = { + userName: 'Alice', + accountID: '', // Mandatory property of User type but not needed for this test + recoveryPhrase: process.env.SOGS_ADMIN_SEED!, + }; + const { + device1: alice1, + device2: bob1, + device3: bob2, + } = await openAppThreeDevices(platform, testInfo); + const [, bob] = + await test.step('Restore admin account, create new account to be banned', async () => { + return await Promise.all([restoreAccount(alice1, alice, 'alice1'), newUser(bob1, 'Bob')]); + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + const adminJoined = await alice1.doesElementExist( + new ConversationItem(alice1, communities.testCommunity.name) + ); + if (!adminJoined) { + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); + } else { + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); + await alice1.scrollToBottom(); + } + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); + }); + await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { + await bob1.sendMessage(msg1); + }); + await test.step('Admin bans Bob and deletes all from community', async () => { + await alice1.longPressMessage(new MessageBody(alice1, msg1)); + await alice1.clickOnElementAll(new LongPressBanAndDelete(alice1)); + await alice1.clickOnByAccessibilityID('Continue'); + }); + await test.step(`Verify Bob's first message has been deleted`, async () => { + await alice1.verifyElementNotPresent({ + ...new MessageBody(alice1, msg1).build(), + maxWait: 5_000, + }); + }); + await test.step(TestSteps.SETUP.RESTORE_ACCOUNT('Bob'), async () => { + await restoreAccount(bob2, bob, 'bob2'); + await bob2.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.roomName)); // Since we're banned we don't get the "real" name + await bob2.waitForTextElementToBePresent(new EmptyConversation(bob2)); + }); + await test.step('Verify Bob cannot send messages in community on either device', async () => { + if (platform === 'android') { + await Promise.all( + [bob1, bob2].map(async device => { + await device.inputText(msg2, new MessageInput(device)); + await device.clickOnElementAll(new SendButton(device)); + await device.verifyElementNotPresent({ + ...new OutgoingMessageStatusSent(device).build(), + maxWait: 10_000, + }); + }) + ); + } else { + await bob1.inputText(msg2, new MessageInput(bob1)); + await bob1.clickOnElementAll(new SendButton(bob1)); + await bob1.verifyElementNotPresent({ + ...new OutgoingMessageStatusSent(bob1).build(), + maxWait: 10_000, + }); + await bob2.waitForTextElementToBePresent({ + strategy: 'xpath', + selector: `//XCUIElementTypeStaticText`, + text: tStripped('permissionsWriteCommunity'), + }); + } + await alice1.verifyElementNotPresent(new MessageBody(alice1, msg2)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, bob2); + }); +} diff --git a/run/test/specs/linked_device_restore_group.spec.ts b/run/test/specs/linked_device_restore_group.spec.ts index 787bd8f66..22a346ef1 100644 --- a/run/test/specs/linked_device_restore_group.spec.ts +++ b/run/test/specs/linked_device_restore_group.spec.ts @@ -34,7 +34,7 @@ async function restoreGroup(platform: SupportedPlatformsType, testInfo: TestInfo const aliceMessage = `${USERNAME.ALICE} to ${testGroupName}`; const bobMessage = `${USERNAME.BOB} to ${testGroupName}`; const charlieMessage = `${USERNAME.CHARLIE} to ${testGroupName}`; - await restoreAccount(device4, alice); + await restoreAccount(device4, alice, 'alice2'); // Check that group has loaded on linked device await device4.clickOnElementAll(new ConversationItem(device4, testGroupName)); // Check the group name has loaded diff --git a/run/test/specs/linked_device_unsend_message.spec.ts b/run/test/specs/linked_device_unsend_message.spec.ts index 2aabc0ce1..ef20acc41 100644 --- a/run/test/specs/linked_device_unsend_message.spec.ts +++ b/run/test/specs/linked_device_unsend_message.spec.ts @@ -69,11 +69,11 @@ async function unSendMessageLinkedDevice(platform: SupportedPlatformsType, testI await Promise.all( [alice1, bob1, alice2].map(async device => { await device.waitForTextElementToBePresent(new MessageBody(device, firstMessage)); - await device.verifyElementNotPresent(new MessageBody(device, secondMessage)); await device.waitForTextElementToBePresent({ ...new DeletedMessage(device).build(), maxWait: 10_000, }); + await device.verifyElementNotPresent(new MessageBody(device, secondMessage)); await device.back(); }) ); diff --git a/run/test/specs/linked_group_leave.spec.ts b/run/test/specs/linked_group_leave.spec.ts index 0d0c62514..47f06e318 100644 --- a/run/test/specs/linked_group_leave.spec.ts +++ b/run/test/specs/linked_group_leave.spec.ts @@ -6,7 +6,6 @@ import { USERNAME } from '../../types/testing'; import { ConversationSettings } from '../locators/conversation'; import { LeaveGroupConfirm, LeaveGroupMenuItem } from '../locators/groups'; import { ConversationItem } from '../locators/home'; -import { sleepFor } from '../utils'; import { newUser } from '../utils/create_account'; import { createGroup } from '../utils/create_group'; import { linkedDevice } from '../utils/link_device'; @@ -27,29 +26,32 @@ bothPlatformsIt({ async function leaveGroupLinkedDevice(platform: SupportedPlatformsType, testInfo: TestInfo) { const testGroupName = 'Leave group linked device'; const { device1, device2, device3, device4 } = await openAppFourDevices(platform, testInfo); - const charlie = await linkedDevice(device3, device4, USERNAME.CHARLIE); - // Create users A, B and C - const [alice, bob] = await Promise.all([ + const [alice, bob, charlie] = await Promise.all([ newUser(device1, USERNAME.ALICE), newUser(device2, USERNAME.BOB), + linkedDevice(device3, device4, USERNAME.CHARLIE), ]); // Create group with user A, user B and User C await createGroup(platform, device1, alice, device2, bob, device3, charlie, testGroupName); - await sleepFor(1000); + // If we know group is present on device4, we can check for just disappearance later (vs. hasElementBeenDeleted) + await device4.waitForTextElementToBePresent(new ConversationItem(device4, testGroupName)); + // Leave Group on device 3 await device3.clickOnElementAll(new ConversationSettings(device3)); - await sleepFor(1000); await device3.clickOnElementAll(new LeaveGroupMenuItem(device3)); await device3.checkModalStrings( tStripped('groupLeave'), tStripped('groupLeaveDescription', { group_name: testGroupName }) ); - // Modal with Leave/Cancel await device3.clickOnElementAll(new LeaveGroupConfirm(device3)); - // Check for group disappearing - await Promise.all([ - device3.verifyElementNotPresent(new ConversationItem(device3, testGroupName)), - device4.hasElementBeenDeleted(new ConversationItem(device4, testGroupName)), - ]); + // Check for group not being visible anymore + await Promise.all( + [device3, device4].map(device => + device.verifyElementNotPresent({ + ...new ConversationItem(device, testGroupName).build(), + maxWait: 10_000, + }) + ) + ); // Create control message for user leaving group const groupMemberLeft = tStripped('groupMemberLeft', { name: charlie.userName }); await Promise.all([ diff --git a/run/test/specs/message_community_invitation.spec.ts b/run/test/specs/message_community_invitation.spec.ts index 2c042042a..95d5c1142 100644 --- a/run/test/specs/message_community_invitation.spec.ts +++ b/run/test/specs/message_community_invitation.spec.ts @@ -1,19 +1,21 @@ import type { TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { tStripped } from '../../localizer/lib'; import { bothPlatformsIt } from '../../types/sessionIt'; import { InviteContactsMenuItem, JoinCommunityModalButton } from '../locators'; import { CommunityInvitation, CommunityInviteConfirmButton, + ConversationHeaderName, ConversationSettings, + MessageBody, } from '../locators/conversation'; import { GroupMember } from '../locators/groups'; import { ConversationItem } from '../locators/home'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils'; -import { joinCommunity } from '../utils/join_community'; +import { joinCommunity } from '../utils/community'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ @@ -35,7 +37,7 @@ async function sendCommunityInvitation(platform: SupportedPlatformsType, testInf // Join community on device 1 // Click on plus button await alice1.navigateBack(); - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); await alice1.clickOnElementAll(new ConversationSettings(alice1)); await sleepFor(500); await alice1.clickOnElementAll(new InviteContactsMenuItem(alice1)); @@ -43,12 +45,22 @@ async function sendCommunityInvitation(platform: SupportedPlatformsType, testInf await alice1.clickOnElementAll(new CommunityInviteConfirmButton(alice1)); await bob1.waitForTextElementToBePresent(new CommunityInvitation(bob1)); await bob1.clickOnElementAll(new CommunityInvitation(bob1)); - await bob1.checkModalStrings( - tStripped('communityJoin'), - tStripped('communityJoinDescription', { community_name: testCommunityName }) - ); + const joinCommunityModaBody = + platform === 'android' + ? tStripped('joinThisCommunity') + : tStripped('communityJoinDescription', { community_name: communities.testCommunity.name }); + await bob1.checkModalStrings(tStripped('communityJoin'), joinCommunityModaBody); await bob1.clickOnElementAll(new JoinCommunityModalButton(bob1)); - await bob1.navigateBack(); - await bob1.waitForTextElementToBePresent(new ConversationItem(bob1, testCommunityName)); + if (platform === 'android') { + await bob1.waitForTextElementToBePresent( + new ConversationHeaderName(bob1, communities.testCommunity.name) + ); + await bob1.waitForTextElementToBePresent(new MessageBody(bob1)); + } else { + await bob1.navigateBack(); + await bob1.waitForTextElementToBePresent( + new ConversationItem(bob1, communities.testCommunity.name) + ); + } await closeApp(alice1, bob1); } diff --git a/run/test/specs/message_length.spec.ts b/run/test/specs/message_length.spec.ts index b04b27d0b..d423ff9b0 100644 --- a/run/test/specs/message_length.spec.ts +++ b/run/test/specs/message_length.spec.ts @@ -11,48 +11,96 @@ import { MessageLengthOkayButton, SendButton, } from '../locators/conversation'; +import { CTAButtonNegative } from '../locators/global'; import { PlusButton } from '../locators/home'; import { EnterAccountID, NewMessageOption, NextButton } from '../locators/start_conversation'; +import { IOS_PRO_CONTEXT } from '../utils/capabilities_ios'; import { newUser } from '../utils/create_account'; +import { makeAccountPro } from '../utils/mock_pro'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; +import { forceStopAndRestart } from '../utils/utilities'; -const maxChars = 2000; -const countdownThreshold = 1800; +const STANDARD_MAX_CHARS = 2000; +const PRO_MAX_CHARS = 10000; +const COUNTDOWN_START_THRESHOLD = 200; const messageLengthTestCases = [ { + pro: false, length: 1799, - char: 'a', shouldSend: true, description: 'no countdown shows, message sends', }, - { length: 1800, char: 'b', shouldSend: true, description: 'countdown shows 200, message sends' }, - { length: 2000, char: 'c', shouldSend: true, description: 'countdown shows 0, message sends' }, { + pro: false, + length: 1800, + shouldSend: true, + description: 'countdown shows 200, message sends', + }, + { + pro: false, + length: 2000, + shouldSend: true, + description: 'countdown shows 0, message sends', + }, + { + pro: false, length: 2001, - char: 'd', + shouldSend: false, + description: 'countdown shows -1, cannot send message', + }, + { + pro: true, + length: 9799, + shouldSend: true, + description: 'no countdown shows, message sends', + }, + { + pro: true, + length: 9800, + shouldSend: true, + description: 'countdown shows 200, message sends', + }, + { + pro: true, + length: 10000, + shouldSend: true, + description: 'countdown shows 0, message sends', + }, + { + pro: true, + length: 10001, shouldSend: false, description: 'countdown shows -1, cannot send message', }, ]; for (const testCase of messageLengthTestCases) { + const proSuffix = testCase.pro ? `Pro` : `non Pro`; bothPlatformsIt({ - title: `Message length limit (${testCase.length} chars)`, + title: `Message length limit (${testCase.length} chars ${proSuffix})`, risk: 'high', countOfDevicesNeeded: 1, + isPro: testCase.pro, allureSuites: { parent: 'Sending Messages', suite: 'Rules', }, - allureDescription: `Verifies message length behavior at ${testCase.length} characters - ${testCase.description}`, + allureDescription: `Verifies message length behavior at ${testCase.length} characters - ${testCase.description} (${proSuffix})`, testCb: async (platform: SupportedPlatformsType, testInfo: TestInfo) => { const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { - const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, IOS_PRO_CONTEXT); const alice = await newUser(device, USERNAME.ALICE); return { device, alice }; }); + if (testCase.pro) { + await makeAccountPro({ user: alice, platform }); + // Restart to notify app of Pro status change + await forceStopAndRestart(device); + await device.dismissCTA(); + } + // Send message to self to bring up Note to Self conversation await test.step(TestSteps.OPEN.NTS, async () => { await device.clickOnElementAll(new PlusButton(device)); @@ -63,12 +111,15 @@ for (const testCase of messageLengthTestCases) { }); await test.step(`Type ${testCase.length} chars, check countdown`, async () => { + const expectedMax = testCase.pro ? PRO_MAX_CHARS : STANDARD_MAX_CHARS; const expectedCount = - testCase.length < countdownThreshold ? null : (maxChars - testCase.length).toString(); + testCase.length < expectedMax - COUNTDOWN_START_THRESHOLD + ? null + : (expectedMax - testCase.length).toString(); // Construct the string of desired length - const message = testCase.char.repeat(testCase.length); - await device.inputText(message, new MessageInput(device)); + const message = 'x'.repeat(testCase.length); + await device.inputText(message, new MessageInput(device), true); // Does the countdown appear? if (expectedCount) { @@ -84,11 +135,16 @@ for (const testCase of messageLengthTestCases) { // Is the message short enough to send? if (testCase.shouldSend) { await device.waitForTextElementToBePresent(new MessageBody(device, message)); - } else { - // Modal appears, verify and dismiss + } else if (!testCase.pro) { + // For Non Pro, a CTA appears + await device.checkCTA('longerMessages'); + await device.clickOnElementAll(new CTAButtonNegative(device)); + await device.verifyElementNotPresent(new MessageBody(device, message)); + } else if (testCase.pro) { + // For Pro, a normal message length dialog appears await device.checkModalStrings( tStripped('modalMessageTooLongTitle'), - tStripped('modalMessageTooLongDescription', { limit: maxChars.toString() }) + tStripped('modalMessageTooLongDescription', { limit: expectedMax.toString() }) ); await device.clickOnElementAll(new MessageLengthOkayButton(device)); await device.verifyElementNotPresent(new MessageBody(device, message)); diff --git a/run/test/specs/message_requests_accept.spec.ts b/run/test/specs/message_requests_accept.spec.ts index 9d933fb49..0e7fcb73a 100644 --- a/run/test/specs/message_requests_accept.spec.ts +++ b/run/test/specs/message_requests_accept.spec.ts @@ -3,7 +3,7 @@ import type { TestInfo } from '@playwright/test'; import { tStripped } from '../../localizer/lib'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { ConversationItem, MessageRequestsBanner } from '../locators/home'; +import { ConversationItem } from '../locators/home'; import { newUser } from '../utils/create_account'; import { linkedDevice } from '../utils/link_device'; import { closeApp, openAppThreeDevices, SupportedPlatformsType } from '../utils/open_app'; @@ -25,13 +25,7 @@ async function acceptRequest(platform: SupportedPlatformsType, testInfo: TestInf // Send message from Alice to Bob await device1.sendNewMessage(bob, `${alice.userName} to ${bob.userName}`); - // Wait for banner to appear - // Bob clicks on message request banner - await device2.clickOnElementAll(new MessageRequestsBanner(device2)); - // Bob clicks on request conversation item - await device2.clickOnByAccessibilityID('Message request'); - // Bob clicks accept button on device 2 (original device) - await device2.clickOnByAccessibilityID('Accept message request'); + await device2.acceptMessageRequestWithButton(); // Check control message for message request acceptance // "messageRequestsAccepted": "Your message request has been accepted.", const messageRequestsAccepted = tStripped('messageRequestsAccepted'); diff --git a/run/test/specs/message_requests_accept_text_reply.spec.ts b/run/test/specs/message_requests_accept_text_reply.spec.ts index 36825d3af..4f53004dc 100644 --- a/run/test/specs/message_requests_accept_text_reply.spec.ts +++ b/run/test/specs/message_requests_accept_text_reply.spec.ts @@ -4,7 +4,7 @@ import { tStripped } from '../../localizer/lib'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { MessageInput, OutgoingMessageStatusSent, SendButton } from '../locators/conversation'; -import { PlusButton } from '../locators/home'; +import { MessageRequestItem, PlusButton } from '../locators/home'; import { MessageRequestsBanner } from '../locators/home'; import { EnterAccountID, NewMessageOption, NextButton } from '../locators/start_conversation'; import { newUser } from '../utils/create_account'; @@ -57,7 +57,7 @@ async function acceptRequestWithText(platform: SupportedPlatformsType, testInfo: // Bob clicks on message request banner await device2.clickOnElementAll(new MessageRequestsBanner(device2)); // Bob clicks on request conversation item - await device2.clickOnByAccessibilityID('Message request'); + await device2.clickOnElementAll(new MessageRequestItem(device2)); // Check control message warning of sending message request reply // "messageRequestsAcceptDescription": "Sending a message to this user will automatically accept their message request and reveal your Account ID." const messageRequestsAcceptDescription = tStripped('messageRequestsAcceptDescription'); diff --git a/run/test/specs/message_requests_block.spec.ts b/run/test/specs/message_requests_block.spec.ts index 04f0f9ac5..204d28dee 100644 --- a/run/test/specs/message_requests_block.spec.ts +++ b/run/test/specs/message_requests_block.spec.ts @@ -5,7 +5,7 @@ import { bothPlatformsIt } from '../../types/sessionIt'; import { type AccessibilityId, USERNAME } from '../../types/testing'; import { BlockedContactsSettings } from '../locators'; import { Contact } from '../locators/global'; -import { MessageRequestsBanner, PlusButton } from '../locators/home'; +import { MessageRequestItem, MessageRequestsBanner, PlusButton } from '../locators/home'; import { ConversationsMenuItem, UserSettings } from '../locators/settings'; import { sleepFor } from '../utils'; import { newUser } from '../utils/create_account'; @@ -35,7 +35,7 @@ async function blockedRequest(platform: SupportedPlatformsType, testInfo: TestIn // Bob clicks on message request banner await device2.clickOnElementAll(new MessageRequestsBanner(device2)); // Bob clicks on request conversation item - await device2.clickOnByAccessibilityID('Message request'); + await device2.clickOnElementAll(new MessageRequestItem(device2)); // Bob clicks on block option await device2.clickOnByAccessibilityID('Block message request'); // Confirm block on android diff --git a/run/test/specs/message_requests_decline.spec.ts b/run/test/specs/message_requests_decline.spec.ts index 65078612f..c4cc1fcf4 100644 --- a/run/test/specs/message_requests_decline.spec.ts +++ b/run/test/specs/message_requests_decline.spec.ts @@ -29,7 +29,7 @@ async function declineRequest(platform: SupportedPlatformsType, testInfo: TestIn // Bob clicks on message request banner await device2.clickOnElementAll(new MessageRequestsBanner(device2)); // Bob clicks on request conversation item - await device2.clickOnByAccessibilityID('Message request'); + await device2.clickOnElementAll(new MessageRequestItem(device2)); // Check message request appears on linked device (device 3) await device3.clickOnElementAll(new MessageRequestsBanner(device3)); await device3.waitForTextElementToBePresent(new MessageRequestItem(device3)); diff --git a/run/test/specs/message_requests_delete.spec.ts b/run/test/specs/message_requests_delete.spec.ts index 6657a57ca..b5a9e6f57 100644 --- a/run/test/specs/message_requests_delete.spec.ts +++ b/run/test/specs/message_requests_delete.spec.ts @@ -27,7 +27,7 @@ async function deleteRequest(platform: SupportedPlatformsType, testInfo: TestInf // Bob clicks on message request banner await device2.clickOnElementAll(new MessageRequestsBanner(device2)); // Swipe left on ios - await device2.onIOS().swipeLeftAny('Message request'); + await device2.onIOS().swipeLeftAny(new MessageRequestItem(device2).build().selector); await device2.onAndroid().longPress(new MessageRequestItem(device2)); await device2.clickOnElementAll(new DeleteMessageRequestButton(device2)); await device2.checkModalStrings(tStripped('delete'), tStripped('messageRequestsContactDelete')); diff --git a/run/test/specs/message_voice.spec.ts b/run/test/specs/message_voice.spec.ts index e5802e79d..1e155aefe 100644 --- a/run/test/specs/message_voice.spec.ts +++ b/run/test/specs/message_voice.spec.ts @@ -29,7 +29,9 @@ async function sendVoiceMessage(platform: SupportedPlatformsType, testInfo: Test await alice1.waitForTextElementToBePresent(new VoiceMessage(alice1)); await bob1.trustAttachments(alice.userName); await sleepFor(500); - await bob1.longPressMessage(new VoiceMessage(bob1)); + // The voice message long tap must be offset so that it doesn't tap the scrubber + // As this starts playback and does not open the long press menu + await bob1.longPressMessage(new VoiceMessage(bob1), { offset: { x: 0, y: 100 } }); await bob1.clickOnByAccessibilityID('Reply to message'); await sleepFor(500); // Let the UI settle before finding message input and typing await bob1.sendMessage(replyMessage); diff --git a/run/test/specs/network_page_refresh_page.spec.ts b/run/test/specs/network_page_refresh_page.spec.ts index 64c2173ec..bcc8010d6 100644 --- a/run/test/specs/network_page_refresh_page.spec.ts +++ b/run/test/specs/network_page_refresh_page.spec.ts @@ -17,9 +17,6 @@ bothPlatformsIt({ parent: 'Network Page', }, allureDescription: `Verifies that the Network Page refreshes and updates the "Last updated" timestamp correctly.`, - allureLinks: { - android: 'SES-4884', - }, }); async function refreshNetworkPage(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/qr_codes.spec.ts b/run/test/specs/qr_codes.spec.ts new file mode 100644 index 000000000..a22e20af0 --- /dev/null +++ b/run/test/specs/qr_codes.spec.ts @@ -0,0 +1,159 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { communities } from '../../constants/community'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { InteractionPoints, USERNAME } from '../../types/testing'; +import { GrantCameraAccessButton, ImagePermissionsModalAllow, ScanQRTab } from '../locators'; +import { ConversationHeaderName, ConversationSettings } from '../locators/conversation'; +import { AccountIDDisplay, ContinueButton } from '../locators/global'; +import { PlusButton } from '../locators/home'; +import { AccountRestoreButton, FastModeRadio } from '../locators/onboarding'; +import { RecoveryPasswordMenuItem, UserSettings, ViewQR } from '../locators/settings'; +import { JoinCommunityOption, NewMessageOption } from '../locators/start_conversation'; +import { open_Alice1_bob1_notfriends } from '../state_builder'; +import { clickOnCoordinates, sleepFor, verify } from '../utils'; +import { joinCommunity } from '../utils/community'; +import { newUser } from '../utils/create_account'; +import { truncatePubkey } from '../utils/get_account_id'; +import { closeApp, openAppTwoDevices, SupportedPlatformsType } from '../utils/open_app'; +import { handleNotificationPermissions } from '../utils/permissions'; + +androidIt({ + title: 'Restore account from QR code', + risk: 'high', + testCb: qrCodeSeedPhrase, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Onboarding', + suite: 'Restore account', + }, + allureDescription: + 'Verifies that an account can be restored on a second device by scanning a recovery phrase QR code', +}); + +androidIt({ + title: 'New Conversation from QR code', + risk: 'high', + testCb: qrCodeAccountID, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'New Conversation', + suite: 'New Message', + }, + allureDescription: `Verifies that a new conversation can be started by scanning another user's Account ID QR code`, +}); + +androidIt({ + title: 'Join Community from QR code', + risk: 'medium', + testCb: qrCodeCommunity, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'New Conversation', + suite: 'Join Community', + }, + allureDescription: 'Verifies that a community can be joined by scanning a community QR code', +}); + +async function qrCodeSeedPhrase(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device1, device2 } = await openAppTwoDevices(platform, testInfo); + const firstAccountID = await test.step(TestSteps.SETUP.NEW_USER, async () => { + await newUser(device1, USERNAME.ALICE, { saveUserData: false }); + await device1.clickOnElementAll(new UserSettings(device1)); + const firstAccountIDElement = await device1.waitForTextElementToBePresent( + new AccountIDDisplay(device1) + ); + return device1.getTextFromElement(firstAccountIDElement); + }); + const base64 = await test.step(TestSteps.OPEN.GENERIC('Recovery Password QR code'), async () => { + await device1.clickOnElementAll(new RecoveryPasswordMenuItem(device1)); + await device1.clickOnElementAll(new ViewQR(device1)); + await sleepFor(500); + return device1.getScreenshot(); + }); + await test.step(TestSteps.SETUP.RESTORE_ACCOUNT(USERNAME.ALICE), async () => { + await device2.injectImageToScene(base64); + await device2.clickOnElementAll(new AccountRestoreButton(device2)); + await device2.clickOnElementAll(new ScanQRTab(device2)); + await device2.clickOnElementAll(new GrantCameraAccessButton(device2)); + await device2.clickOnElementAll(new ImagePermissionsModalAllow(device2)); + await device2.clickOnElementAll(new FastModeRadio(device2)); + await device2.clickOnElementAll(new ContinueButton(device2)); + await handleNotificationPermissions(device2, true); + }); + await test.step('Verify the correct account has been restored', async () => { + await device2.clickOnElementAll(new UserSettings(device2)); + const secondAccountIDElement = await device2.waitForTextElementToBePresent( + new AccountIDDisplay(device2) + ); + const secondAccountID = await device2.getTextFromElement(secondAccountIDElement); + verify(firstAccountID, 'The account recovered from QR code is not the right one').toBe( + secondAccountID + ); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device1, device2); + }); +} + +async function qrCodeAccountID(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { + devices: { alice1, bob1 }, + prebuilt: { alice }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_bob1_notfriends({ platform, testInfo }); + }); + const base64 = await test.step(TestSteps.OPEN.GENERIC('Account ID QR code'), async () => { + await alice1.clickOnElementAll(new PlusButton(alice1)); + await sleepFor(500); + return alice1.getScreenshot(); + }); + await test.step(TestSteps.NEW_CONVERSATION.NEW_MESSAGE, async () => { + await bob1.injectImageToScene(base64); + await bob1.clickOnElementAll(new PlusButton(bob1)); + await bob1.clickOnElementAll(new NewMessageOption(bob1)); + await bob1.clickOnElementAll(new ScanQRTab(bob1)); + await bob1.clickOnElementAll(new GrantCameraAccessButton(bob1)); + await bob1.clickOnElementAll(new ImagePermissionsModalAllow(bob1)); + }); + await test.step(`Verify conversation with ${alice.userName} opened`, async () => { + const truncatedPubkey = truncatePubkey(alice.accountID, platform); + await bob1.waitForTextElementToBePresent(new ConversationHeaderName(bob1, truncatedPubkey)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} + +async function qrCodeCommunity(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { + devices: { alice1, bob1 }, + prebuilt: { bob }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_bob1_notfriends({ platform, testInfo }); + }); + const base64 = await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await clickOnCoordinates(alice1, InteractionPoints.AndroidConvoSettingsQRCode); + await sleepFor(500); + return alice1.getScreenshot(); + }); + await test.step(`${bob.userName} joins community via QR scan`, async () => { + await bob1.clickOnElementAll(new PlusButton(bob1)); + await bob1.injectImageToScene(base64); + await bob1.clickOnElementAll(new JoinCommunityOption(bob1)); + await bob1.clickOnElementAll(new ScanQRTab(bob1)); + await bob1.clickOnElementAll(new GrantCameraAccessButton(bob1)); + await bob1.clickOnElementAll(new ImagePermissionsModalAllow(bob1)); + }); + await test.step(`Verify ${bob.userName} joined the community`, async () => { + await bob1.waitForTextElementToBePresent( + new ConversationHeaderName(bob1, communities.testCommunity.name) + ); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} diff --git a/run/test/specs/recovery_banner.spec.ts b/run/test/specs/recovery_banner.spec.ts new file mode 100644 index 000000000..898bcac22 --- /dev/null +++ b/run/test/specs/recovery_banner.spec.ts @@ -0,0 +1,122 @@ +import { test, type TestInfo } from '@playwright/test'; +import { USERNAME } from '@session-foundation/qa-seeder'; + +import { communities } from '../../constants/community'; +import { TestSteps } from '../../types/allure'; +import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { androidIt } from '../../types/sessionIt'; +import { ConversationItem, PlusButton } from '../locators/home'; +import { RecoveryPhraseContainer, RevealRecoveryPhraseButton } from '../locators/settings'; +import { joinCommunities, joinCommunity } from '../utils/community'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; + +androidIt({ + title: 'Recovery password banner only shows after 3 conversations', + risk: 'medium', + testCb: bannerShowsThreeConvos, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Settings', + suite: 'Recovery Password', + }, + allureDescription: + 'Verifies that the recovery password banner only shows after the user has at least three conversations.', +}); + +androidIt({ + title: 'Recovery password banner disappears after being opened', + risk: 'medium', + testCb: bannerDisappearsAfterOpened, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Settings', + suite: 'Recovery Password', + }, + allureDescription: 'Verifies that the recovery password banner disappears after first opened.', +}); + +androidIt({ + title: 'Recovery password banner persists with less than 3 conversations', + risk: 'medium', + testCb: bannerPersists, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Settings', + suite: 'Recovery Password', + }, + allureDescription: + 'Verifies that the recovery password banner does not disappear if the conversation count drops below 3', +}); + +async function bannerShouldNotShow(device: DeviceWrapper) { + await device.waitForTextElementToBePresent(new PlusButton(device)); + await device.verifyElementNotPresent(new RevealRecoveryPhraseButton(device)); + device.log('On home screen, banner did not appear'); +} + +async function bannerShouldShow(device: DeviceWrapper) { + await device.waitForTextElementToBePresent(new PlusButton(device)); + await device.waitForTextElementToBePresent(new RevealRecoveryPhraseButton(device)); + device.log('On home screen, banner appeared'); +} + +async function bannerShowsThreeConvos(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Create three conversations, verify banner only appears after the third', async () => { + for (const community of Object.values(communities).slice(0, 3)) { + await bannerShouldNotShow(device); + await joinCommunity(device, community.link, community.name); + await device.navigateBack(); + } + await bannerShouldShow(device); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function bannerDisappearsAfterOpened(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Create three conversations, verify banner does not reappear after being opened', async () => { + await joinCommunities(device, 3); + await bannerShouldShow(device); + await device.clickOnElementAll(new RevealRecoveryPhraseButton(device)); + await device.waitForTextElementToBePresent(new RecoveryPhraseContainer(device)); + await device.navigateBack(); + await bannerShouldNotShow(device); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function bannerPersists(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Create three conversations, verify banner persists after a conversation is deleted', async () => { + await joinCommunities(device, 3); + await bannerShouldShow(device); + await device.longPressConversation(communities.testCommunity.name); + await device.clickOnElementAll({ strategy: 'accessibility id', selector: 'Leave' }); // Long press options + await device.clickOnElementAll({ strategy: 'accessibility id', selector: 'Leave' }); // Modal confirm + await device.verifyElementNotPresent( + new ConversationItem(device, communities.testCommunity.name) + ); + await bannerShouldShow(device); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} diff --git a/run/test/specs/review_positive.spec.ts b/run/test/specs/review_positive.spec.ts index 86a8ff268..8de5d6491 100644 --- a/run/test/specs/review_positive.spec.ts +++ b/run/test/specs/review_positive.spec.ts @@ -28,11 +28,6 @@ bothPlatformsIt({ async function reviewPromptPositive(platform: SupportedPlatformsType, testInfo: TestInfo) { const storevariant = platform === 'android' ? 'Google Play Store' : 'App Store'; - // Platform specific string for the Rate Session modal - const rateModalDescriptionString = - platform === 'android' - ? tStripped('rateSessionModalDescription', { storevariant }) - : tStripped('rateSessionModalDescriptionUpdated', { storevariant }); const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); await newUser(device, USERNAME.ALICE, { saveUserData: false }); @@ -52,9 +47,12 @@ async function reviewPromptPositive(platform: SupportedPlatformsType, testInfo: await device.clickOnElementAll(new ReviewPromptItsGreatButton(device)); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Rate Session'), async () => { - await device.checkModalStrings(tStripped('rateSession'), rateModalDescriptionString); + await device.checkModalStrings( + tStripped('rateSession'), + tStripped('rateSessionModalDescriptionUpdated', { storevariant }) + ); await device.waitForTextElementToBePresent(new ReviewPromptRateAppButton(device)); - await device.onAndroid().waitForTextElementToBePresent(new ReviewPromptNotNowButton(device)); // On iOS the modal only has the Rate button + await device.verifyElementNotPresent(new ReviewPromptNotNowButton(device)); // This modal now only has the Rate button }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); diff --git a/run/test/specs/slow_mode_background.spec.ts b/run/test/specs/slow_mode_background.spec.ts new file mode 100644 index 000000000..9d1a331f1 --- /dev/null +++ b/run/test/specs/slow_mode_background.spec.ts @@ -0,0 +1,71 @@ +import { test, TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { BackgroundPermsAllowButton } from '../locators/home'; +import { NotificationsMenuItem, UserSettings } from '../locators/settings'; +import { newUser } from '../utils/create_account'; +import { + closeApp, + openAppOnPlatformSingleDevice, + SupportedPlatformsType, + uninstallApp, +} from '../utils/open_app'; + +androidIt({ + title: 'Slow mode background perms modal', + risk: 'medium', + testCb: slowModeBackgroundModal, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Settings', + suite: 'Notifications', + }, + allureDescription: + 'Verifies the slow mode background permissions modal appears, accepting it shows the system dialog.', +}); + +async function slowModeBackgroundModal(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { + saveUserData: false, + fastMode: false, + }); + return { device }; + }); + try { + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Background Permissions'), async () => { + await device.checkModalStrings( + tStripped('runSessionBackground'), + tStripped('runSessionBackgroundDescription') + ); + await device.clickOnElementAll(new BackgroundPermsAllowButton(device)); + await device.clickOnElementAll({ + strategy: 'id', + selector: 'android:id/button1', + text: 'Allow', + }); + }); + await test.step('Verify Background usage toggle is turned ON', async () => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new NotificationsMenuItem(device)); + await device.assertAttribute( + { + strategy: 'id', + selector: 'preferences-option-whitelist-toggle', + }, + 'checked', + 'true' + ); + }); + } finally { + // App must be uninstalled to prevent state pollution (background permission is tied to app install) + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + await uninstallApp(device, platform); + }); + } +} diff --git a/run/test/specs/upm_homescreen.spec.ts b/run/test/specs/upm_homescreen.spec.ts index 1ef5c7ee6..aad3222c8 100644 --- a/run/test/specs/upm_homescreen.spec.ts +++ b/run/test/specs/upm_homescreen.spec.ts @@ -48,7 +48,7 @@ async function upmHomeScreen(platform: SupportedPlatformsType, testInfo: TestInf }); const elText = await alice1.getTextFromElement(el); const normalized = elText.replace(/\s+/g, ''); // account id comes in two lines - const expected = bob.sessionId.trim(); + const expected = bob.accountID.trim(); if (normalized !== expected) { console.log(`Expected: ${expected} Observed: ${normalized}`); diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts new file mode 100644 index 000000000..c96e5a7b2 --- /dev/null +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -0,0 +1,152 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { ChangeProfilePictureButton, CloseSettings } from '../locators'; +import { ConversationSettings, MessageBody } from '../locators/conversation'; +import { ConversationItem } from '../locators/home'; +import { + PathMenuItem, + ProAnimatedDisplayPictureModalDescription, + UserAvatar, + UserSettings, +} from '../locators/settings'; +import { open_Alice1_Bob1_friends } from '../state_builder'; +import { IOS_PRO_CONTEXT } from '../utils/capabilities_ios'; +import { newUser } from '../utils/create_account'; +import { makeAccountPro } from '../utils/mock_pro'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; +import { forceStopAndRestart } from '../utils/utilities'; +import { verifyPageScreenshot } from '../utils/verify_screenshots'; + +bothPlatformsIt({ + title: 'Upload animated profile picture (non Pro)', + risk: 'high', + countOfDevicesNeeded: 1, + testCb: nonProAnimatedDP, + isPro: true, + allureSuites: { + parent: 'User Actions', + suite: 'Change Profile Picture', + }, +}); + +bothPlatformsIt({ + title: 'Upload animated profile picture (Pro)', + risk: 'high', + countOfDevicesNeeded: 1, + testCb: proAnimatedDP, + isPro: true, + allureSuites: { + parent: 'User Actions', + suite: 'Change Profile Picture', + }, +}); + +bothPlatformsIt({ + title: 'Pro Activated CTA', + risk: 'low', + countOfDevicesNeeded: 1, + testCb: proActivatedCTA, + isPro: true, + allureSuites: { + parent: 'Session Pro', + }, +}); + +bothPlatformsIt({ + title: 'Animated Profile Picture shows', + risk: 'high', + countOfDevicesNeeded: 2, + testCb: proAnimatedDPShows, + isPro: true, + allureSuites: { + parent: 'Session Pro', + }, +}); + +async function nonProAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, IOS_PRO_CONTEXT); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { + await device.uploadProfilePicture(true); + await device.checkCTA('animatedProfilePicture'); + }); + + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} +async function proActivatedCTA(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, IOS_PRO_CONTEXT); + const alice = await newUser(device, USERNAME.ALICE); + return { device, alice }; + }); + await makeAccountPro({ user: alice, platform }); + await forceStopAndRestart(device); + await device.dismissCTA(); + await test.step('Verify Pro Activated CTA', async () => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new UserAvatar(device)); + await device.waitForTextElementToBePresent(new ChangeProfilePictureButton(device)); + await device.clickOnElementAll(new ProAnimatedDisplayPictureModalDescription(device)); + await device.checkCTA('alreadyActivated'); + await verifyPageScreenshot(device, platform, 'cta_pro_activated', testInfo); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function proAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, IOS_PRO_CONTEXT); + const alice = await newUser(device, USERNAME.ALICE); + return { device, alice }; + }); + await makeAccountPro({ user: alice, platform }); + await forceStopAndRestart(device); + await device.dismissCTA(); + await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { + await device.uploadProfilePicture(true); + }); + await device.waitForTextElementToBePresent(new PathMenuItem(device)); + await device.verifyNoCTAShows(); + await device.verifyElementIsAnimated(new UserAvatar(device)); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function proAnimatedDPShows(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { devices, prebuilt } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return await open_Alice1_Bob1_friends({ + platform, + focusFriendsConvo: false, + testInfo, + iOSContext: IOS_PRO_CONTEXT, + }); + }); + const { alice1, bob1 } = devices; + const { alice, bob } = prebuilt; + await makeAccountPro({ user: alice, platform }); + await forceStopAndRestart(alice1); + await alice1.dismissCTA(); + await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { + await alice1.uploadProfilePicture(true); + }); + await alice1.clickOnElementAll(new CloseSettings(alice1)); + await alice1.clickOnElementAll(new ConversationItem(alice1, bob.userName)); + await alice1.sendMessage('Howdy'); + await bob1.clickOnElementAll(new ConversationItem(bob1, alice.userName)); + await bob1.waitForTextElementToBePresent(new MessageBody(bob1, 'Howdy')); + await bob1.verifyElementIsAnimated(new ConversationSettings(bob1)); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} diff --git a/run/test/specs/user_actions_block_conversation_options.spec.ts b/run/test/specs/user_actions_block_conversation_options.spec.ts index 41c17d851..f5c709e35 100644 --- a/run/test/specs/user_actions_block_conversation_options.spec.ts +++ b/run/test/specs/user_actions_block_conversation_options.spec.ts @@ -44,6 +44,7 @@ async function blockUserInConversationSettings( await alice1.clickOnElementAll(new ConversationSettings(alice1)); // Select Block option await sleepFor(500); + await alice1.onIOS().scrollDown(); // Blind scroll because Block option is obscured by system UI on iOS await alice1.clickOnElementAll(new BlockUser(alice1)); // Check modal strings await alice1.checkModalStrings( diff --git a/run/test/specs/user_actions_change_username.spec.ts b/run/test/specs/user_actions_change_username.spec.ts index 0a242b628..00d1bad3a 100644 --- a/run/test/specs/user_actions_change_username.spec.ts +++ b/run/test/specs/user_actions_change_username.spec.ts @@ -13,9 +13,6 @@ bothPlatformsIt({ risk: 'medium', countOfDevicesNeeded: 1, testCb: changeUsername, - allureLinks: { - android: 'SES-4277', - }, }); async function changeUsername(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/user_actions_create_contact.spec.ts b/run/test/specs/user_actions_create_contact.spec.ts index cbca337c4..782223b5b 100644 --- a/run/test/specs/user_actions_create_contact.spec.ts +++ b/run/test/specs/user_actions_create_contact.spec.ts @@ -2,7 +2,7 @@ import type { TestInfo } from '@playwright/test'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { ConversationItem, MessageRequestsBanner } from '../locators/home'; +import { ConversationItem } from '../locators/home'; import { newUser } from '../utils/create_account'; import { retryMsgSentForBanner } from '../utils/create_contact'; import { linkedDevice } from '../utils/link_device'; @@ -28,12 +28,8 @@ async function createContact(platform: SupportedPlatformsType, testInfo: TestInf await sleepFor(100); await runOnlyOnIOS(platform, () => retryMsgSentForBanner(platform, device1, device2, 30000)); // this runOnlyOnIOS is needed - await device2.clickOnElementAll(new MessageRequestsBanner(device2)); - await device2.clickOnByAccessibilityID('Message request'); - await device2.clickOnByAccessibilityID('Accept message request'); + await device2.acceptMessageRequestWithButton(); - // Type into message input box - await device2.sendMessage(`Reply-message-${Bob.userName}-to-${Alice.userName}`); // NOTE: This appears to be broken on both platforms: // Verify config message states message request was accepted // "messageRequestsAccepted": "Your message request has been accepted.", diff --git a/run/test/specs/user_actions_delete_contact_ucs.spec.ts b/run/test/specs/user_actions_delete_contact_ucs.spec.ts index 0cbeecfc9..4bfd022e2 100644 --- a/run/test/specs/user_actions_delete_contact_ucs.spec.ts +++ b/run/test/specs/user_actions_delete_contact_ucs.spec.ts @@ -10,7 +10,7 @@ import { DeleteContactMenuItem, MessageBody, } from '../locators/conversation'; -import { ConversationItem, MessageRequestsBanner } from '../locators/home'; +import { ConversationItem, MessageRequestItem, MessageRequestsBanner } from '../locators/home'; import { open_Alice2_Bob1_friends } from '../state_builder'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; @@ -78,7 +78,7 @@ async function deleteContactCS(platform: SupportedPlatformsType, testInfo: TestI await Promise.all( [alice1, alice2].map(async device => { await device.clickOnElementAll(new MessageRequestsBanner(device)); - await device.clickOnByAccessibilityID('Message request'); + await device.clickOnElementAll(new MessageRequestItem(device)); await device.waitForTextElementToBePresent(new MessageBody(device, newMessage)); }) ); diff --git a/run/test/specs/user_actions_hide_recovery_password.spec.ts b/run/test/specs/user_actions_hide_recovery_password.spec.ts index 5e04c5a89..6a564b7c3 100644 --- a/run/test/specs/user_actions_hide_recovery_password.spec.ts +++ b/run/test/specs/user_actions_hide_recovery_password.spec.ts @@ -48,7 +48,7 @@ async function hideRecoveryPassword(platform: SupportedPlatformsType, testInfo: maxWait: 1000, }); // Should be taken back to Settings page after hiding recovery password - await device1.onAndroid().scrollUp(); + await device1.scrollUp(); await device1.waitForTextElementToBePresent(new AccountIDDisplay(device1)); // Check that linked device still has Recovery Password await device2.clickOnElementAll(new UserSettings(device2)); diff --git a/run/test/specs/user_actions_pin_unpin.spec.ts b/run/test/specs/user_actions_pin_unpin.spec.ts new file mode 100644 index 000000000..f76442682 --- /dev/null +++ b/run/test/specs/user_actions_pin_unpin.spec.ts @@ -0,0 +1,158 @@ +import { test, type TestInfo } from '@playwright/test'; +import { USERNAME } from '@session-foundation/qa-seeder'; + +import { communities } from '../../constants/community'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { ConversationPinnedIcon, PlusButton } from '../locators/home'; +import { IOS_PRO_CONTEXT } from '../utils/capabilities_ios'; +import { joinCommunities } from '../utils/community'; +import { assertPinOrder, getConversationOrder } from '../utils/conversation_order'; +import { newUser } from '../utils/create_account'; +import { makeAccountPro } from '../utils/mock_pro'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; +import { forceStopAndRestart } from '../utils/utilities'; + +bothPlatformsIt({ + title: 'Pin and unpin conversation', + risk: 'medium', + testCb: pinConversation, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'User Actions', + suite: 'Pin/Unpin', + }, + allureDescription: + 'Verifies that pinning moves a conversation to the top of the list and unpinning restores the original order', +}); + +bothPlatformsIt({ + title: 'Pinned conversation limit (non Pro)', + risk: 'high', + testCb: nonProPinnedLimit, + countOfDevicesNeeded: 1, + isPro: true, + allureSuites: { + parent: 'Session Pro', + }, + allureDescription: 'Verifies that a standard user can only pin 5 conversations', +}); + +bothPlatformsIt({ + title: 'Pinned conversation limit (Pro)', + risk: 'high', + testCb: proPinnedLimit, + countOfDevicesNeeded: 1, + isPro: true, + allureSuites: { + parent: 'Session Pro', + }, + allureDescription: 'Verifies that a Pro user can pin 5+ conversations', +}); + +async function pinConversation(platform: SupportedPlatformsType, testInfo: TestInfo) { + const numCommunities = 2; + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITIES(numCommunities), async () => { + await joinCommunities(device, numCommunities); + }); + let beforeOrder: string[] = []; + let toPin = ''; + await test.step('Capture conversation order before pinning', async () => { + beforeOrder = await getConversationOrder(device); + toPin = beforeOrder[beforeOrder.length - 1]; + device.log(`Pinning last conversation: "${toPin}"`); + }); + await test.step(`Pin "${toPin}"`, async () => { + await device.pinConversation(toPin); + }); + await test.step('Assert pinned conversation moved to top', async () => { + const afterOrder = await getConversationOrder(device); + assertPinOrder(beforeOrder, [toPin], afterOrder); + }); + if (platform === 'android') { + await test.step('Assert pin icon is visible on pinned conversation', async () => { + await device.waitForTextElementToBePresent(new ConversationPinnedIcon(device, toPin)); + }); + } + await test.step(`Unpin "${toPin}"`, async () => { + await device.unpinConversation(toPin); + }); + await test.step('Assert order restored after unpinning', async () => { + const afterUnpinOrder = await getConversationOrder(device); + assertPinOrder(beforeOrder, [], afterUnpinOrder); + }); + if (platform === 'android') { + await test.step('Assert pin icon is gone after unpinning', async () => { + await device.verifyElementNotPresent(new ConversationPinnedIcon(device, toPin)); + }); + } + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function nonProPinnedLimit(platform: SupportedPlatformsType, testInfo: TestInfo) { + const numCommunities = 6; + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, IOS_PRO_CONTEXT); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITIES(numCommunities), async () => { + await joinCommunities(device, numCommunities); + }); + await test.step(TestSteps.USER_ACTIONS.PIN_CONVERSATIONS(numCommunities), async () => { + let pinned = 0; + for (const community of Object.values(communities).slice(0, numCommunities)) { + await device.pinConversation(community.name); + pinned++; + if (pinned < numCommunities) { + await device.waitForTextElementToBePresent(new PlusButton(device)); + await device.verifyNoCTAShows(); + await device + .onAndroid() + .waitForTextElementToBePresent(new ConversationPinnedIcon(device, community.name)); + } else { + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Pinned Conversations CTA'), async () => { + await device.checkCTA('pinnedConversations'); + }); + } + } + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function proPinnedLimit(platform: SupportedPlatformsType, testInfo: TestInfo) { + const numCommunities = 6; + const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, IOS_PRO_CONTEXT); + const alice = await newUser(device, USERNAME.ALICE); + return { device, alice }; + }); + await makeAccountPro({ user: alice, platform }); + await forceStopAndRestart(device); + await device.dismissCTA(); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITIES(numCommunities), async () => { + await joinCommunities(device, numCommunities); + }); + await test.step(TestSteps.USER_ACTIONS.PIN_CONVERSATIONS(numCommunities), async () => { + for (const community of Object.values(communities).slice(0, numCommunities)) { + await device.pinConversation(community.name); + await device + .onAndroid() + .waitForTextElementToBePresent(new ConversationPinnedIcon(device, community.name)); + await device.waitForTextElementToBePresent(new PlusButton(device)); + } + await device.verifyNoCTAShows(); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} diff --git a/run/test/specs/user_actions_share_to_session.spec.ts b/run/test/specs/user_actions_share_to_session.spec.ts index 952c18384..e5d6af1e5 100644 --- a/run/test/specs/user_actions_share_to_session.spec.ts +++ b/run/test/specs/user_actions_share_to_session.spec.ts @@ -2,10 +2,16 @@ import { test, type TestInfo } from '@playwright/test'; import { testImage } from '../../constants/testfiles'; import { TestSteps } from '../../types/allure'; -import { bothPlatformsIt } from '../../types/sessionIt'; +import { androidIt, bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { ImageName, ShareExtensionIcon } from '../locators'; -import { MessageBody, MessageInput, SendButton } from '../locators/conversation'; +import { + ConversationHeaderName, + MediaMessage, + MessageBody, + MessageInput, + SendButton, +} from '../locators/conversation'; import { PhotoLibrary } from '../locators/external'; import { Contact } from '../locators/global'; import { open_Alice1_Bob1_friends } from '../state_builder'; @@ -14,7 +20,7 @@ import { handlePhotosFirstTimeOpen } from '../utils/handle_first_open'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ - title: 'Share to session', + title: 'Share to Session', risk: 'medium', testCb: shareToSession, countOfDevicesNeeded: 2, @@ -25,6 +31,19 @@ bothPlatformsIt({ allureDescription: `Verifies that a user can share an image from the photo gallery to Session`, }); +// On iOS the Share button just opens the regular share sheet, same as 'Share to Session' - no need to test separately. +androidIt({ + title: 'Share within Session', + risk: 'medium', + testCb: shareInSession, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'User Actions', + suite: 'Share to Session', + }, + allureDescription: `Verifies that a user can share an image from one Session conversation to another (forwarding)`, +}); + async function shareToSession(platform: SupportedPlatformsType, testInfo: TestInfo) { const { devices: { alice1, bob1 }, @@ -76,3 +95,35 @@ async function shareToSession(platform: SupportedPlatformsType, testInfo: TestIn await closeApp(alice1, bob1); }); } + +async function shareInSession(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { + devices: { alice1, bob1 }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_friends({ + platform, + focusFriendsConvo: true, + testInfo, + }); + }); + const testMessage = 'Testing forwarding an image within Session'; + await test.step(TestSteps.SEND.IMAGE, async () => { + await alice1.sendImage(testMessage); + }); + await test.step('Share image to another Session conversation', async () => { + await alice1.clickOnElementAll(new MediaMessage(alice1)); + await alice1.clickOnElementAll({ strategy: 'accessibility id', selector: 'Share' }); + await alice1.clickOnElementAll(new Contact(alice1, 'Note to Self')); + await alice1.inputText(testMessage, new MessageInput(alice1)); + await alice1.clickOnElementAll(new SendButton(alice1)); + await alice1.waitForLoadingOnboarding(); + }); + await test.step(TestSteps.VERIFY.MESSAGE_RECEIVED, async () => { + await alice1.waitForTextElementToBePresent(new ConversationHeaderName(alice1, 'Note to Self')); + await alice1.waitForTextElementToBePresent(new MessageBody(alice1, testMessage)); + await alice1.waitForTextElementToBePresent(new MediaMessage(alice1)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} diff --git a/run/test/specs/user_actions_unblock_user.spec.ts b/run/test/specs/user_actions_unblock_user.spec.ts index f5f14aef8..1facd08d7 100644 --- a/run/test/specs/user_actions_unblock_user.spec.ts +++ b/run/test/specs/user_actions_unblock_user.spec.ts @@ -30,6 +30,7 @@ async function unblockUser(platform: SupportedPlatformsType, testInfo: TestInfo) }); const blockedMessage = `Blocked message from ${bob.userName} to ${alice.userName}`; await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.onIOS().scrollDown(); // Blind scroll because Block option is obscured by system UI on iOS await alice1.clickOnElementAll(new BlockUser(alice1)); await alice1.checkModalStrings( tStripped('block'), diff --git a/run/test/specs/visual_settings.spec.ts b/run/test/specs/visual_settings.spec.ts index 9050a88f9..16d4b021f 100644 --- a/run/test/specs/visual_settings.spec.ts +++ b/run/test/specs/visual_settings.spec.ts @@ -45,6 +45,7 @@ const testCases = [ screenshotFile: 'settings_notifications', navigation: async (device: DeviceWrapper) => { await device.clickOnElementAll(new UserSettings(device)); + await device.onIOS().scrollDown(); await device.clickOnElementAll(new NotificationsMenuItem(device)); await sleepFor(1_000); // This one otherwise captures a black screen }, diff --git a/run/test/specs/voice_calls.spec.ts b/run/test/specs/voice_calls.spec.ts index 49925f26a..c71789ccf 100644 --- a/run/test/specs/voice_calls.spec.ts +++ b/run/test/specs/voice_calls.spec.ts @@ -4,7 +4,8 @@ import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsItSeparate } from '../../types/sessionIt'; import { CloseSettings } from '../locators'; -import { CallButton, NotificationsModalButton, NotificationSwitch } from '../locators/conversation'; +import { CallButton, NotificationSwitch } from '../locators/conversation'; +import { SettingsModalsEnableButton } from '../locators/settings'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils/index'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; @@ -58,7 +59,7 @@ async function voiceCallIos(platform: SupportedPlatformsType, testInfo: TestInfo tStripped('callsVoiceAndVideoModalDescription') ); }); - await alice1.clickOnByAccessibilityID('Continue'); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); // Need to allow microphone access await alice1.modalPopup({ strategy: 'accessibility id', selector: 'Allow' }); await sleepFor(1_000); @@ -103,7 +104,7 @@ async function voiceCallIos(platform: SupportedPlatformsType, testInfo: TestInfo tStripped('callsVoiceAndVideoBeta'), tStripped('callsVoiceAndVideoModalDescription') ); - await bob1.clickOnByAccessibilityID('Continue'); + await bob1.clickOnElementAll(new SettingsModalsEnableButton(bob1)); // Need to allow microphone access await bob1.modalPopup({ strategy: 'accessibility id', selector: 'Allow' }); await sleepFor(1_000); @@ -178,7 +179,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test tStripped('callsVoiceAndVideoBeta'), tStripped('callsVoiceAndVideoModalDescription') ); - await alice1.clickOnByAccessibilityID('Enable'); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); }); await alice1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' @@ -188,7 +189,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test tStripped('sessionNotifications'), tStripped('callsNotificationsRequired') ); - await alice1.clickOnElementAll(new NotificationsModalButton(alice1)); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); await alice1.clickOnElementAll(new NotificationSwitch(alice1)); }); await alice1.navigateBack(false); @@ -231,11 +232,11 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test strategy: 'accessibility id', selector: 'Settings', }); - await bob1.clickOnByAccessibilityID('Enable'); + await bob1.clickOnElementAll(new SettingsModalsEnableButton(bob1)); await bob1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' ); - await bob1.clickOnElementAll(new NotificationsModalButton(bob1)); + await bob1.clickOnElementAll(new SettingsModalsEnableButton(bob1)); await bob1.clickOnElementAll(new NotificationSwitch(bob1)); await bob1.navigateBack(false); await bob1.navigateBack(false); diff --git a/run/test/state_builder/index.ts b/run/test/state_builder/index.ts index e448a9012..f6a33901b 100644 --- a/run/test/state_builder/index.ts +++ b/run/test/state_builder/index.ts @@ -14,16 +14,26 @@ import { } from '@session-foundation/qa-seeder'; import type { DeviceWrapper } from '../../types/DeviceWrapper'; +import type { User } from '../../types/testing'; import { ConversationItem } from '../locators/home'; +import { IOSTestContext } from '../utils/capabilities_ios'; import { getNetworkTarget } from '../utils/devnet'; import { openAppMultipleDevices, type SupportedPlatformsType } from '../utils/open_app'; import { restoreAccountNoFallback } from '../utils/restore_account'; -type WithAlice = { alice: StateUser }; -type WithBob = { bob: StateUser }; -type WithCharlie = { charlie: StateUser }; -type WithDracula = { dracula: StateUser }; +function toUser(stateUser: StateUser): User { + return { + userName: stateUser.userName, + accountID: stateUser.sessionId, + recoveryPhrase: stateUser.seedPhrase, + }; +} + +type WithAlice = { alice: User }; +type WithBob = { bob: User }; +type WithCharlie = { charlie: User }; +type WithDracula = { dracula: User }; type WithFocusFriendsConvo = { focusFriendsConvo: boolean }; type WithFocusGroupConvo = { focusGroupConvo: boolean }; @@ -91,14 +101,16 @@ async function openAppsWithState m.seedPhrase); await linkDevices(result.devices, seedPhrases); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); const alice1 = result.devices[0]; const bob1 = result.devices[1]; const formattedDevices = { @@ -156,10 +170,12 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ groupName, focusGroupConvo, testInfo, + iOSContext, }: WithPlatform & WithFocusGroupConvo & { groupName: string; testInfo: TestInfo; + iOSContext?: IOSTestContext; }) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 3; @@ -169,6 +185,7 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ stateToBuildKey, groupName, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); @@ -177,9 +194,9 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ const seedPhrases = result.prebuilt.users.map(m => m.seedPhrase); await linkDevices(result.devices, seedPhrases); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; - const charlie = result.prebuilt.users[2]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); + const charlie = toUser(result.prebuilt.users[2]); const alice1 = result.devices[0]; const bob1 = result.devices[1]; @@ -214,10 +231,12 @@ export async function open_Alice2_Bob1_Charlie1_friends_group({ groupName, focusGroupConvo, testInfo, + iOSContext, }: WithPlatform & WithFocusGroupConvo & { groupName: string; testInfo: TestInfo; + iOSContext?: IOSTestContext; }) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 4; @@ -227,15 +246,23 @@ export async function open_Alice2_Bob1_Charlie1_friends_group({ stateToBuildKey, groupName, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); result.devices[2].setDeviceIdentity('charlie1'); result.devices[3].setDeviceIdentity('alice2'); - const [alice, bob, charlie] = result.prebuilt.users; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); + const charlie = toUser(result.prebuilt.users[2]); - const seedPhrases = [alice.seedPhrase, bob.seedPhrase, charlie.seedPhrase, alice.seedPhrase]; + const seedPhrases = [ + alice.recoveryPhrase, + bob.recoveryPhrase, + charlie.recoveryPhrase, + alice.recoveryPhrase, + ]; await linkDevices(result.devices, seedPhrases); const [alice1, bob1, charlie1, alice2] = result.devices; @@ -275,10 +302,12 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ groupName, focusGroupConvo = true, testInfo, + iOSContext, }: WithPlatform & WithFocusGroupConvo & { groupName: string; testInfo: TestInfo; + iOSContext?: IOSTestContext; }) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 4; @@ -288,6 +317,7 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ stateToBuildKey, groupName, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); @@ -308,9 +338,9 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ charlie1, unknown1: result.devices[3], // not assigned yet }; - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; - const charlie = result.prebuilt.users[2]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); + const charlie = toUser(result.prebuilt.users[2]); const formattedUsers: WithUsers<3> = { alice, bob, @@ -330,7 +360,11 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ }; } -export async function open_Alice2({ platform, testInfo }: WithPlatform & { testInfo: TestInfo }) { +export async function open_Alice2({ + platform, + testInfo, + iOSContext, +}: WithPlatform & { testInfo: TestInfo; iOSContext?: IOSTestContext }) { const prebuiltStateKey = '1user'; const appsToOpen = 2; const result = await openAppsWithState({ @@ -339,15 +373,16 @@ export async function open_Alice2({ platform, testInfo }: WithPlatform & { testI stateToBuildKey: prebuiltStateKey, groupName: undefined, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('alice2'); // we want the first user to have the first 2 devices linked - const alice = result.prebuilt.users[0]; + const alice = toUser(result.prebuilt.users[0]); const alice1 = result.devices[0]; const alice2 = result.devices[1]; - const seedPhrases = [alice.seedPhrase, alice.seedPhrase]; + const seedPhrases = [alice.recoveryPhrase, alice.recoveryPhrase]; await linkDevices(result.devices, seedPhrases); const formattedUsers: WithUsers<1> = { @@ -370,7 +405,8 @@ export async function open_Alice2({ platform, testInfo }: WithPlatform & { testI export async function open_Alice1_bob1_notfriends({ platform, testInfo, -}: WithPlatform & { testInfo: TestInfo }) { + iOSContext, +}: WithPlatform & { testInfo: TestInfo; iOSContext?: IOSTestContext }) { const appsToOpen = 2; const result = await openAppsWithState({ platform, @@ -378,16 +414,17 @@ export async function open_Alice1_bob1_notfriends({ stateToBuildKey: '2users', groupName: undefined, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); const alice1 = result.devices[0]; const bob1 = result.devices[1]; - const seedPhrases = [alice.seedPhrase, bob.seedPhrase]; + const seedPhrases = [alice.recoveryPhrase, bob.recoveryPhrase]; await linkDevices(result.devices, seedPhrases); const formattedUsers: WithUsers<2> = { @@ -408,7 +445,8 @@ export async function open_Alice2_Bob1_friends({ platform, focusFriendsConvo, testInfo, -}: WithPlatform & WithFocusFriendsConvo & { testInfo: TestInfo }) { + iOSContext, +}: WithPlatform & WithFocusFriendsConvo & { testInfo: TestInfo; iOSContext?: IOSTestContext }) { const prebuiltStateKey = '2friends'; const appsToOpen = 3; const result = await openAppsWithState({ @@ -417,14 +455,15 @@ export async function open_Alice2_Bob1_friends({ stateToBuildKey: prebuiltStateKey, groupName: undefined, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('alice2'); result.devices[2].setDeviceIdentity('bob1'); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); // we want the first user to have the first 2 devices linked - const seedPhrases = [alice.seedPhrase, alice.seedPhrase, bob.seedPhrase]; + const seedPhrases = [alice.recoveryPhrase, alice.recoveryPhrase, bob.recoveryPhrase]; await linkDevices(result.devices, seedPhrases); const alice1 = result.devices[0]; diff --git a/run/test/utils/allure/allureHelpers.ts b/run/test/utils/allure/allureHelpers.ts index 2cb1c4e5e..6993aaeb2 100644 --- a/run/test/utils/allure/allureHelpers.ts +++ b/run/test/utils/allure/allureHelpers.ts @@ -17,6 +17,7 @@ export interface ReportContext { build: string; artifact: string; risk: string; + networkTarget: string; runNumber: number; runAttempt: number; runID: number; @@ -33,6 +34,7 @@ export function getReportContextFromEnv(): ReportContext { const build = process.env.BUILD_NUMBER; const artifact = process.env.APK_URL; const risk = process.env.RISK?.trim() || 'full'; + const networkTarget = process.env.NETWORK_TARGET || 'mainnet'; // Default to mainnet for iOS const runNumber = Number(process.env.GITHUB_RUN_NUMBER); const runAttempt = Number(process.env.GITHUB_RUN_ATTEMPT); const runID = Number(process.env.GITHUB_RUN_ID); @@ -64,6 +66,7 @@ export function getReportContextFromEnv(): ReportContext { build, artifact, risk, + networkTarget, runNumber, runAttempt, runID, @@ -72,6 +75,7 @@ export function getReportContextFromEnv(): ReportContext { githubRunUrl, }; } + // The Environment block shows up in the report dashboard export async function writeEnvironmentProperties(ctx: ReportContext) { await fs.ensureDir(allureResultsDir); @@ -79,6 +83,7 @@ export async function writeEnvironmentProperties(ctx: ReportContext) { `platform=${ctx.platform}`, `build=${ctx.build}`, `artifact=${ctx.artifact}`, + `network=${ctx.networkTarget}`, `appium=https://github.com/session-foundation/session-appium/commit/${getGitCommitSha()}`, `branch=${getGitBranch()}`, ].join('\n'); @@ -86,6 +91,7 @@ export async function writeEnvironmentProperties(ctx: ReportContext) { await fs.writeFile(path.join(allureResultsDir, 'environment.properties'), content); console.log('Created environment.properties'); } + // The Executors block shows up in the report dashboard and links back to the CI run // It also allows us to access history through trend graphs and test results export async function writeExecutorJson(ctx: ReportContext) { @@ -106,6 +112,7 @@ export async function writeExecutorJson(ctx: ReportContext) { ); console.log('Created executor.json'); } + // The metadata.json is a custom file for the front-end display export async function writeMetadataJson(ctx: ReportContext) { const metadata = { @@ -179,6 +186,7 @@ function getGitCommitSha(): string { function getGitBranch(): string { return execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); } + // Handle test-level metadata such as suites, test description or linked issues export async function setupAllureTestInfo({ suites, diff --git a/run/test/utils/allure/publishReport.ts b/run/test/utils/allure/publishReport.ts index db6e94182..8a795eebc 100644 --- a/run/test/utils/allure/publishReport.ts +++ b/run/test/utils/allure/publishReport.ts @@ -12,19 +12,12 @@ import { // Bail out early if not on CI if (process.env.CI !== '1' || process.env.ALLURE_ENABLED === 'false') { - console.log('Skipping closeRun (CI != 1 or ALLURE_ENABLED is false)'); + console.log('Skipping publishReport (CI != 1 or ALLURE_ENABLED is false)'); process.exit(0); } // Publishes the report directory to the gh-pages branch of the repo function publishToGhPages(dir: string, dest: string, repo: string, message: string): Promise { - // Ensure .nojekyll file exists to skip Jekyll processing - const nojekyllPath = path.join(dir, '.nojekyll'); - if (!fs.existsSync(nojekyllPath)) { - fs.writeFileSync(nojekyllPath, ''); - console.log('Created .nojekyll file'); - } - return new Promise((resolve, reject) => { void ghpages.publish( dir, @@ -61,7 +54,7 @@ async function publishReport() { const publishedReportName = ctx.reportFolder; const newReportDir = path.join(ctx.platform, publishedReportName); - // Allue manipulation + // Allure manipulation await patchStylesCss(); await patchFilesForLFSCDN(ctx); @@ -86,6 +79,9 @@ async function publishReport() { console.log(`Deploying report to GitHub Pages as: ${publishedReportName}`); + // Clear stale gh-pages cache left by interrupted runs + ghpages.clean(); + // Publish the report to GitHub Pages try { await publishToGhPages( diff --git a/run/test/utils/binaries.ts b/run/test/utils/binaries.ts index a30b5adb3..796ff1f2e 100644 --- a/run/test/utils/binaries.ts +++ b/run/test/utils/binaries.ts @@ -38,39 +38,6 @@ export const getEmulatorFullPath = () => { return fromEnv; }; -export const getAvdManagerFullPath = () => { - const fromEnv = process.env.AVD_MANAGER_FULL_PATH; - - if (!fromEnv) { - throw new Error('env variable `AVD_MANAGER_FULL_PATH` needs to be set'); - } - existsAndFileOrThrow(fromEnv, 'AVD_MANAGER_FULL_PATH'); - - return fromEnv; -}; - -export const getSdkManagerFullPath = () => { - const fromEnv = process.env.SDK_MANAGER_FULL_PATH; - - if (!fromEnv) { - throw new Error('env variable `SDK_MANAGER_FULL_PATH` needs to be set'); - } - - existsAndFileOrThrow(fromEnv, 'SDK_MANAGER_FULL_PATH'); - - return fromEnv; -}; - -export const getAndroidSystemImageToUse = () => { - const fromEnv = process.env.ANDROID_SYSTEM_IMAGE; - - if (!fromEnv) { - throw new Error('env variable `ANDROID_SYSTEM_IMAGE` needs to be set'); - } - - return fromEnv; -}; - export const getRetriesCount = () => { const asNumber = toNumber(process.env.PLAYWRIGHT_RETRIES_COUNT); return isFinite(asNumber) ? asNumber : 0; diff --git a/run/test/utils/capabilities_android.ts b/run/test/utils/capabilities_android.ts index 7a152e4bf..8fbd07688 100644 --- a/run/test/utils/capabilities_android.ts +++ b/run/test/utils/capabilities_android.ts @@ -1,4 +1,3 @@ -import { AppiumAndroidCapabilities, AppiumCapabilities } from '@wdio/types/build/Capabilities'; import { W3CUiautomator2DriverCaps } from 'appium-uiautomator2-driver/build/lib/types'; import dotenv from 'dotenv'; import { isString } from 'lodash'; @@ -16,69 +15,32 @@ export const androidAppActivity = 'network.loki.messenger.RoutingActivity'; console.log(`Android app full path: ${androidAppFullPath}`); -const sharedCapabilities: AppiumAndroidCapabilities & AppiumCapabilities = { +const sharedCapabilities: W3CUiautomator2DriverCaps['alwaysMatch'] = { 'appium:app': androidAppFullPath, - 'appium:platformName': 'Android', + platformName: 'Android', 'appium:platformVersion': '14', 'appium:appPackage': androidAppPackage, 'appium:appActivity': androidAppActivity, 'appium:automationName': 'UiAutomator2', 'appium:newCommandTimeout': 300000, 'appium:eventTimings': false, + 'appium:injectedImageProperties': {}, }; -const emulator1Udid = 'emulator-5554'; -const emulator2Udid = 'emulator-5556'; -const emulator3Udid = 'emulator-5558'; -const emulator4Udid = 'emulator-5560'; -const emulator5Udid = 'emulator-5562'; -const emulator6Udid = 'emulator-5564'; -const emulator7Udid = 'emulator-5566'; -const emulator8Udid = 'emulator-5568'; +const udids = ['emulator-5554', 'emulator-5556', 'emulator-5558', 'emulator-5560']; -const udids = [ - emulator1Udid, - emulator2Udid, - emulator3Udid, - emulator4Udid, - emulator5Udid, - emulator6Udid, - emulator7Udid, - emulator8Udid, -]; - -const emulatorCapabilities: AppiumCapabilities[] = udids.map(udid => ({ +const emulatorCapabilities: W3CUiautomator2DriverCaps['alwaysMatch'][] = udids.map(udid => ({ ...sharedCapabilities, 'appium:udid': udid, })); -// Access individual capabilities like this -const emulatorCapabilities1 = emulatorCapabilities[0]; -const emulatorCapabilities2 = emulatorCapabilities[1]; -const emulatorCapabilities3 = emulatorCapabilities[2]; -const emulatorCapabilities4 = emulatorCapabilities[3]; -const emulatorCapabilities5 = emulatorCapabilities[4]; -const emulatorCapabilities6 = emulatorCapabilities[5]; -const emulatorCapabilities7 = emulatorCapabilities[6]; -const emulatorCapabilities8 = emulatorCapabilities[7]; - export const androidCapabilities = { sharedCapabilities, androidAppFullPath, }; function getAllCaps() { - const emulatorCaps = [ - emulatorCapabilities1, - emulatorCapabilities2, - emulatorCapabilities3, - emulatorCapabilities4, - emulatorCapabilities5, - emulatorCapabilities6, - emulatorCapabilities7, - emulatorCapabilities8, - ]; - return emulatorCaps; + return emulatorCapabilities; } export function getAndroidCapabilities( diff --git a/run/test/utils/capabilities_ios.ts b/run/test/utils/capabilities_ios.ts index 27327478f..67b756fa8 100644 --- a/run/test/utils/capabilities_ios.ts +++ b/run/test/utils/capabilities_ios.ts @@ -7,6 +7,13 @@ import { IntRange } from '../../types/RangeType'; dotenv.config({ quiet: true }); +export type IOSTestContext = { + customInstallTime?: string; + sessionProEnabled?: string; +}; + +export const IOS_PRO_CONTEXT: IOSTestContext = { sessionProEnabled: 'true' }; + const iosPathPrefix = process.env.IOS_APP_PATH_PREFIX; export const iOSBundleId = 'com.loki-project.loki-messenger'; @@ -25,7 +32,7 @@ const sharediOSCapabilities: AppiumXCUITestCapabilities = { 'appium:deviceName': 'iPhone 17', 'appium:automationName': 'XCUITest', 'appium:bundleId': iOSBundleId, - 'appium:newCommandTimeout': 300000, + 'appium:newCommandTimeout': 600000, 'appium:useNewWDA': false, 'appium:showXcodeLog': false, 'appium:autoDismissAlerts': false, @@ -34,6 +41,7 @@ const sharediOSCapabilities: AppiumXCUITestCapabilities = { env: { debugDisappearingMessageDurations: 'true', communityPollLimit: '3', + animationsEnabled: 'false', }, }, } as AppiumXCUITestCapabilities; @@ -124,7 +132,7 @@ export function capabilityIsValid( export function getIosCapabilities( capabilitiesIndex: CapabilitiesIndexType, - customInstallTime?: string + customCaps?: IOSTestContext ): W3CXCUITestDriverCaps { if (capabilitiesIndex >= capabilities.length) { throw new Error( @@ -140,10 +148,14 @@ export function getIosCapabilities( const baseEnv = (caps['appium:processArguments'] as { env?: Record } | undefined)?.env ?? {}; - // Optional per-test override: - // Some tests set IOS_CUSTOM_FIRST_INSTALL_DATE_TIME before starting Appium. - // If present, inject it into the processArguments.env. Otherwise inject nothing. - const customEnv = customInstallTime ? { customFirstInstallDateTime: customInstallTime } : {}; + // Build custom env entries from per-test overrides + const customEnv: Record = {}; + if (customCaps?.customInstallTime) { + customEnv.customFirstInstallDateTime = customCaps.customInstallTime; + } + if (customCaps?.sessionProEnabled) { + customEnv.sessionPro = customCaps.sessionProEnabled; + } // Rebuild the processArguments block with merged env vars caps['appium:processArguments'] = { diff --git a/run/test/utils/click_by_coordinates.ts b/run/test/utils/click_by_coordinates.ts index 707649b88..83320ff97 100644 --- a/run/test/utils/click_by_coordinates.ts +++ b/run/test/utils/click_by_coordinates.ts @@ -1,10 +1,8 @@ import { DeviceWrapper } from '../../types/DeviceWrapper'; import { Coordinates } from '../../types/testing'; -import { sleepFor } from './sleep_for'; export const clickOnCoordinates = async (device: DeviceWrapper, coordinates: Coordinates) => { const { x, y } = coordinates; - await sleepFor(1000); await device.pressCoordinates(x, y); device.log(`Tapped coordinates ${x}, ${y}`); }; diff --git a/run/test/utils/community.ts b/run/test/utils/community.ts new file mode 100644 index 000000000..c5a675802 --- /dev/null +++ b/run/test/utils/community.ts @@ -0,0 +1,43 @@ +import { test } from '@playwright/test'; + +import { communities } from '../../constants/community'; +import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { CommunityInput, JoinCommunityButton } from '../locators'; +import { ConversationHeaderName, MessageBody } from '../locators/conversation'; +import { PlusButton } from '../locators/home'; +import { JoinCommunityOption } from '../locators/start_conversation'; + +export function assertAdminIsKnown() { + if (!process.env.SOGS_ADMIN_SEED) { + console.error('SOGS_ADMIN_SEED required. In CI this is a GitHub secret.'); + console.error('Locally, set a known admin seed as an env var to run this test.'); + test.skip(); + } +} + +export const joinCommunity = async ( + device: DeviceWrapper, + communityLink: string, + communityName: string +) => { + await device.clickOnElementAll(new PlusButton(device)); + await device.clickOnElementAll(new JoinCommunityOption(device)); + await device.inputText(communityLink, new CommunityInput(device)); + await device.clickOnElementAll(new JoinCommunityButton(device)); + await device.waitForTextElementToBePresent(new ConversationHeaderName(device, communityName)); + await device.waitForTextElementToBePresent(new MessageBody(device)); // Check for ANY message + await device.scrollToBottom(); +}; + +export const joinCommunities = async (device: DeviceWrapper, toJoin: number) => { + const available = Object.values(communities).length; + if (toJoin > available) { + throw new Error( + `joinCommunities: requested ${toJoin} but only ${available} communities have been recorded.\nCheck run/constants/community.ts for more` + ); + } + for (const community of Object.values(communities).slice(0, toJoin)) { + await joinCommunity(device, community.link, community.name); + await device.navigateBack(); + } +}; diff --git a/run/test/utils/conversation_order.ts b/run/test/utils/conversation_order.ts new file mode 100644 index 000000000..6747aca41 --- /dev/null +++ b/run/test/utils/conversation_order.ts @@ -0,0 +1,30 @@ +import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { verify } from './'; + +// Returns the names of all conversation list items in their current DOM order +export const getConversationOrder = async (device: DeviceWrapper): Promise => { + const items = await device.findElementsByAccessibilityId('Conversation list item'); + return Promise.all(items.map(item => device.getTextFromElement(item))); +}; + +// Asserts pinned conversations float to the top maintaining relative order, followed by unpinned in their original order. +// Pass an empty pinnedNames array to assert the order is fully restored (e.g. after unpinning). +export const assertPinOrder = ( + beforeOrder: string[], + pinnedNames: string[], + afterOrder: string[] +) => { + const pinnedSet = new Set(pinnedNames); + const pinnedExpected: string[] = []; + const unpinnedExpected: string[] = []; + for (const name of beforeOrder) { + if (pinnedSet.has(name)) { + pinnedExpected.push(name); + } else { + unpinnedExpected.push(name); + } + } + const expected = [...pinnedExpected, ...unpinnedExpected]; + + verify(afterOrder, 'Conversation order is not correct').toEqual(expected); +}; diff --git a/run/test/utils/create_account.ts b/run/test/utils/create_account.ts index 37ffe96ca..897a887d8 100644 --- a/run/test/utils/create_account.ts +++ b/run/test/utils/create_account.ts @@ -4,18 +4,38 @@ import { DeviceWrapper } from '../../types/DeviceWrapper'; import { User } from '../../types/testing'; import { CloseSettings } from '../locators'; import { AccountIDDisplay, ContinueButton } from '../locators/global'; -import { CreateAccountButton, DisplayNameInput, FastModeRadio } from '../locators/onboarding'; -import { RecoveryPhraseContainer, RevealRecoveryPhraseButton } from '../locators/settings'; +import { + CreateAccountButton, + DisplayNameInput, + FastModeRadio, + SlowModeRadio, +} from '../locators/onboarding'; +import { RecoveryPasswordMenuItem, RecoveryPhraseContainer } from '../locators/settings'; import { UserSettings } from '../locators/settings'; -import { CopyButton } from '../locators/start_conversation'; -import { handlePermissions } from './permissions'; +import { handleBackgroundPermissions, handleNotificationPermissions } from './permissions'; export type BaseSetupOptions = { allowNotificationPermissions?: boolean; }; +/** + * Setup options for account creation specifically + * + * By default, new accounts will: + * - set fast mode + * - deny notification permissions + * + * If fast mode is `false` and allowBackgroundPermissions is not explicitly set, + * the test will have to handle the background permissions modal on Android. + * Tests that *do* grant background permissions must clean up with a try/finally uninstall + * to avoid state pollution in following tests. + * + * Note that this is all theoretically possible in restore account as well, we just don't bother to do it. + */ export type NewUserSetupOptions = BaseSetupOptions & { saveUserData?: boolean; + fastMode?: boolean; + allowBackgroundPermissions?: boolean; }; export async function newUser( @@ -23,40 +43,45 @@ export async function newUser( userName: UserNameType, options?: NewUserSetupOptions ): Promise { - const { saveUserData = true, allowNotificationPermissions = false } = options || {}; + const { + saveUserData = true, + allowNotificationPermissions = false, + allowBackgroundPermissions, + fastMode = true, + } = options || {}; device.setDeviceIdentity(`${userName.toLowerCase()}1`); - // Click create session ID await device.clickOnElementAll(new CreateAccountButton(device)); - // Input username await device.inputText(userName, new DisplayNameInput(device)); - // Click continue await device.clickOnElementAll(new ContinueButton(device)); // Choose message notification options (Fast mode by default) - // TODO: Add option to choose slow mode and handle bg perms on Android (SES-4975) - await device.clickOnElementAll(new FastModeRadio(device)); - // Select Continue to save notification settings + if (fastMode) { + await device.clickOnElementAll(new FastModeRadio(device)); + } else { + await device.clickOnElementAll(new SlowModeRadio(device)); + } await device.clickOnElementAll(new ContinueButton(device)); // Handle permissions based on the flag - await handlePermissions(device, allowNotificationPermissions); + await handleNotificationPermissions(device, allowNotificationPermissions); + if (!fastMode) { + await handleBackgroundPermissions(device, allowBackgroundPermissions); + } // Some tests don't need to save the Account ID and Recovery Password if (!saveUserData) { return { userName, accountID: 'not_needed', recoveryPhrase: 'not_needed' }; } - // Click on 'continue' button to open recovery phrase modal - await device.waitForTextElementToBePresent(new RevealRecoveryPhraseButton(device)); - await device.clickOnElementAll(new RevealRecoveryPhraseButton(device)); - //Save recovery password + // Open recovery phrase modal and save recovery phrase + await device.clickOnElementAll(new UserSettings(device)); + await device.onIOS().scrollDown(); + await device.clickOnElementAll(new RecoveryPasswordMenuItem(device)); const recoveryPhraseContainer = await device.clickOnElementAll( new RecoveryPhraseContainer(device) ); - await device.onAndroid().clickOnElementAll(new CopyButton(device)); - // Save recovery phrase as variable const recoveryPhrase = await device.getTextFromElement(recoveryPhraseContainer); device.log(`${userName}s recovery phrase is "${recoveryPhrase}"`); - // Exit Modal await device.navigateBack(false); - await device.clickOnElementAll(new UserSettings(device)); + await device.scrollUp(); + // Get Account ID from User Settings const el = await device.waitForTextElementToBePresent(new AccountIDDisplay(device)); const accountID = await device.getTextFromElement(el); await device.clickOnElementAll(new CloseSettings(device)); diff --git a/run/test/utils/create_contact.ts b/run/test/utils/create_contact.ts index 120be5392..5432685f0 100644 --- a/run/test/utils/create_contact.ts +++ b/run/test/utils/create_contact.ts @@ -17,11 +17,9 @@ export const newContact = async ( await sleepFor(100); await runOnlyOnIOS(platform, () => retryMsgSentForBanner(platform, device1, device2, 30000)); // this runOnlyOnIOS is needed - await device2.clickOnElementAll(new MessageRequestsBanner(device2)); - await device2.clickOnByAccessibilityID('Message request'); - await device2.onAndroid().clickOnByAccessibilityID('Accept message request'); + await device2.acceptMessageRequestWithButton(); // Type into message input box - const replyMessage = `Reply-message-${receiver.userName}-to-${sender.userName}`; + const replyMessage = `${receiver.userName} to ${sender.userName}`; await device2.sendMessage(replyMessage); // Verify config message states message request was accepted diff --git a/run/test/utils/create_group.ts b/run/test/utils/create_group.ts index cbf49f249..a5e1bc491 100644 --- a/run/test/utils/create_group.ts +++ b/run/test/utils/create_group.ts @@ -9,7 +9,6 @@ import { CreateGroupOption } from '../locators/start_conversation'; import { newContact } from './create_contact'; import { sortByPubkey } from './get_account_id'; import { SupportedPlatformsType } from './open_app'; -import { sleepFor } from './sleep_for'; export const createGroup = async ( platform: SupportedPlatformsType, @@ -48,7 +47,6 @@ export const createGroup = async ( await device1.clickOnElementAll({ ...new Contact(device1).build(), text: userThree.userName }); // Select tick await device1.clickOnElementAll(new CreateGroupButton(device1)); - await sleepFor(3000); // Enter group chat on device 2 and 3 await Promise.all([ device2.onAndroid().navigateBack(false), @@ -76,26 +74,19 @@ export const createGroup = async ( ), ]); } - // Send message from User A to group to verify all working - await device1.sendMessage(aliceMessage); - // Did the other devices receive alice's message? - await Promise.all( - [device2, device3].map(device => - device.waitForTextElementToBePresent(new MessageBody(device, aliceMessage)) - ) - ); - // Send message from User B to group - await device2.sendMessage(bobMessage); - await Promise.all( - [device1, device3].map(device => - device.waitForTextElementToBePresent(new MessageBody(device, bobMessage)) - ) - ); - // Send message to User C to group - await device3.sendMessage(charlieMessage); + // Send messages from all three users simultaneously to verify group is working + await Promise.all([ + device1.sendMessage(aliceMessage), + device2.sendMessage(bobMessage), + device3.sendMessage(charlieMessage), + ]); + // Verify all messages are visible on all devices + const allMessages = [aliceMessage, bobMessage, charlieMessage]; await Promise.all( - [device1, device2].map(device => - device.waitForTextElementToBePresent(new MessageBody(device, charlieMessage)) + [device1, device2, device3].flatMap(device => + allMessages.map(message => + device.waitForTextElementToBePresent(new MessageBody(device, message)) + ) ) ); return { userName, userOne, userTwo, userThree }; diff --git a/run/test/utils/device_registry.ts b/run/test/utils/device_registry.ts new file mode 100644 index 000000000..4a7c731ce --- /dev/null +++ b/run/test/utils/device_registry.ts @@ -0,0 +1,66 @@ +import type { TestInfo } from '@playwright/test'; + +import type { SupportedPlatformsType } from './open_app'; + +import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { getAdbFullPath } from './binaries'; +import { androidAppPackage } from './capabilities_android'; +import { runScriptAndLog } from './utilities'; + +export type LogContext = { + startMs: number; // epoch ms — iOS: compared against log file mtime; Android: derived to epoch seconds for adb -T + pid?: string | null; // Android only — null if pidof returned nothing (app not yet running or already dead) +}; + +export type DeviceContext = { + devices: DeviceWrapper[]; + platform: SupportedPlatformsType; + logCtxByUdid?: Map; +}; + +export const deviceRegistry = new Map(); + +export function registryKey(testInfo: TestInfo, retry = testInfo.retry): string { + return `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}-${retry}`; +} + +// Async because Android registration fetches per-device PID for scoped logcat on failure. +export async function registerDevicesForTest( + testInfo: TestInfo, + devices: DeviceWrapper[], + platform: SupportedPlatformsType +) { + const key = registryKey(testInfo); + // Throw if registry already has an entry — indicates a previous test didn't unregister properly + if (deviceRegistry.has(key)) { + throw new Error(`Device registry already contains entry for test "${testInfo.title}"`); + } + + const startMs = Date.now(); + const logCtxByUdid = new Map(); + + if (platform === 'android') { + await Promise.all( + devices.map(async device => { + const pidOutput = await runScriptAndLog( + `${getAdbFullPath()} -s ${device.udid} shell pidof ${androidAppPackage}` + ); + const pid = pidOutput.trim() || null; + logCtxByUdid.set(device.udid, { startMs, pid }); + }) + ); + } else if (platform === 'ios') { + for (const device of devices) { + logCtxByUdid.set(device.udid, { startMs }); + } + } + + deviceRegistry.set(key, { devices, platform, logCtxByUdid }); +} + +export function unregisterDevicesForTest(testInfo: TestInfo) { + // Clean up current attempt and any stale entries left by prior retry attempts + for (let r = 0; r <= testInfo.retry; r++) { + deviceRegistry.delete(registryKey(testInfo, r)); + } +} diff --git a/run/test/utils/disappearing_control_messages.ts b/run/test/utils/disappearing_control_messages.ts deleted file mode 100644 index db29c2702..000000000 --- a/run/test/utils/disappearing_control_messages.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { UserNameType } from '@session-foundation/qa-seeder'; - -import { tStripped } from '../../localizer/lib'; -import { DeviceWrapper } from '../../types/DeviceWrapper'; -import { DisappearActions, DISAPPEARING_TIMES } from '../../types/testing'; -import { ConversationItem } from '../locators/home'; -import { SupportedPlatformsType } from './open_app'; - -export const checkDisappearingControlMessage = async ( - platform: SupportedPlatformsType, - userNameA: UserNameType, - userNameB: UserNameType, - device1: DeviceWrapper, - device2: DeviceWrapper, - time: DISAPPEARING_TIMES, - mode: DisappearActions, - linkedDevice?: DeviceWrapper -) => { - // Two control messages to check - You have set and other user has set - // "disappearingMessagesSet": "{name} has set messages to disappear {time} after they have been {disappearing_messages_type}.", - const disappearingMessagesSetAlice = tStripped('disappearingMessagesSet', { - name: userNameA, - time, - disappearing_messages_type: mode, - }); - const disappearingMessagesSetBob = tStripped('disappearingMessagesSet', { - name: userNameB, - time, - disappearing_messages_type: mode, - }); - // "disappearingMessagesSetYou": "You set messages to disappear {time} after they have been {disappearing_messages_type}.", - const disappearingMessagesSetYou = tStripped('disappearingMessagesSetYou', { - time, - disappearing_messages_type: mode, - }); - // Check device 1 - if (platform === 'android') { - await Promise.all([ - device1.waitForControlMessageToBePresent(disappearingMessagesSetYou), - device1.waitForControlMessageToBePresent(disappearingMessagesSetBob), - ]); - // Check device 2 - await Promise.all([ - device2.waitForControlMessageToBePresent(disappearingMessagesSetYou), - device2.waitForControlMessageToBePresent(disappearingMessagesSetAlice), - ]); - } - if (platform === 'ios') { - await Promise.all([ - device1.waitForControlMessageToBePresent(disappearingMessagesSetYou), - device2.waitForControlMessageToBePresent(disappearingMessagesSetAlice), - ]); - } - // Check if control messages are syncing from both user A and user B - if (linkedDevice) { - await linkedDevice.clickOnElementAll(new ConversationItem(linkedDevice, userNameB)); - await linkedDevice.waitForControlMessageToBePresent(disappearingMessagesSetYou); - await linkedDevice.waitForControlMessageToBePresent(disappearingMessagesSetBob); - } -}; diff --git a/run/test/utils/screenshot_helper.ts b/run/test/utils/failure_artifacts.ts similarity index 57% rename from run/test/utils/screenshot_helper.ts rename to run/test/utils/failure_artifacts.ts index 9116ff257..2ec35c68f 100644 --- a/run/test/utils/screenshot_helper.ts +++ b/run/test/utils/failure_artifacts.ts @@ -1,43 +1,19 @@ import type { TestInfo } from '@playwright/test'; +import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import sharp from 'sharp'; import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { getAdbFullPath } from './binaries'; +import { iOSBundleId } from './capabilities_ios'; +import { deviceRegistry, LogContext, registryKey } from './device_registry'; import { SupportedPlatformsType } from './open_app'; +import { runScriptAndLog } from './utilities'; -// Screenshot context type -type ScreenshotContext = { - devices: DeviceWrapper[]; - testInfo: TestInfo; - platform: SupportedPlatformsType; -}; - -// Global registry to track devices for screenshot capture -const deviceRegistry = new Map(); - -// Register devices for a test -export function registerDevicesForTest( - testInfo: TestInfo, - devices: DeviceWrapper[], - platform: SupportedPlatformsType -) { - const testId = `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}`; - // Throw if deviceRegistry already has an entry for this test - // Could indicate that previous test did not unregister properly - if (deviceRegistry.has(testId)) { - throw new Error(`Device registry already contains entry for test "${testInfo.title}"`); - } - - deviceRegistry.set(testId, { devices, testInfo, platform }); -} +// --- Screenshots --- -// Unregister devices after test -export function unregisterDevicesForTest(testInfo: TestInfo) { - const testId = `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}`; - deviceRegistry.delete(testId); -} // Add device labels to screenshots (e.g. "Device: alice1") async function addDeviceLabel(screenshot: Buffer, device: DeviceWrapper): Promise { const { width } = await sharp(screenshot).metadata(); @@ -50,11 +26,11 @@ async function addDeviceLabel(screenshot: Buffer, device: DeviceWrapper): Promis - Device: ${deviceName} @@ -63,14 +39,7 @@ async function addDeviceLabel(screenshot: Buffer, device: DeviceWrapper): Promis // Composite label over screenshot return sharp(screenshot) - .composite([ - { - input: label, - top: 0, - left: 0, - blend: 'over', - }, - ]) + .composite([{ input: label, top: 0, left: 0, blend: 'over' }]) .png() .toBuffer(); } @@ -116,14 +85,7 @@ async function createComposite(screenshots: Buffer[]): Promise { const composites = screenshots.map((screenshot, index) => { const col = index % cols; const row = Math.floor(index / cols); - const x = col * (width + gap); - const y = row * (height + gap); - - return { - input: screenshot, - left: x, - top: y, - }; + return { input: screenshot, left: col * (width + gap), top: row * (height + gap) }; }); // Apply all screenshots to canvas @@ -132,8 +94,7 @@ async function createComposite(screenshots: Buffer[]): Promise { // Main screenshot capture function export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise { - const testId = `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}`; - const context = deviceRegistry.get(testId); + const context = deviceRegistry.get(registryKey(testInfo)); if (!context || context.devices.length === 0) { console.log('No devices registered for screenshot capture'); @@ -143,30 +104,20 @@ export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise { try { const base64 = await device.getScreenshot(); - return { - device, - base64, - success: true, - }; + return { device, base64, success: true }; } catch (error) { console.error(`Failed to capture from ${device.getDeviceIdentity()}:`, error); - return { - device, - base64: null, - success: false, - }; + return { device, base64: null, success: false }; } }) ); // Filter out failed captures const successfulCaptures = rawCaptures.filter(c => c.success && c.base64); - if (successfulCaptures.length === 0) { console.log('No screenshots captured successfully'); return; @@ -180,16 +131,19 @@ export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise => { - if (result.status === 'rejected') { - console.error(`Failed to process screenshot:`, result.reason); - return false; + .filter( + ( + result + ): result is PromiseFulfilledResult<{ device: DeviceWrapper; labeledBuffer: Buffer }> => { + if (result.status === 'rejected') { + console.error(`Failed to process screenshot:`, result.reason); + return false; + } + return true; } - return true; - }) + ) .map(result => result.value.labeledBuffer); if (screenshots.length === 0) { @@ -198,7 +152,6 @@ export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise { + if (platform === 'android') { + const startEpochSec = (logCtx.startMs / 1000).toFixed(3); + const parts = [ + `${getAdbFullPath()} -s ${device.udid} logcat -d -T ${startEpochSec}`, + ...(logCtx.pid ? [`--pid=${logCtx.pid}`] : []), + ]; + const output = await runScriptAndLog(parts.join(' ')); + return Buffer.from(output); + } + + if (platform === 'ios') { + const containerPath = execSync( + `xcrun simctl get_app_container ${device.udid} ${iOSBundleId} data`, + { encoding: 'utf8' } + ).trim(); + + const logsDir = path.join(containerPath, 'Library', 'Caches', 'Logs'); + + if (!fs.existsSync(logsDir)) { + console.log(`No logs directory found for ${device.getDeviceIdentity()}`); + return null; + } + + const logFiles = fs + .readdirSync(logsDir) + .filter(f => f.startsWith(iOSBundleId) && f.endsWith('.log')) + .map(f => ({ name: f, mtime: fs.statSync(path.join(logsDir, f)).mtimeMs })) + .filter(f => f.mtime >= logCtx.startMs) + .sort((a, b) => b.mtime - a.mtime); + + if (logFiles.length === 0) { + console.log(`No log files found after test start for ${device.getDeviceIdentity()}`); + return null; + } + + return Buffer.from(fs.readFileSync(path.join(logsDir, logFiles[0].name), 'utf8')); + } + + return null; +} + +const MAX_LOG_BYTES = 512 * 1024; // 512kB — tail beyond this to keep reports lean + +function tailBuffer(raw: Buffer): Buffer { + if (raw.length <= MAX_LOG_BYTES) return raw; + + const tail = raw.subarray(raw.length - MAX_LOG_BYTES); + // Advance past any partial line at the cut point + const firstNewline = tail.indexOf('\n'.charCodeAt(0)); + return firstNewline > 0 ? tail.subarray(firstNewline + 1) : tail; +} + +export async function captureLogsOnFailure(testInfo: TestInfo): Promise { + const context = deviceRegistry.get(registryKey(testInfo)); + + if (!context?.logCtxByUdid) { + return; + } + + await Promise.all( + context.devices.map(async device => { + const logCtx = context.logCtxByUdid!.get(device.udid); + if (!logCtx) return; + + try { + const raw = await collectLogBuffer(context.platform, device, logCtx); + if (!raw) return; + + const buffer = tailBuffer(raw); + const label = device.getDeviceIdentity(); + const truncated = raw.length !== buffer.length; + await testInfo.attach(`device-log-${label}`, { body: buffer, contentType: 'text/plain' }); + console.log( + `Log captured for ${label} (${buffer.length} bytes${truncated ? `, truncated from ${raw.length}` : ''})` + ); + } catch (error) { + console.error(`Failed to capture log for ${device.getDeviceIdentity()}:`, error); + } + }) + ); +} diff --git a/run/test/utils/get_account_id.ts b/run/test/utils/get_account_id.ts index 64f104c8d..1c9a7bd2a 100644 --- a/run/test/utils/get_account_id.ts +++ b/run/test/utils/get_account_id.ts @@ -1,6 +1,7 @@ import { User } from '../../types/testing'; import { SupportedPlatformsType } from './open_app'; +// Sorts users by pubkey hex and returns their usernames export function sortByPubkey(...users: Array) { return [...users] .sort((a, b) => a.accountID.localeCompare(b.accountID)) diff --git a/run/test/utils/handle_first_open.ts b/run/test/utils/handle_first_open.ts index 59c1497db..aee80a8bc 100644 --- a/run/test/utils/handle_first_open.ts +++ b/run/test/utils/handle_first_open.ts @@ -6,7 +6,7 @@ import { iOSPhotosContinuebutton } from '../locators/external'; export async function handleChromeFirstTimeOpen(device: DeviceWrapper) { const chromeUseWithoutAnAccount = await device.doesElementExist({ ...new ChromeUseWithoutAnAccount(device).build(), - maxWait: 2_000, + maxWait: 5_000, }); if (!chromeUseWithoutAnAccount) { device.log('Chrome opened without an account check, proceeding'); @@ -37,8 +37,7 @@ export async function handlePhotosFirstTimeOpen(device: DeviceWrapper) { // On Android, the Photos app shows a sign-in prompt the first time it's opened that needs to be dismissed // I've seen two different kinds of sign in buttons on the same set of emulators if (device.isAndroid()) { - let signInButton = null; - signInButton = await device.doesElementExist({ + let signInButton = await device.doesElementExist({ strategy: 'id', selector: 'com.google.android.apps.photos:id/sign_in_button', maxWait: 1_000, diff --git a/run/test/utils/index.ts b/run/test/utils/index.ts index 66e353af7..e7140f74a 100644 --- a/run/test/utils/index.ts +++ b/run/test/utils/index.ts @@ -1,5 +1,6 @@ import { clickOnCoordinates } from './click_by_coordinates'; import { runOnlyOnAndroid, runOnlyOnIOS } from './run_on'; import { sleepFor } from './sleep_for'; +import { verify } from './utilities'; -export { sleepFor, runOnlyOnIOS, runOnlyOnAndroid, clickOnCoordinates }; +export { verify, sleepFor, runOnlyOnIOS, runOnlyOnAndroid, clickOnCoordinates }; diff --git a/run/test/utils/join_community.ts b/run/test/utils/join_community.ts deleted file mode 100644 index 5a9faea85..000000000 --- a/run/test/utils/join_community.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { DeviceWrapper } from '../../types/DeviceWrapper'; -import { CommunityInput, JoinCommunityButton } from '../locators'; -import { ConversationHeaderName, EmptyConversation } from '../locators/conversation'; -import { PlusButton } from '../locators/home'; -import { JoinCommunityOption } from '../locators/start_conversation'; - -export const joinCommunity = async ( - device: DeviceWrapper, - communityLink: string, - communityName: string -) => { - await device.clickOnElementAll(new PlusButton(device)); - await device.clickOnElementAll(new JoinCommunityOption(device)); - await device.inputText(communityLink, new CommunityInput(device)); - await device.clickOnElementAll(new JoinCommunityButton(device)); - await device.waitForTextElementToBePresent(new ConversationHeaderName(device, communityName)); - await device.verifyElementNotPresent(new EmptyConversation(device)); // checking that messages loaded already - await device.scrollToBottom(); -}; diff --git a/run/test/utils/link_device.ts b/run/test/utils/link_device.ts index 3c94d7cd8..557bf02dc 100644 --- a/run/test/utils/link_device.ts +++ b/run/test/utils/link_device.ts @@ -12,7 +12,7 @@ import { } from '../locators/onboarding'; import { newUser } from './create_account'; import { BaseSetupOptions } from './create_account'; -import { handlePermissions } from './permissions'; +import { handleNotificationPermissions } from './permissions'; export const linkedDevice = async ( device1: DeviceWrapper, @@ -49,7 +49,7 @@ export const linkedDevice = async ( device2.info('Display name found: Loading account'); } // Wait for permissions modal to pop up - await handlePermissions(device2, allowNotificationPermissions); + await handleNotificationPermissions(device2, allowNotificationPermissions); // Check that button was clicked await device2.waitForTextElementToBePresent(new PlusButton(device2)); diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts new file mode 100644 index 000000000..cd8a2a2c1 --- /dev/null +++ b/run/test/utils/mock_pro.ts @@ -0,0 +1,386 @@ +/** + * Session Pro Test Account Setup + * + * Registers test accounts as Pro subscribers against the Session Pro dev backend, + * bypassing Google Play / Apple App Store verification entirely. + * + * Based on: + * https://github.com/session-foundation/session-pro-backend/blob/main/examples/endpoint_example.py + * + * Usage: + * import { makeAccountPro } from './mock_pro'; + * + * await makeAccountPro({ user: alice, platform }); + * + * In order for the changes to take effect in the clients it's best to force close and restart the app + */ + +import { ed25519 } from '@noble/curves/ed25519.js'; +import { blake2b } from '@noble/hashes/blake2.js'; +import { randomBytes } from 'crypto'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { PRO_BACKEND_URL } from '../../constants'; +import { User } from '../../types/testing'; +import { SupportedPlatformsType } from './open_app'; + +type PaymentProvider = 'apple' | 'google'; + +type MakeAccountProParams = { + user: User; + platform: SupportedPlatformsType; + dryRun?: boolean; // If true, build and print the request but don't send it +}; + +type AddProPaymentRequest = { + version: number; + master_pkey: string; + rotating_pkey: string; + master_sig: string; + rotating_sig: string; + payment_tx: { + provider: number; + google_payment_token?: string; + google_order_id?: string; + apple_tx_id?: string; + }; +}; + +type ProProof = { + version: number; + expiry_unix_ts_ms: number; + gen_index_hash: string; + rotating_pkey: string; + sig: string; +}; + +type AddProPaymentResponse = { + status: number; + result?: ProProof; + errors?: string[]; +}; + +let WORDLIST_CACHE: string[] | null = null; + +function getWordlist(): string[] { + if (WORDLIST_CACHE) { + return WORDLIST_CACHE; + } + + const wordlistPath = join(__dirname, '../../../english_wordlist.txt'); + const content = readFileSync(wordlistPath, 'utf-8'); + const words = content + .split('\n') + .map(w => w.trim()) + .filter(Boolean); + + if (words.length !== 1626) { + throw new Error(`Expected 1626 words in wordlist, got ${words.length}`); + } + + WORDLIST_CACHE = words; + return words; +} + +// Decodes a 13-word recovery phrase to a 16-byte seed hex string. */ +function mnemonicToSeedHex(mnemonic: string): string { + const wordlist = getWordlist(); + const n = wordlist.length; // 1626 + + const words = mnemonic.toLowerCase().trim().split(/\s+/); + if (words.length !== 13) { + throw new Error(`Expected 13 words, got ${words.length}`); + } + + // Build word -> index lookup + const wordToIdx = new Map(); + wordlist.forEach((w, i) => wordToIdx.set(w, i)); + + // Resolve word indices (with prefix matching support) + const indices: number[] = []; + for (const word of words) { + if (wordToIdx.has(word)) { + indices.push(wordToIdx.get(word)!); + } else { + // Try prefix match (first 4 chars) + const matches = wordlist + .map((w, i) => ({ w, i })) + .filter(({ w }) => w.startsWith(word.slice(0, 4))); + + if (matches.length === 1) { + indices.push(matches[0].i); + } else { + throw new Error(`Unknown or ambiguous mnemonic word: '${word}'`); + } + } + } + + // Decode: every 3 words -> 4 bytes (little-endian) + const dataIndices = indices.slice(0, 12); + const seedBytes: number[] = []; + for (let i = 0; i < 12; i += 3) { + const w1 = dataIndices[i]; + const w2 = dataIndices[i + 1]; + const w3 = dataIndices[i + 2]; + + const x = w1 + n * ((((w2 - w1) % n) + n) % n) + n * n * ((((w3 - w2) % n) + n) % n); + + // Convert to 4 bytes little-endian + seedBytes.push(x & 0xff); + seedBytes.push((x >> 8) & 0xff); + seedBytes.push((x >> 16) & 0xff); + seedBytes.push((x >> 24) & 0xff); + } + + if (seedBytes.length !== 16) { + throw new Error(`Expected 16 bytes, got ${seedBytes.length}`); + } + + return Buffer.from(seedBytes).toString('hex'); +} + +function padSeed(seedHex: string): Uint8Array { + const seed = Buffer.from(seedHex, 'hex'); + if (seed.length !== 16) { + throw new Error(`Seed must be 16 bytes, got ${seed.length}`); + } + + // Pad with 16 zero bytes + const padded = new Uint8Array(32); + padded.set(seed, 0); + return padded; +} + +// Derives the Pro master keypair from the seed using Blake2b with "SessionProRandom" as the key. +function deriveProMasterKey(seedHex: string): { + privateKey: Uint8Array; + publicKey: Uint8Array; +} { + const padded = padSeed(seedHex); + + // Blake2b-256 with "SessionProRandom" as the key + const proSeed = blake2b(padded, { + dkLen: 32, + key: Buffer.from('SessionProRandom', 'utf-8'), + }); + + const privateKey = proSeed; + const publicKey = ed25519.getPublicKey(privateKey); + + return { privateKey, publicKey }; +} + +// Generates a random ephemeral rotating keypair for the payment request. +function generateRotatingKey(): { privateKey: Uint8Array; publicKey: Uint8Array } { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + return { privateKey, publicKey }; +} + +function makeAddProPaymentHash( + version: number, + masterPubkey: Uint8Array, + rotatingPubkey: Uint8Array, + provider: number, + paymentToken?: string, + orderId?: string, + appleTxId?: string +): Uint8Array { + const personalization = Buffer.from('ProAddPayment___', 'utf-8'); // 16 bytes + + const parts: Uint8Array[] = [ + new Uint8Array([version]), + masterPubkey, + rotatingPubkey, + new Uint8Array([provider]), + ]; + + if (provider === 1) { + // Google + if (!paymentToken || !orderId) { + throw new Error('Google provider requires payment_token and order_id'); + } + parts.push(Buffer.from(paymentToken, 'utf-8')); + parts.push(Buffer.from(orderId, 'utf-8')); + } else if (provider === 2) { + // Apple + if (!appleTxId) { + throw new Error('Apple provider requires tx_id'); + } + parts.push(Buffer.from(appleTxId, 'utf-8')); + } + + // Concatenate all parts + const totalLen = parts.reduce((sum, p) => sum + p.length, 0); + const message = new Uint8Array(totalLen); + let offset = 0; + for (const part of parts) { + message.set(part, offset); + offset += part.length; + } + + return blake2b(message, { dkLen: 32, personalization }); +} + +// Builds a signed add_pro_payment request body with fake payment tokens. +function buildAddProPaymentRequest( + masterKey: { privateKey: Uint8Array; publicKey: Uint8Array }, + rotatingKey: { privateKey: Uint8Array; publicKey: Uint8Array }, + provider: PaymentProvider +): AddProPaymentRequest { + const version = 0; + const providerNum = provider === 'google' ? 1 : 2; + + let paymentToken: string | undefined; + let orderId: string | undefined; + let appleTxId: string | undefined; + + const timestamp = Date.now(); + const nonce = randomBytes(4).toString('hex'); + + if (provider === 'google') { + paymentToken = `DEV.${timestamp}.${nonce}`; + orderId = `DEV.${timestamp}.${nonce}`; + } else { + appleTxId = `DEV.${timestamp}.${nonce}`; + } + + const hash = makeAddProPaymentHash( + version, + masterKey.publicKey, + rotatingKey.publicKey, + providerNum, + paymentToken, + orderId, + appleTxId + ); + + const masterSig = ed25519.sign(hash, masterKey.privateKey); + const rotatingSig = ed25519.sign(hash, rotatingKey.privateKey); + + const paymentTx: AddProPaymentRequest['payment_tx'] = { + provider: providerNum, + }; + + if (provider === 'google') { + paymentTx.google_payment_token = paymentToken; + paymentTx.google_order_id = orderId; + } else { + paymentTx.apple_tx_id = appleTxId; + } + + return { + version, + master_pkey: Buffer.from(masterKey.publicKey).toString('hex'), + rotating_pkey: Buffer.from(rotatingKey.publicKey).toString('hex'), + master_sig: Buffer.from(masterSig).toString('hex'), + rotating_sig: Buffer.from(rotatingSig).toString('hex'), + payment_tx: paymentTx, + }; +} + +// POSTs the payment request to the Pro backend with retries and timeout. +async function addProPayment( + backendUrl: string, + request: AddProPaymentRequest, + { maxAttempts = 3, timeout = 10_000 } = {} +): Promise { + const url = `${backendUrl}/add_pro_payment`; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const data = (await response.json()) as AddProPaymentResponse; + + if (!response.ok || data.status !== 0) { + throw new Error( + `Failed to add Pro payment: ${data.errors?.join(', ') || `HTTP ${response.status}`}` + ); + } + + return data; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (attempt === maxAttempts) { + throw new Error(`add_pro_payment failed after ${maxAttempts} attempts: ${msg}`); + } + console.log(`add_pro_payment attempt ${attempt}/${maxAttempts} failed: ${msg}, retrying...`); + } + } + + throw new Error('Unreachable'); +} + +// Registers a test account as a Pro subscriber against the dev backend. +export async function makeAccountPro(params: MakeAccountProParams): Promise { + const { user, platform, dryRun = false } = params; + const mnemonic = user.recoveryPhrase; + const provider: PaymentProvider = platform === 'ios' ? 'apple' : 'google'; + const seedHex = mnemonicToSeedHex(mnemonic); + const masterKey = deriveProMasterKey(seedHex); + const rotatingKey = generateRotatingKey(); + // Build request + const request = buildAddProPaymentRequest(masterKey, rotatingKey, provider); + console.log(`\nRequest body: + ${JSON.stringify(request, null, 2)}`); + + if (dryRun) { + console.log('\nDRY RUN - Request not sent'); + return null; + } + + // Send request + console.log(`\nSending request to ${PRO_BACKEND_URL}...`); + const response = await addProPayment(PRO_BACKEND_URL, request); + + if (!response.result) { + throw new Error('No proof in response'); + } + + console.log('Account successfully registered as Pro'); + console.log(` Expiry: ${new Date(response.result.expiry_unix_ts_ms).toISOString()}`); + + return response.result; +} + +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error( + 'Usage: npx ts-node run/test/utils/mock_pro.ts [--dry-run]' + ); + console.error('Example: npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." android'); + console.error( + ' npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." ios --dry-run' + ); + process.exit(1); + } + + const dryRun = args.includes('--dry-run'); + const filteredArgs = args.filter(a => a !== '--dry-run'); + const [mnemonic, platform] = filteredArgs; + + makeAccountPro({ + user: { userName: '' as any, accountID: '', recoveryPhrase: mnemonic }, + platform: platform as SupportedPlatformsType, + dryRun, + }) + .then(() => process.exit(0)) + .catch(err => { + console.error('Error:', err.message); + process.exit(1); + }); +} diff --git a/run/test/utils/open_app.ts b/run/test/utils/open_app.ts index f9f1d9b31..cbc9c96c3 100644 --- a/run/test/utils/open_app.ts +++ b/run/test/utils/open_app.ts @@ -5,29 +5,26 @@ import { XCUITestDriverOpts } from 'appium-xcuitest-driver/build/lib/driver'; import { DriverOpts } from 'appium/build/lib/appium'; import { compact } from 'lodash'; +import { recoverEmulator } from '../../../scripts/emulator_health'; import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { getAdbFullPath, getDevicesPerTestCount } from './binaries'; +import { androidAppPackage, getAndroidCapabilities, getAndroidUdid } from './capabilities_android'; import { - getAdbFullPath, - getAndroidSystemImageToUse, - getDevicesPerTestCount, - getEmulatorFullPath, - getSdkManagerFullPath, -} from './binaries'; -import { getAndroidCapabilities, getAndroidUdid } from './capabilities_android'; -import { CapabilitiesIndexType, capabilityIsValid, getIosCapabilities } from './capabilities_ios'; + CapabilitiesIndexType, + capabilityIsValid, + getIosCapabilities, + iOSBundleId, + IOSTestContext, +} from './capabilities_ios'; +import { registerDevicesForTest } from './device_registry'; import { cleanPermissions } from './permissions'; -import { registerDevicesForTest } from './screenshot_helper'; import { sleepFor } from './sleep_for'; -import { isCI, runScriptAndLog } from './utilities'; +import { runScriptAndLog } from './utilities'; const APPIUM_PORT = 4728; export type SupportedPlatformsType = 'android' | 'ios'; -export type IOSTestContext = { - customInstallTime?: string; -}; - export const openAppMultipleDevices = async ( platform: SupportedPlatformsType, numberOfDevices: number, @@ -45,7 +42,7 @@ export const openAppMultipleDevices = async ( // Map the result to return only the device objects const devices = apps.map(app => app.device); - registerDevicesForTest(testInfo, devices, platform); + await registerDevicesForTest(testInfo, devices, platform); return devices; }; @@ -73,7 +70,7 @@ export const openAppOnPlatformSingleDevice = async ( }> => { const result = await openAppOnPlatform(platform, 0, testInfo, iOSContext); - registerDevicesForTest(testInfo, [result.device], platform); + await registerDevicesForTest(testInfo, [result.device], platform); return result; }; @@ -93,7 +90,7 @@ export const openAppTwoDevices = async ( const result = { device1: app1.device, device2: app2.device }; - registerDevicesForTest(testInfo, Object.values(result), platform); + await registerDevicesForTest(testInfo, Object.values(result), platform); return result; }; @@ -119,7 +116,7 @@ export const openAppThreeDevices = async ( device3: app3.device, }; - registerDevicesForTest(testInfo, Object.values(result), platform); + await registerDevicesForTest(testInfo, Object.values(result), platform); return result; }; @@ -148,36 +145,11 @@ export const openAppFourDevices = async ( device4: app4.device, }; - registerDevicesForTest(testInfo, Object.values(result), platform); + await registerDevicesForTest(testInfo, Object.values(result), platform); return result; }; -async function createAndroidEmulator(emulatorName: string) { - if (isCI()) { - // on CI, emulators are created during the docker build step. - return emulatorName; - } - const installSystemImageCmd = `${getSdkManagerFullPath()} --install '${getAndroidSystemImageToUse()}'`; - console.warn(installSystemImageCmd); - await runScriptAndLog(installSystemImageCmd); - - const createCmd = `echo "no" | ${getSdkManagerFullPath()} create avd --name ${emulatorName} -k '${getAndroidSystemImageToUse()}' --force --skin pixel_5`; - console.info(createCmd); - await runScriptAndLog(createCmd); - return emulatorName; -} - -async function startAndroidEmulator(emulatorName: string) { - await runScriptAndLog(`echo "hw.lcd.density=440" >> ~/.android/avd/${emulatorName}.avd/config.ini - `); - const startEmulatorCmd = `${getEmulatorFullPath()} @${emulatorName}`; - console.info(`${startEmulatorCmd} & ; disown`); - await runScriptAndLog( - startEmulatorCmd // -netdelay none -no-snapshot -wipe-data - ); -} - async function isEmulatorRunning(emulatorName: string) { const failedWith = await runScriptAndLog( `${getAdbFullPath()} -s ${emulatorName} get-state;`, @@ -189,7 +161,7 @@ async function isEmulatorRunning(emulatorName: string) { async function waitForEmulatorToBeRunning(emulatorName: string) { let start = Date.now(); - let found = false; + let found: boolean; do { found = await isEmulatorRunning(emulatorName); @@ -242,13 +214,15 @@ const openAndroidApp = async ( const emulatorAlreadyRunning = await isEmulatorRunning(targetName); console.info('emulatorAlreadyRunning', targetName, emulatorAlreadyRunning); if (!emulatorAlreadyRunning) { - if (process.env.CI) { - throw new Error( - `Emulator "${targetName}" is not running but it should have been started earlier.` - ); + if (process.env.CI === '1') { + // Emulator died mid-job — attempt recovery before failing the test. + // Each worker owns a fixed port range (determined by TEST_PARALLEL_INDEX), so + // parallel workers will never race to recover the same emulator. + const port = parseInt(targetName.replace('emulator-', '')); + await recoverEmulator((port - 5554) / 2 + 1); + } else { + throw new Error(`Emulator "${targetName}" is not running. Please start it manually.`); } - await createAndroidEmulator(targetName); - void startAndroidEmulator(targetName); } await waitForEmulatorToBeRunning(targetName); console.log(targetName, ' emulator booted'); @@ -334,7 +308,7 @@ const openiOSApp = async ( const capabilities = getIosCapabilities( actualCapabilitiesIndex as CapabilitiesIndexType, - iOSContext?.customInstallTime + iOSContext ); const udid = capabilities.alwaysMatch['appium:udid'] as string; @@ -347,3 +321,12 @@ export const closeApp = async (...devices: Array) => { console.info('sessions closed'); }; + +export const uninstallApp = async (device: DeviceWrapper, platform: SupportedPlatformsType) => { + const command = + platform === 'android' + ? `${getAdbFullPath()} -s ${device.udid} uninstall ${androidAppPackage}` + : `xcrun simctl uninstall ${device.udid} ${iOSBundleId}`; + + await runScriptAndLog(command, true); +}; diff --git a/run/test/utils/permissions.ts b/run/test/utils/permissions.ts index 4420b315c..5bd7ea576 100644 --- a/run/test/utils/permissions.ts +++ b/run/test/utils/permissions.ts @@ -7,6 +7,7 @@ import { import { DeviceWrapper } from '../../types/DeviceWrapper'; import { AllowPermissionLocator, DenyPermissionLocator } from '../locators/global'; +import { BackgroundPermsAllowButton, BackgroundPermsCancelButton } from '../locators/home'; import { runScriptAndLog } from './utilities'; export const cleanPermissions = async ( @@ -63,13 +64,39 @@ export const cleanPermissions = async ( 'Failed to open the iOS app and find the Create account button after multiple retries.' ); }; -export const handlePermissions = async ( +export const handleNotificationPermissions = async ( device: DeviceWrapper, - allowPermissions: boolean = false + allowNotificationPermissions: boolean = false ) => { - const permissionLocator = allowPermissions + const notificationPermsLocator = allowNotificationPermissions ? new AllowPermissionLocator(device) : new DenyPermissionLocator(device); - await device.processPermissions(permissionLocator); + await device.processPermissions(notificationPermsLocator); +}; + +/** + * Handles the background permissions modal that appears in slow mode on Android. + * + * @param allowBackgroundPermissions + * - `undefined`: Modal is not handled - test must interact with it manually + * - `true`: Auto-allow background permissions + * - `false`: Auto-deny background permissions + */ +export const handleBackgroundPermissions = async ( + device: DeviceWrapper, + allowBackgroundPermissions?: boolean +) => { + if (allowBackgroundPermissions == undefined) return; + + if (allowBackgroundPermissions) { + await device.clickOnElementAll(new BackgroundPermsAllowButton(device)); + await device.clickOnElementAll({ + strategy: 'id', + selector: 'android:id/button1', + text: 'Allow', + }); + } else { + await device.clickOnElementAll(new BackgroundPermsCancelButton(device)); + } }; diff --git a/run/test/utils/restore_account.ts b/run/test/utils/restore_account.ts index d66f5a047..26b7c9592 100644 --- a/run/test/utils/restore_account.ts +++ b/run/test/utils/restore_account.ts @@ -10,14 +10,16 @@ import { SeedPhraseInput, } from '../locators/onboarding'; import { BaseSetupOptions } from './create_account'; -import { handlePermissions } from './permissions'; +import { handleNotificationPermissions } from './permissions'; export const restoreAccount = async ( device: DeviceWrapper, user: User, + deviceIdentity: string, options?: BaseSetupOptions ) => { const { allowNotificationPermissions = false } = options || {}; + device.setDeviceIdentity(deviceIdentity); await device.clickOnElementAll(new AccountRestoreButton(device)); await device.inputText(user.recoveryPhrase, new SeedPhraseInput(device)); // Wait for continue button to become active @@ -41,7 +43,7 @@ export const restoreAccount = async ( device.info('Display name found: Loading account'); } // Wait for permissions modal to pop up - await handlePermissions(device, allowNotificationPermissions); + await handleNotificationPermissions(device, allowNotificationPermissions); // Check that we're on the home screen await device.waitForTextElementToBePresent(new PlusButton(device)); }; @@ -79,8 +81,7 @@ export const restoreAccountNoFallback = async ( // Wait for permissions modal to pop up await sleepFor(500); - await handlePermissions(device, allowNotificationPermissions); - await sleepFor(1000); + await handleNotificationPermissions(device, allowNotificationPermissions); // Check that we're on the home screen await device.waitForTextElementToBePresent(new PlusButton(device)); }; diff --git a/run/test/utils/set_disappearing_messages.ts b/run/test/utils/set_disappearing_messages.ts index b35ad1b25..41ae9a26c 100644 --- a/run/test/utils/set_disappearing_messages.ts +++ b/run/test/utils/set_disappearing_messages.ts @@ -6,52 +6,24 @@ import { DisappearingMessagesMenuOption, DisappearingMessagesSubtitle, DisappearingMessagesTimerType, - FollowSettingsButton, SetDisappearMessagesButton, - SetModalButton, } from '../locators/disappearing_messages'; -import { SupportedPlatformsType } from './open_app'; -import { sleepFor } from './sleep_for'; export const setDisappearingMessage = async ( - platform: SupportedPlatformsType, device: DeviceWrapper, - [conversationType, timerType, timerDuration = DISAPPEARING_TIMES.THIRTY_SECONDS]: MergedOptions, - device2?: DeviceWrapper + [conversationType, timerType, timerDuration = DISAPPEARING_TIMES.THIRTY_SECONDS]: MergedOptions ) => { const enforcedType: ConversationType = conversationType; - await device.clickOnElementAll(new ConversationSettings(device)); - // Wait for UI to load conversation options menu - await sleepFor(500); + await device.clickAndWaitFor( + new ConversationSettings(device), + new DisappearingMessagesMenuOption(device) + ); await device.clickOnElementAll(new DisappearingMessagesMenuOption(device)); if (enforcedType === '1:1') { await device.clickOnElementAll(new DisappearingMessagesTimerType(device, timerType)); } - if (timerType === 'Disappear after read option') { - if (enforcedType === '1:1') { - await device.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.TWELVE_HOURS); - } else { - await device.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.ONE_DAY); - } - } else if ( - enforcedType === 'Group' || - (enforcedType === 'Note to Self' && timerType === 'Disappear after send option') - ) { - await device.onIOS().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_IOS); - await device.onAndroid().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_ANDROID); - } else { - await device.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.ONE_DAY); - } - await device.clickOnElementAll(new DisappearingMessageRadial(device, timerDuration)); await device.clickOnElementAll(new SetDisappearMessagesButton(device)); await device.navigateBack(); - // Extended the wait for the Follow settings button to settle in the UI, it was moving and confusing appium - await sleepFor(2000); - if (device2) { - await device2.clickOnElementAll(new FollowSettingsButton(device2)); - await sleepFor(500); - await device2.clickOnElementAll(new SetModalButton(device2)); - } await device.waitForTextElementToBePresent(new DisappearingMessagesSubtitle(device)); }; diff --git a/run/test/utils/test_setup.ts b/run/test/utils/test_setup.ts deleted file mode 100644 index 97af4b9ed..000000000 --- a/run/test/utils/test_setup.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* -Checkout branch that needs testing: -The Command needs to include: -- platform -- branch -- number of emulators ( new user to old user ratio ) - - -navigate to platform folder - Documents > session-(platform) -git checkout *branch* -then build from branch (ios does automatically, android requires manually click on hammer icon) - -ANDROID -start two emulators (cold boot on android) -cd ~/Library/Android/sdk -./emulator/emulator -avd Pixel_4_API_30 - -open new terminal window -cd ~/Library/Android/sdk -./emulator/emulator -avd Pixel_4_API_30_2 - -IOS -open -a simulator --args -IOS_1_SIMULATOR -no-boot-anim -open -a simulator --args -IOS_2_SIMULATOR -no-boot-anim - -run branch on emulator one -run branch on emulator two - -once session is running on emulator one: -click create session id -save session id -click continue -enter display name -continue -continue -continue -save recovery phrase from reminder -navigate out of reminder page - -once session is running on emulator two: -log into test account -click on restore account - - - - - - - -*/ diff --git a/run/test/utils/utilities.ts b/run/test/utils/utilities.ts index e0461f1d7..7971ed384 100644 --- a/run/test/utils/utilities.ts +++ b/run/test/utils/utilities.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test'; import { exec as execNotPromised } from 'child_process'; import * as fs from 'fs'; import { pick } from 'lodash'; @@ -5,6 +6,7 @@ import path from 'path'; import * as util from 'util'; import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { PlusButton } from '../locators/home'; import { androidAppActivity, androidAppPackage } from './capabilities_android'; import { iOSBundleId } from './capabilities_ios'; import { sleepFor } from './sleep_for'; @@ -146,4 +148,61 @@ export async function forceStopAndRestart(device: DeviceWrapper): Promise await runScriptAndLog(`xcrun simctl launch ${device.udid} ${iOSBundleId}`, true); await sleepFor(1_000); } + // Ensure we're on the home screen again + await device.waitForTextElementToBePresent(new PlusButton(device)); +} + +/** + * Wrapper for Playwright's `expect()` that keeps Allure reports clean. + * + * Playwright dumps the raw diff into the error message, + * which can be confusing for report readers. + * + * `verify()` catches assertion errors and rethrows with a clean message. + * + * @param actual - The value being asserted + * @param message - Business-readable failure message for reporting + * + * @example + * verify(messages, 'Conversation messages are in the wrong order').toEqual(expected); + * verify(isVisible, 'Blocked user banner should not be visible').not.toBe(true); + */ +export function verify(actual: T, message: string) { + const matchers = expect(actual, message); + + function wrapMatchers(obj: typeof matchers): typeof matchers { + return new Proxy(obj, { + get(target, prop: string | symbol) { + const val = Reflect.get(target, prop, target); + if (prop === 'not' || prop === 'resolves' || prop === 'rejects') + return wrapMatchers(val as typeof matchers); + if (typeof val === 'function') { + return (...args: unknown[]) => { + const mismatch = () => { + const lines = [message]; + if (args.length > 0) { + lines.push(`Expected: ${String(args[0])}`); + lines.push(`Actual: ${String(actual)}`); + } + return new Error(lines.join('\n')); + }; + try { + const result = (val as (...a: unknown[]) => unknown).apply(target, args); + if (result instanceof Promise) { + return result.catch(() => { + throw mismatch(); + }); + } + return result; + } catch { + throw mismatch(); + } + }; + } + return val; + }, + }); + } + + return wrapMatchers(matchers); } diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 7c9f0fca2..aa266c6f7 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -18,12 +18,14 @@ import { describeLocator, DownloadMediaButton, FirstGif, + GIFName, ImageName, ImagePermissionsModalAllow, LocatorsInterface, ReadReceiptsButton, } from '../../run/test/locators'; import { + animatedProfilePicture, profilePicture, testFile, testImage, @@ -32,6 +34,7 @@ import { } from '../constants/testfiles'; import { tStripped } from '../localizer/lib'; import { + AcceptMessageRequestButton, AttachmentsButton, DocumentsFolderButton, GIFButton, @@ -53,7 +56,14 @@ import { ModalDescription, ModalHeading, } from '../test/locators/global'; -import { ConversationItem, PlusButton } from '../test/locators/home'; +import { + ConversationItem, + MessageRequestItem, + MessageRequestsBanner, + PinConversationOption, + PlusButton, + UnpinConversationOption, +} from '../test/locators/home'; import { LoadingAnimation } from '../test/locators/onboarding'; import { PrivacyMenuItem, @@ -63,16 +73,17 @@ import { VersionNumber, } from '../test/locators/settings'; import { EnterAccountID, NewMessageOption, NextButton } from '../test/locators/start_conversation'; -import { clickOnCoordinates, sleepFor } from '../test/utils'; +import { clickOnCoordinates, sleepFor, verify } from '../test/utils'; import { getAdbFullPath } from '../test/utils/binaries'; import { parseDataImage } from '../test/utils/check_colour'; import { isSameColor } from '../test/utils/check_colour'; import { SupportedPlatformsType } from '../test/utils/open_app'; import { isDeviceAndroid, isDeviceIOS, runScriptAndLog } from '../test/utils/utilities'; +import { CTAConfig, ctaConfigs, CTAType } from './cta'; import { AccessibilityId, + Coordinates, DISAPPEARING_TIMES, - Group, Id, InteractionPoints, Strategy, @@ -81,10 +92,6 @@ import { XPath, } from './testing'; -export type Coordinates = { - x: number; - y: number; -}; export type ActionSequence = { actions: string; }; @@ -317,6 +324,19 @@ export class DeviceWrapper { return this.toShared().getPageSource(); } + /** + * Injects a base64-encoded image into the Android emulator's virtual camera scene. + */ + public async injectImageToScene(base64Image: string): Promise { + if (this.isAndroid()) { + await this.toShared().execute('mobile: injectEmulatorCameraImage', { + payload: base64Image, + }); + this.log(`Injected image to scene`); + } + // iOS: no-op + } + /* === all the device-specific function === */ // ELEMENT INTERACTION @@ -346,6 +366,7 @@ export class DeviceWrapper { { from: 'Message sent status: Sent', to: 'Message sent status: Sending' }, { from: 'Done', to: 'Donate' }, { from: 'New conversation button', to: 'conversation-options-avatar' }, + { from: 'Leave group', to: 'Delete group' }, ]; // System locators such as 'network.loki.messenger:id' can cause false positives with too high similarity scores @@ -412,7 +433,7 @@ export class DeviceWrapper { } // Validate the candidate element - let isValidCandidate = true; + let isValidCandidate: boolean; // Always check visibility first try { @@ -564,6 +585,16 @@ export class DeviceWrapper { return []; } + private resolveLocator(args: LocatorsInterface | (StrategyExtractionObj & { text?: string })): { + locator: StrategyExtractionObj; + description: string; + } { + const built = args instanceof LocatorsInterface ? args.build() : args; + const text = args instanceof LocatorsInterface ? undefined : args.text; + const locator = text ? { ...built, text } : built; + return { locator, description: describeLocator(locator) }; + } + /** * Attempts to find an element using a primary locator, and if not found, falls back to a secondary locator. * This is useful for supporting UI transitions (e.g., between legacy and Compose Android screens) where @@ -581,13 +612,10 @@ export class DeviceWrapper { fallbackLocator: LocatorsInterface | StrategyExtractionObj, maxWait: number = 3000 ): Promise { - const primary = - primaryLocator instanceof LocatorsInterface ? primaryLocator.build() : primaryLocator; - const fallback = - fallbackLocator instanceof LocatorsInterface ? fallbackLocator.build() : fallbackLocator; - - const primaryDescription = describeLocator(primary); - const fallbackDescription = describeLocator(fallback); + const { locator: primary, description: primaryDescription } = + this.resolveLocator(primaryLocator); + const { locator: fallback, description: fallbackDescription } = + this.resolveLocator(fallbackLocator); try { return await this.waitForTextElementToBePresent({ ...primary, maxWait, skipHealing: true }); @@ -603,23 +631,54 @@ export class DeviceWrapper { skipHealing: true, }); } catch (fallbackError) { - throw new Error(`Element ${primaryDescription} and ${fallbackDescription} not found.`); + throw new Error(`Element ${primaryDescription} and ${fallbackDescription} not found.`, { + cause: fallbackError, + }); } } } - public async longClick(element: AppiumNextElementType, durationMs: number) { + // Appium taps elements in their center but sometimes that is not desirable + // The native methods apply the tap offset from the top left corner + // For a more intuitive offset calculation, this method allows us to + // define offsets based on the element center + private async calculateGestureOffset( + element: AppiumNextElementType, + offset: Coordinates + ): Promise { + const rect = await this.getElementRect(element.ELEMENT); + if (!rect) { + throw new Error('Failed to resolve element rect for offset calculation'); + } + const { width, height } = rect; + const centerX = Math.round(width / 2); + const centerY = Math.round(height / 2); + // Clamp offset to element bounds + const x = Math.min(Math.max(centerX + offset.x, 0), rect.width); + const y = Math.min(Math.max(centerY + offset.y, 0), rect.height); + return { x, y }; + } + + /** + * @param offset Pixel offset from the element center. + * If an offset is necessary, both x and y must be defined, otherwise Appium doesn't apply the offset parameter. + */ + public async longClick(element: AppiumNextElementType, durationMs: number, offset?: Coordinates) { + let xOffset: number | undefined; + let yOffset: number | undefined; + + if (offset) { + const offsetCoordinates = await this.calculateGestureOffset(element, offset); + xOffset = offsetCoordinates.x; + yOffset = offsetCoordinates.y; + } + if (this.isIOS()) { // iOS takes a number in seconds const duration = Math.floor(durationMs / 1000); - return this.toIOS().mobileTouchAndHold(duration, undefined, undefined, element.ELEMENT); + return this.toIOS().mobileTouchAndHold(duration, xOffset, yOffset, element.ELEMENT); } - return this.toAndroid().mobileLongClickGesture( - element.ELEMENT, - undefined, - undefined, - durationMs - ); + return this.toAndroid().mobileLongClickGesture(element.ELEMENT, xOffset, yOffset, durationMs); } public async clickOnByAccessibilityID( @@ -655,14 +714,45 @@ export class DeviceWrapper { public async clickOnElementAll( args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj) ) { - let el: AppiumNextElementType | null = null; const locator = args instanceof LocatorsInterface ? args.build() : args; - - el = await this.waitForTextElementToBePresent({ ...locator }); + const el = await this.waitForTextElementToBePresent({ ...locator }); await this.click(el.ELEMENT); return el; } + /** + * Clicks an element and retries until an expected element appears, confirming the click registered. + * Useful for flaky taps where Appium reports success but the UI doesn't respond. + * + * @param args - The element to click + * @param waitFor - A locator that should become present after a successful click + */ + public async clickAndWaitFor( + args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj), + waitFor: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj) + ) { + const { description: firstLocator } = this.resolveLocator(args); + const { locator: waitForLocator } = this.resolveLocator(waitFor); + + return this.pollUntil( + async () => { + const el = await this.waitForTextElementToBePresent(args); + await this.click(el.ELEMENT); + try { + await this.waitForTextElementToBePresent({ ...waitForLocator, maxWait: 1_000 }); + return { success: true, data: el }; + } catch { + this.log(`Click on ${firstLocator} did not produce expected result, retrying...`); + return { + success: false, + error: `Click on ${firstLocator} did not produce expected result`, + }; + } + }, + { maxWait: 5_000, pollInterval: 500 } + ); + } + public async clickOnElementByText( args: { text: string; maxWait?: number } & StrategyExtractionObj ) { @@ -735,32 +825,31 @@ export class DeviceWrapper { * @throws if message not found or context menu fails to appear within maxWait */ public async longPressMessage( - args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj) + args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj), + options?: { offset?: Coordinates } ): Promise { - const { text, maxWait = 10_000 } = args; - const locator = args instanceof LocatorsInterface ? args.build() : args; - - // Merge text if provided - const finalLocator = text ? { ...locator, text } : locator; + const { maxWait = 10_000 } = args; + const { locator, description } = this.resolveLocator(args); - const displayText = describeLocator(finalLocator); - this.log(`Attempting long press on ${displayText}`); + this.log(`Attempting long press on ${description}`); await this.pollUntil( async () => { // Find the message - this.log(`Looking for: ${JSON.stringify(finalLocator)}`); + this.log(`Looking for: ${JSON.stringify(locator)}`); const el = await this.waitForTextElementToBePresent({ - ...finalLocator, + ...locator, maxWait: 1_000, }); if (!el) { - return { success: false, error: `Message not found: ${displayText}` }; + return { success: false, error: `Message not found: ${description}` }; + } + if (options?.offset) { + this.log(`Offsetting long press by x=${options?.offset?.x}, y=${options?.offset?.y}`); } - // Attempt long click - await this.longClick(el, 2000); + await this.longClick(el, 2000, options?.offset); // Check if context menu appeared const longPressSuccess = await this.waitForTextElementToBePresent({ @@ -776,7 +865,7 @@ export class DeviceWrapper { return { success: false, - error: `Long press didn't show context menu for ${displayText}`, + error: `Long press didn't show context menu for ${description}`, }; }, { @@ -804,12 +893,12 @@ export class DeviceWrapper { await this.longClick(el, 3000); await sleepFor(1000); - // Pin is the only consistent option in context menu - const longPressSuccess = await this.waitForTextElementToBePresent({ - strategy: 'accessibility id', - selector: 'Pin', - maxWait: 1000, - }); + // Either Pin or Unpin will be present depending on whether the conversation is already pinned + const longPressSuccess = await this.findWithFallback( + new PinConversationOption(this), + new UnpinConversationOption(this), + 1000 + ); if (longPressSuccess) { this.log('LongClick successful'); @@ -831,6 +920,18 @@ export class DeviceWrapper { } } + public async pinConversation(name: string) { + await this.onIOS().swipeLeft('Conversation list item', name); + await this.onAndroid().longPressConversation(name); + await this.clickOnElementAll(new PinConversationOption(this)); + } + + public async unpinConversation(name: string) { + await this.onIOS().swipeLeft('Conversation list item', name); + await this.onAndroid().longPressConversation(name); + await this.clickOnElementAll(new UnpinConversationOption(this)); + } + public async pressAndHold(accessibilityId: AccessibilityId) { const el = await this.waitForTextElementToBePresent({ strategy: 'accessibility id', @@ -869,10 +970,8 @@ export class DeviceWrapper { public async deleteText( args: LocatorsInterface | ({ text?: string; maxWait?: number } & StrategyExtractionObj) ) { - let el: AppiumNextElementType | null = null; const locator = args instanceof LocatorsInterface ? args.build() : args; - - el = await this.waitForTextElementToBePresent({ ...locator }); + const el = await this.waitForTextElementToBePresent({ ...locator }); await this.click(el.ELEMENT); await sleepFor(100); const maxRetries = 3; @@ -1003,8 +1102,14 @@ export class DeviceWrapper { if (elements && elements.length) { const matching = await this.findAsync(elements, async e => { const text = await this.getTextFromElement(e); - const isPartialMatch = text && text.toLowerCase().includes(textToLookFor.toLowerCase()); - return Boolean(isPartialMatch); + // Strip LTR/RTL markers and other whitespace nonsense + const normalize = (s: string) => + s + .replace(/[\u200e\u200f\u202a-\u202e]/g, '') + .trim() + .toLowerCase(); + const isExactMatch = text && normalize(text) === normalize(textToLookFor); + return Boolean(isExactMatch); }); return matching || null; @@ -1044,134 +1149,119 @@ export class DeviceWrapper { const message = await this.findMatchingTextAndAccessibilityId('Message body', textToLookFor); return message; } + /** - * Attempts to visually match a reference image against all elements found by the given locator, - * and taps the best match (or the first high-confidence match if earlyMatch is enabled). - * This is useful for scenarios where UI elements cannot be reliably identified, - * such as elements with date-based accessibility IDs. + * Attempts to visually match a reference image against all instances found by the given locator, and taps the best match. + * All element screenshots are taken in parallel. + * If the method finds 0 results for a locator, retries with exponential backoff up to 5 seconds. * * @param locator - The strategy and selector to find candidate elements. * @param referenceImageName - The filename of the reference image (in the media directory). - * @param earlyMatch - If true, taps immediately on the first match above the earlyMatchThreshold. * @throws If no suitable match is found among the candidate elements. */ public async matchAndTapImage( locator: StrategyExtractionObj, - referenceImageName: string, - earlyMatch: boolean = true + referenceImageName: string ): Promise { const threshold = 0.85; - const earlyMatchThreshold = 0.97; - // Find all candidate elements matching the locator - const elements = await this.findElements(locator.strategy, locator.selector); + // Retry findElements with exponential backoff — photo picker may not have rendered yet + let elements = await this.findElements(locator.strategy, locator.selector); + if (elements.length === 0) { + let delay = 100; + const maxWait = 5000; + const start = Date.now(); + while (elements.length === 0 && Date.now() - start < maxWait) { + await sleepFor(delay); + delay = Math.min(delay * 2, 1600); + elements = await this.findElements(locator.strategy, locator.selector); + } + } + this.info( `[matchAndTapImage] Starting image matching: ${elements.length} elements with ${locator.strategy} "${locator.selector}"` ); - // Load the reference image buffer from disk + // Load the reference image buffer from disk once const referencePath = path.join('run', 'test', 'media', referenceImageName); await fs.access(referencePath).catch(() => { throw new Error(`Reference image not found: ${referencePath}`); }); const referenceBuffer = await fs.readFile(referencePath); + // Reference metadata never changes across elements + const refMeta = await sharp(referenceBuffer).metadata(); - let bestMatch: { - center: { x: number; y: number }; - score: number; - } | null = null; - - // Iterate over each candidate element - for (const el of elements) { - // Take a screenshot of the element - const base64 = await this.getElementScreenshot(el.ELEMENT); - const elementBuffer = Buffer.from(base64, 'base64'); + // Phase 1: screenshot + comparison in parallel + const results = await Promise.all( + elements.map(async el => { + const base64 = await this.getElementScreenshot(el.ELEMENT); + const elementBuffer = Buffer.from(base64, 'base64'); - // Get the element's rectangle (position and size) - const rect = await this.getElementRect(el.ELEMENT); - if (!rect) { - continue; - } - // Get actual pixel dimensions of the element screenshot - const elementMeta = await sharp(elementBuffer).metadata(); - // Get original reference image dimensions - const refMeta = await sharp(referenceBuffer).metadata(); + const elementMeta = await sharp(elementBuffer).metadata(); - let resizedRef: Buffer; + let resizedRef: Buffer; + let resizedMeta: Awaited>; - if (elementMeta.width === refMeta.width && elementMeta.height === refMeta.height) { - // Skip resizing if reference already matches the screenshot dimensions - resizedRef = referenceBuffer; - } else { - // Resize the reference image to exactly match the screenshot dimensions - const targetWidth = elementMeta.width; - const targetHeight = elementMeta.height; + if (elementMeta.width === refMeta.width && elementMeta.height === refMeta.height) { + // Skip resizing if reference already matches the screenshot dimensions + resizedRef = referenceBuffer; + resizedMeta = refMeta; + } else { + resizedRef = await sharp(referenceBuffer) + .resize(elementMeta.width, elementMeta.height) + .toBuffer(); + resizedMeta = await sharp(resizedRef).metadata(); + } - resizedRef = await sharp(referenceBuffer).resize(targetWidth, targetHeight).toBuffer(); - } + try { + const { rect: matchRect, score } = await getImageOccurrence(elementBuffer, resizedRef, { + threshold, + }); + return { el, matchRect, score, resizedMeta }; + } catch { + return null; + } + }) + ); - try { - const { rect: matchRect, score } = await getImageOccurrence(elementBuffer, resizedRef, { - threshold, - }); + type MatchResult = NonNullable<(typeof results)[number]>; + const bestResult = results + .filter((r): r is MatchResult => r !== null) + .reduce((best, r) => (!best || r.score > best.score ? r : best), null); - /** - * Matching is done on a resized reference image to account for device pixel density. - * However, the coordinates returned by getImageOccurrence are relative to the resized buffer, - * *not* the original screen element. This leads to incorrect tap positions unless we - * scale the match result back down to the actual dimensions of the element. - * The logic below handles this scaling correction, ensuring the tap lands at the correct - * screen coordinates — even when Retina displays and image resizing are involved. - */ - - // Calculate scale between resized image and element dimensions - const resizedMeta = await sharp(resizedRef).metadata(); - const scaleX = rect.width / (resizedMeta.width ?? rect.width); - const scaleY = rect.height / (resizedMeta.height ?? rect.height); - - // Calculate center of the match rectangle (in buffer space) - const matchCenterX = matchRect.x + Math.floor(matchRect.width / 2); - const matchCenterY = matchRect.y + Math.floor(matchRect.height / 2); - - // Scale match center down to element space - const scaledCenterX = matchCenterX * scaleX; - const scaledCenterY = matchCenterY * scaleY; - - // Final absolute coordinates - const tapX = Math.round(rect.x + scaledCenterX); - const tapY = Math.round(rect.y + scaledCenterY); - - const center = { x: tapX, y: tapY }; - - // If earlyMatch is enabled and the score is high enough, tap immediately - if (earlyMatch && score >= earlyMatchThreshold) { - this.info( - `[matchAndTapImage] Tapping first high-confidence match (${(score * 100).toFixed(2)}%)` - ); - await clickOnCoordinates(this, center); - return; - } - // Otherwise, keep track of the best match so far - if (!bestMatch || score > bestMatch.score) { - bestMatch = { center, score }; - } - } catch { - continue; // No match in this element, try next - } - } - // If no good match was found, throw an error - if (!bestMatch) { + if (!bestResult) { console.log( `[matchAndTapImage] No matching image found among ${elements.length} elements for ${locator.strategy} "${locator.selector}"` ); throw new Error('Unable to find the expected UI element on screen'); } - // Tap the best match found - this.info( - `[matchAndTapImage] Tapping best match with ${(bestMatch.score * 100).toFixed(2)}% confidence` - ); - await clickOnCoordinates(this, bestMatch.center); + + // Phase 2: fetch rect only for the winning element to determine tap coords + const rect = await this.getElementRect(bestResult.el.ELEMENT); + + if (!rect) { + throw new Error('Unable to get rect for matched element'); + } + + /** + * Matching is done on a resized reference image to account for device pixel density. + * However, the coordinates returned by getImageOccurrence are relative to the resized buffer, + * *not* the original screen element. This leads to incorrect tap positions unless we + * scale the match result back down to the actual dimensions of the element. + * The logic below handles this scaling correction, ensuring the tap lands at the correct + * screen coordinates — even when Retina displays and image resizing are involved. + */ + const { matchRect, resizedMeta } = bestResult; + const scaleX = rect.width / (resizedMeta.width ?? rect.width); + const scaleY = rect.height / (resizedMeta.height ?? rect.height); + const matchCenterX = matchRect.x + Math.floor(matchRect.width / 2); + const matchCenterY = matchRect.y + Math.floor(matchRect.height / 2); + const tapX = Math.round(rect.x + matchCenterX * scaleX); + const tapY = Math.round(rect.y + matchCenterY * scaleY); + + await clickOnCoordinates(this, { x: tapX, y: tapY }); } + /** * Checks if an element exists on the screen without throwing an error. * Only useful for scenarios where you want to interact with an element if it exists @@ -1216,7 +1306,7 @@ export class DeviceWrapper { maxWait?: number; } & (LocatorsInterface | StrategyExtractionObj) ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; + const { locator, description } = this.resolveLocator(args); const maxWait = args.maxWait || 2_000; // Wait for any transitions to complete @@ -1224,23 +1314,26 @@ export class DeviceWrapper { const element = await this.findElementQuietly(locator, args.text); - const description = describeLocator({ ...locator, text: args.text }); - if (element) { // Elements can disappear in the GUI but still be present in the DOM + let isVisible: boolean; try { - const isVisible = await this.isVisible(element.ELEMENT); - if (isVisible) { - throw new Error( - `Element with ${description} is visible after ${maxWait}ms when it should not be` - ); - } - // Element exists but not visible - that's okay - this.log(`Element with ${description} exists but is not visible`); + isVisible = await this.isVisible(element.ELEMENT); } catch (e) { - // Stale element or other error - element is gone, that's okay - this.log(`Element with ${description} is not present (stale reference)`); + // Stale reference or other error checking visibility + const errorMsg = e instanceof Error ? e.message : String(e); + throw new Error( + `Element with ${description} has stale reference or error checking visibility: ${errorMsg}` + ); } + + if (isVisible) { + throw new Error( + `Element with ${description} is visible after ${maxWait}ms when it should not be` + ); + } + // Element exists but not visible - that's okay + this.log(`Element with ${description} exists but is not visible`); } else { this.log(`Verified no element with ${description} is present`); } @@ -1265,13 +1358,11 @@ export class DeviceWrapper { maxWait?: number; } & (LocatorsInterface | StrategyExtractionObj) ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; + const { locator, description } = this.resolveLocator(args); const text = args.text; const initialMaxWait = args.initialMaxWait ?? 10_000; const maxWait = args.maxWait ?? 30_000; - const description = describeLocator({ ...locator, text: args.text }); - // Track total time from start - disappearing timers begin on send, not on display const functionStartTime = Date.now(); // Phase 1: Wait for element to appear @@ -1316,13 +1407,11 @@ export class DeviceWrapper { maxWait?: number; } & (LocatorsInterface | StrategyExtractionObj) ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; + const { locator, description } = this.resolveLocator(args); const text = args.text; const initialMaxWait = args.initialMaxWait ?? 10_000; const maxWait = args.maxWait ?? 30_000; - const description = describeLocator({ ...locator, text: args.text }); - // Phase 1: Wait for element to appear this.log(`Waiting for element with ${description} to be deleted...`); await this.waitForElementToAppear(locator, initialMaxWait, text); @@ -1628,7 +1717,7 @@ export class DeviceWrapper { // Success when element is GONE return { success: !element }; }, - { maxWait: 15_000 } + { maxWait: 18_000 } ); this.info('Loading animation has finished'); @@ -1649,7 +1738,7 @@ export class DeviceWrapper { } = {} ): Promise { const start = Date.now(); - let elapsed = 0; + let elapsed: number; let attempt = 0; let lastError: string | undefined; @@ -1692,8 +1781,7 @@ export class DeviceWrapper { expectedColor: string, tolerance?: number ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; - const description = describeLocator({ ...locator, text: args.text }); + const { locator, description } = this.resolveLocator(args); this.log(`Waiting for ${description} to have color #${expectedColor}`); @@ -1772,14 +1860,12 @@ export class DeviceWrapper { return message; } - public async sendMessageTo(sender: User, receiver: Group | User) { - const message = `${sender.userName} to ${receiver.userName}`; - await this.clickOnElementAll(new ConversationItem(this, receiver.userName)); - this.log(`${sender.userName} + " sent message to ${receiver.userName}`); - await this.sendMessage(message); - this.log(`Message received by ${receiver.userName} from ${sender.userName}`); - return message; + public async acceptMessageRequestWithButton() { + await this.clickOnElementAll(new MessageRequestsBanner(this)); + await this.clickOnElementAll(new MessageRequestItem(this)); + await this.clickOnElementAll(new AcceptMessageRequestButton(this)); } + // TODO instead of blind sleeping, check presence of reply preview // Remove blind sleep from other tests that reply as well public async replyToMessage(user: Pick, body: string) { @@ -1814,25 +1900,48 @@ export class DeviceWrapper { public async inputText( textToInput: string, - args: LocatorsInterface | ({ maxWait?: number } & StrategyExtractionObj) + args: LocatorsInterface | ({ maxWait?: number } & StrategyExtractionObj), + paste: boolean = false ) { - let el: AppiumNextElementType | null = null; const locator = args instanceof LocatorsInterface ? args.build() : args; - this.log('Locator being used:', locator); - - el = await this.waitForTextElementToBePresent({ ...locator }); - if (!el) { - throw new Error(`inputText: Did not find element with locator: ${JSON.stringify(locator)}`); + if (paste) { + // Set clipboard, press key-code for instant paste + await this.clickOnElementAll({ ...locator }); + if (this.isAndroid()) { + await this.toAndroid().setClipboard( + Buffer.from(textToInput).toString('base64'), + 'plaintext' + ); + await this.toAndroid().pressKeyCode(279); + } else { + // Use native paste UI, accept perms if needed + await this.toIOS().mobileSetPasteboard(textToInput); + await this.toIOS().mobileGetPasteboard(); + await this.processPermissions({ strategy: 'accessibility id', selector: 'Allow Paste' }); + await this.clickOnElementAll({ ...locator }); + await this.clickOnByAccessibilityID('Paste'); + } + } else { + const el = await this.waitForTextElementToBePresent({ ...locator }); + await this.setValueImmediate(textToInput, el.ELEMENT); } - - await this.setValueImmediate(textToInput, el.ELEMENT); } public async getAttribute(attribute: string, elementId: string) { return this.toShared().getAttribute(attribute, elementId); } + public async assertAttribute( + element: LocatorsInterface | StrategyExtractionObj, + attribute: string, + value: string + ) { + const el = await this.waitForTextElementToBePresent(element); + const received = await this.getAttribute(attribute, el.ELEMENT); + verify(received, 'Element attribute value mismatch').toBe(value); + } + public async disappearRadioButtonSelected( platform: SupportedPlatformsType, timeOption: DISAPPEARING_TIMES @@ -1862,7 +1971,12 @@ export class DeviceWrapper { } public async pushMediaToDevice( - mediaFileName: 'profile_picture.jpg' | 'test_file.pdf' | 'test_image.jpg' | 'test_video.mp4' + mediaFileName: + | 'animated_profile_picture.gif' + | 'profile_picture.jpg' + | 'test_file.pdf' + | 'test_image.jpg' + | 'test_video.mp4' ) { const filePath = path.join('run', 'test', 'media', mediaFileName); await fs.access(filePath).catch(() => { @@ -1870,6 +1984,9 @@ export class DeviceWrapper { }); if (this.isIOS()) { // Push file to simulator + this.warn( + `pushMediaToDevice on iOS is deprecated. Consider pre-loading it on simulator creation` + ); await runScriptAndLog(`xcrun simctl addmedia ${this.udid} ${filePath}`, true); } else if (this.isAndroid()) { const ANDROID_DOWNLOAD_DIR = '/storage/emulated/0/Download'; @@ -1896,9 +2013,11 @@ export class DeviceWrapper { if (this.isIOS()) { await this.clickOnElementAll(new AttachmentsButton(this)); await this.clickOnElementAll(new ImagesFolderButton(this)); - await sleepFor(1000); await this.modalPopup({ strategy: 'accessibility id', selector: 'Allow Full Access' }); - await sleepFor(2000); // Appium needs a moment, matchAndTapImage sometimes finds 0 elements otherwise + await this.waitForTextElementToBePresent({ + strategy: 'accessibility id', + selector: 'Recents', + }); await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeCell` }, testImage @@ -1916,12 +2035,12 @@ export class DeviceWrapper { await sleepFor(500); await this.clickOnElementAll({ strategy: 'id', - selector: 'network.loki.messenger:id/mediapicker_folder_item_thumbnail', + selector: 'mediapicker-folder-item-thumbnail-0', }); await sleepFor(100); await this.clickOnElementAll({ strategy: 'id', - selector: 'network.loki.messenger:id/mediapicker_image_item_thumbnail', + selector: 'mediapicker-image-item-thumbnail-0', }); } await this.inputText(message, new MessageInput(this)); @@ -1940,12 +2059,11 @@ export class DeviceWrapper { // iOS files are pre-loaded on simulator creation, no need to push await this.clickOnElementAll(new AttachmentsButton(this)); await this.clickOnElementAll(new ImagesFolderButton(this)); - await sleepFor(100); await this.modalPopup({ strategy: 'accessibility id', selector: 'Allow Full Access', }); - await sleepFor(2000); // Appium needs a moment, matchAndTapImage sometimes finds 0 elements otherwise + await this.waitForTextElementToBePresent({ strategy: 'accessibility id', selector: 'Recents' }); // A video can't be matched by its thumbnail so we use a video thumbnail file await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeCell` }, @@ -2146,7 +2264,17 @@ export class DeviceWrapper { return sentTimestamp; } - public async uploadProfilePicture() { + public async uploadProfilePicture(animated: boolean = false) { + let uploadPicture: 'animated_profile_picture.gif' | 'profile_picture.jpg'; + let dpLocator: LocatorsInterface; + if (animated) { + uploadPicture = animatedProfilePicture; + dpLocator = new GIFName(this); + } else { + uploadPicture = profilePicture; + dpLocator = new ImageName(this); + } + await this.clickOnElementAll(new UserSettings(this)); // Click on Profile picture await this.clickOnElementAll(new UserAvatar(this)); @@ -2154,15 +2282,18 @@ export class DeviceWrapper { // iOS files are pre-loaded on simulator creation, no need to push if (this.isIOS()) { await this.modalPopup({ strategy: 'accessibility id', selector: 'Allow Full Access' }); - await sleepFor(5000); // sometimes Appium doesn't recognize the XPATH immediately + await this.waitForTextElementToBePresent({ + strategy: 'accessibility id', + selector: 'Collections', + }); await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeImage` }, - profilePicture + uploadPicture ); await this.clickOnByAccessibilityID('Done'); } else if (this.isAndroid()) { // Push file first - await this.pushMediaToDevice(profilePicture); + await this.pushMediaToDevice(uploadPicture); await this.clickOnElementAll(new ImagePermissionsModalAllow(this)); await sleepFor(1000); await this.clickOnElementAll({ @@ -2170,8 +2301,10 @@ export class DeviceWrapper { selector: 'Image button', }); await sleepFor(500); - await this.clickOnElementAll(new ImageName(this)); - await this.clickOnElementById('network.loki.messenger:id/crop_image_menu_crop'); + await this.clickOnElementAll(dpLocator); + if (!animated) { + await this.clickOnElementById('network.loki.messenger:id/crop_image_menu_crop'); + } } await this.clickOnElementAll(new SaveProfilePictureButton(this)); } @@ -2261,11 +2394,13 @@ export class DeviceWrapper { this.info('Swiped left on ', selector); } + + // Swipe horizontally from 20% to 80% of screen width at the vertical center public async swipeRight() { const { width, height } = await this.getWindowRect(); - // Swipe horizontally from 20% to 80% of screen width at the vertical center await this.scroll({ x: width * 0.2, y: height / 2 }, { x: width * 0.8, y: height / 2 }, 100); } + public async swipeLeft(accessibilityId: AccessibilityId, text: string) { const el = await this.findMatchingTextAndAccessibilityId(accessibilityId, text); @@ -2285,14 +2420,19 @@ export class DeviceWrapper { // let some time for swipe action to happen and UI to update } + // Swipe vertically from 70% to 30% of screen height at the horizontal center public async scrollDown() { - await this.scroll({ x: 760, y: 1500 }, { x: 760, y: 710 }, 100); + const { width, height } = await this.getWindowRect(); + await this.scroll({ x: width / 2, y: height * 0.7 }, { x: width / 2, y: height * 0.3 }, 100); } + // Swipe vertically from 30% to 70% of screen height at the horizontal center public async scrollUp() { - await this.scroll({ x: 760, y: 710 }, { x: 760, y: 1500 }, 100); + const { width, height } = await this.getWindowRect(); + await this.scroll({ x: width / 2, y: height * 0.3 }, { x: width / 2, y: height * 0.7 }, 100); } + // Swipe vertically from 95% to 35% of screen height at the horizontal center public async swipeFromBottom(): Promise { const { width, height } = await this.getWindowRect(); @@ -2347,19 +2487,15 @@ export class DeviceWrapper { public async turnOnReadReceipts() { await this.navigateBack(); - await sleepFor(100); await this.clickOnElementAll(new UserSettings(this)); - await sleepFor(500); await this.clickOnElementAll(new PrivacyMenuItem(this)); - await sleepFor(2000); await this.clickOnElementAll(new ReadReceiptsButton(this)); await this.navigateBack(false); - await sleepFor(100); await this.clickOnElementAll(new CloseSettings(this)); } - public async processPermissions(locator: LocatorsInterface) { - const locatorConfig = locator.build(); + public async processPermissions(locator: LocatorsInterface | StrategyExtractionObj) { + const locatorConfig = locator instanceof LocatorsInterface ? locator.build() : locator; if (this.isAndroid()) { const permissions = await this.doesElementExist({ @@ -2507,64 +2643,82 @@ export class DeviceWrapper { this.assertTextMatches(actualDescription, expectedDescription, 'Modal description'); } - /** - * Checks CTA component text against expected values. - * CTAs contain: heading, body, 0-3 features, 1-2 buttons. - * @param heading - Expected CTA heading text - * @param body - Expected CTA body text - * @param buttons - Expected button text(s). First is positive, second (if present) is negative - * @param features - Optional array of expected feature text (0-3 items) - * @throws Error if any text element doesn't match expected value - */ - public async checkCTAStrings( - heading: string, - body: string, - buttons: string[], - features?: string[] - ): Promise { - // Validate input + private async checkCTAStrings({ + heading, + body, + negativeButton, + positiveButton, + features, + }: CTAConfig): Promise { if (features && features.length > 3) { throw new Error('CTAs support maximum 3 features'); } - if (buttons.length < 1 || buttons.length > 2) { - throw new Error('CTAs must have 1-2 buttons'); - } - // Find and check heading + // CTA heading const elHeading = await this.waitForTextElementToBePresent(new CTAHeading(this)); const actualHeading = await this.getTextFromElement(elHeading); this.assertTextMatches(actualHeading, heading, 'CTA heading'); - // Find and check body - const elBody = await this.waitForTextElementToBePresent(new CTABody(this)); - const actualBody = await this.getTextFromElement(elBody); - this.assertTextMatches(actualBody, body, 'CTA body'); - - // Check features if expected + // iOS may split the body around inline images, producing multiple cta-body elements. + // Wait for the first, then find all and check that the expected text appears in any of them. + await this.waitForTextElementToBePresent(new CTABody(this)); + const { strategy, selector } = new CTABody(this).build(); + const bodyElements = await this.findElements(strategy, selector, true); + const bodyTexts = await Promise.all(bodyElements.map(el => this.getTextFromElement(el))); + const matchingText = + bodyTexts.find(t => this.sanitizeString(t) === this.sanitizeString(body)) ?? bodyTexts[0]; + this.assertTextMatches(matchingText, body, 'CTA body'); + + // CTA features if present if (features) { for (let i = 0; i < features.length; i++) { - const featureLocator = new CTAFeature(this, i + 1); - const elFeature = await this.waitForTextElementToBePresent(featureLocator); + const elFeature = await this.waitForTextElementToBePresent(new CTAFeature(this, i)); const actualFeature = await this.getTextFromElement(elFeature); this.assertTextMatches(actualFeature, features[i], `CTA feature ${i + 1}`); } } - // Check buttons - const positiveLocator = new CTAButtonPositive(this); - const elPositive = await this.waitForTextElementToBePresent(positiveLocator); - const actualPositive = await this.getTextFromElement(elPositive); - this.assertTextMatches(actualPositive, buttons[0], 'CTA positive button'); - - if (buttons.length === 2) { - const negativeLocator = new CTAButtonNegative(this); - const elNegative = await this.waitForTextElementToBePresent(negativeLocator); + if (negativeButton) { + const elNegative = await this.waitForTextElementToBePresent(new CTAButtonNegative(this)); const actualNegative = await this.getTextFromElement(elNegative); - this.assertTextMatches(actualNegative, buttons[1], 'CTA negative button'); + this.assertTextMatches(actualNegative, negativeButton, 'CTA negative button'); + } + + if (positiveButton) { + const elPositive = await this.waitForTextElementToBePresent(new CTAButtonPositive(this)); + const actualPositive = await this.getTextFromElement(elPositive); + this.assertTextMatches(actualPositive, positiveButton, 'CTA positive button'); } } - public async getElementPixelColor(args: LocatorsInterface): Promise { + public async checkCTA(type: CTAType): Promise { + await this.checkCTAStrings(ctaConfigs[type]); + } + + public async verifyNoCTAShows(): Promise { + await Promise.all([ + this.verifyElementNotPresent(new CTAHeading(this)), + this.verifyElementNotPresent(new CTABody(this)), + this.verifyElementNotPresent(new CTAButtonNegative(this)), + this.verifyElementNotPresent(new CTAButtonPositive(this)), + ]); + } + + // Dismiss any CTA if it shows + public async dismissCTA(): Promise { + const hasCTAAppeared = await this.doesElementExist({ + ...new CTAHeading(this).build(), + maxWait: 8_000, + }); + if (hasCTAAppeared) { + this.log('Dismissing CTA'); + await this.clickOnCoordinates(150, 150); + } + } + + public async getElementPixelColor( + args: LocatorsInterface | StrategyExtractionObj + ): Promise { // Wait for the element to be present const element = await this.waitForTextElementToBePresent(args); // Take a screenshot and return a hex color value @@ -2573,9 +2727,28 @@ export class DeviceWrapper { return pixelColor; } + // Sample an element's centre pixel color SAMPLE_SIZE times to determine whether it is animated or not. + // If the set contains more than 1 color it is likely animated. + public async verifyElementIsAnimated( + args: LocatorsInterface | StrategyExtractionObj + ): Promise { + const { locator, description } = this.resolveLocator(args); + this.log(`Checking if ${description} is animated`); + const SAMPLE_SIZE = 3; + const colors = new Set(); + for (let i = 0; i < SAMPLE_SIZE; i++) { + colors.add(await this.getElementPixelColor(locator)); + } + verify( + colors.size, + `Expected element to be animated but detected 1 unique color: ${[...colors][0]}` + ).toBeGreaterThan(1); + } + public async getVersionNumber() { // NOTE if this becomes necessary for more tests, consider adding a property/caching to the DeviceWrapper await this.clickOnElementAll(new UserSettings(this)); + await this.onIOS().scrollDown(); const versionElement = await this.waitForTextElementToBePresent(new VersionNumber(this)); // Get the full text from the element const versionText = await this.getTextFromElement(versionElement); diff --git a/run/types/allure.ts b/run/types/allure.ts index 2f9ff1430..fd621d9d2 100644 --- a/run/types/allure.ts +++ b/run/types/allure.ts @@ -31,10 +31,15 @@ export type AllureSuiteConfig = parent: 'Sending Messages'; suite: 'Emoji reacts' | 'Mentions' | 'Message types' | 'Performance' | 'Rules'; } - | { parent: 'Settings'; suite: 'App Disguise' | 'Community Message Requests' } + | { parent: 'Session Pro' } + | { + parent: 'Settings'; + suite: 'App Disguise' | 'Community Message Requests' | 'Notifications' | 'Recovery Password'; + } | { parent: 'User Actions'; suite: + | 'Ban/Unban' | 'Block/Unblock' | 'Change Profile Picture' | 'Change Username' @@ -42,6 +47,7 @@ export type AllureSuiteConfig = | 'Delete Conversation' | 'Delete Message' | 'Hide Note to Self' + | 'Pin/Unpin' | 'Set Nickname' | 'Share to Session'; } @@ -67,11 +73,13 @@ export const TestSteps = { NEW_USER: 'Create new account', QA_SEEDER: 'Restore pre-seeded accounts', CLOSE_APP: 'Close app(s)', + RESTORE_ACCOUNT: (name: UserNameType) => `Restore ${name} on another device`, }, // Plus Button options NEW_CONVERSATION: { NEW_MESSAGE: 'New Message', JOIN_COMMUNITY: 'Join Community', + JOIN_COMMUNITIES: (number: number) => `Join ${number} communities`, }, // Sending things SEND: { @@ -95,6 +103,9 @@ export const TestSteps = { CHANGE_PROFILE_PICTURE: 'Change profile picture', APP_DISGUISE: 'Set App Disguise', DELETE_FOR_EVERYONE: 'Delete for everyone', + GROUPS_ADD_CONTACT: (name: string) => `Invite ${name} to group`, + GROUPS_REMOVE_MEMBER: (name: string) => `Remove ${name} from group`, + PIN_CONVERSATIONS: (number: number) => `Attempt to pin ${number} conversations`, }, // Disappearing Messages DISAPPEARING_MESSAGES: { @@ -120,5 +131,6 @@ export const TestSteps = { NICKNAME_CHANGED: (context: string) => `Verify nickname changed in/on ${context}`, PROFILE_PICTURE_CHANGED: 'Verify profile picture has been changed', EMOJI_REACT: 'Verify emoji react appears for everyone', + GROUP_DELETED: 'Verify group is deleted for all members', }, }; diff --git a/run/types/cta.ts b/run/types/cta.ts new file mode 100644 index 000000000..acac9cb4c --- /dev/null +++ b/run/types/cta.ts @@ -0,0 +1,62 @@ +import { tStripped } from '../localizer/lib'; + +export type CTAType = + | 'alreadyActivated' + | 'animatedProfilePicture' + | 'donate' + | 'longerMessages' + | 'pinnedConversations'; + +export type CTAConfig = { + heading: string; + body: string; + negativeButton?: string; + positiveButton?: string; + features?: string[]; +}; + +export const ctaConfigs: Record = { + donate: { + heading: tStripped('donateSessionAppealTitle'), + body: tStripped('donateSessionAppealDescription'), + positiveButton: tStripped('donateSessionAppealReadMore'), + }, + longerMessages: { + heading: tStripped('upgradeTo'), + body: tStripped('proCallToActionLongerMessages'), + negativeButton: tStripped('cancel'), + positiveButton: tStripped('theContinue'), + features: [ + tStripped('proFeatureListLongerMessages'), + tStripped('proFeatureListPinnedConversations'), + tStripped('proFeatureListLoadsMore'), + ], + }, + animatedProfilePicture: { + heading: tStripped('upgradeTo'), + body: tStripped('proAnimatedDisplayPictureCallToActionDescription'), + negativeButton: tStripped('cancel'), + positiveButton: tStripped('theContinue'), + features: [ + tStripped('proFeatureListAnimatedDisplayPicture'), + tStripped('proFeatureListLongerMessages'), + tStripped('proFeatureListLoadsMore'), + ], + }, + alreadyActivated: { + heading: tStripped('proActivated'), + body: tStripped('proAnimatedDisplayPicture'), + negativeButton: tStripped('close'), + }, + pinnedConversations: { + heading: tStripped('upgradeTo'), + body: tStripped('proCallToActionPinnedConversationsMoreThan', { limit: '5' }), + negativeButton: tStripped('cancel'), + positiveButton: tStripped('theContinue'), + features: [ + tStripped('proFeatureListPinnedConversations'), + tStripped('proFeatureListLongerMessages'), + tStripped('proFeatureListLoadsMore'), + ], + }, +}; diff --git a/run/types/sessionIt.ts b/run/types/sessionIt.ts index 0b1be85bb..635e6deb5 100644 --- a/run/types/sessionIt.ts +++ b/run/types/sessionIt.ts @@ -1,16 +1,13 @@ -// run/types/sessionIt.ts - Clean version matching original pattern import { test, type TestInfo } from '@playwright/test'; import { omit } from 'lodash'; import type { AppCountPerTest } from '../test/state_builder'; import { setupAllureTestInfo } from '../test/utils/allure/allureHelpers'; +import { unregisterDevicesForTest } from '../test/utils/device_registry'; import { getNetworkTarget } from '../test/utils/devnet'; +import { captureLogsOnFailure, captureScreenshotsOnFailure } from '../test/utils/failure_artifacts'; import { SupportedPlatformsType } from '../test/utils/open_app'; -import { - captureScreenshotsOnFailure, - unregisterDevicesForTest, -} from '../test/utils/screenshot_helper'; import { AllureSuiteConfig } from './allure'; import { TestRisk } from './testing'; @@ -22,6 +19,7 @@ type MobileItArgs = { risk: TestRisk; testCb: (platform: SupportedPlatformsType, testInfo: TestInfo) => Promise; shouldSkip?: boolean; + isPro?: boolean; allureSuites?: AllureSuiteConfig; allureDescription?: string; allureLinks?: { @@ -45,12 +43,14 @@ function mobileIt({ testCb, title, shouldSkip = false, + isPro = false, countOfDevicesNeeded, allureSuites, allureDescription, allureLinks, }: MobileItArgs) { - const testName = `${title} @${platform} @${risk ?? 'default'}-risk @${countOfDevicesNeeded}-devices`; + const proTag = isPro ? ' @pro' : ''; + const testName = `${title} @${platform} @${risk ?? 'default'}-risk @${countOfDevicesNeeded}-devices${proTag}`; if (shouldSkip) { test.skip(testName, () => { @@ -105,9 +105,10 @@ function mobileIt({ testInfo.status === 'timedOut' ) { await captureScreenshotsOnFailure(testInfo); + await captureLogsOnFailure(testInfo); } - } catch (screenshotError) { - console.error('Failed to capture screenshot:', screenshotError); + } catch (artifactError) { + console.error('Failed to capture failure artifacts:', artifactError); } try { diff --git a/run/types/testing.ts b/run/types/testing.ts index d1ceec346..72ad7c5bb 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -13,7 +13,6 @@ export const USERNAME = usernameFromSeeder; export type GROUPNAME = | 'Disappear after send test' | 'Disappear after sent test' - | 'Group to test adding contact' | 'Kick member' | 'Leave group' | 'Leave group linked device' @@ -47,6 +46,7 @@ export type Coordinates = { export const InteractionPoints: Record = { BackToSession: { x: 42, y: 42 }, + AndroidConvoSettingsQRCode: { x: 627, y: 329 }, }; export type Strategy = '-android uiautomator' | 'accessibility id' | 'class name' | 'id' | 'xpath'; @@ -125,10 +125,12 @@ export type XPath = | `(//XCUIElementTypeImage[@name="gif cell"])[1]` | `//*[./*[@name='${DISAPPEARING_TIMES}']]/*[2]` | `//*[@resource-id='network.loki.messenger:id/callTitle' and contains(@text, ':')]` + | `//*[starts-with(@content-desc, "GIF taken on")]` | `//*[starts-with(@content-desc, "Photo taken on")]` | `//android.view.ViewGroup[@resource-id='network.loki.messenger:id/mainContainer'][.//android.widget.TextView[contains(@text,'${string}')]]//androidx.compose.ui.platform.ComposeView[@resource-id='network.loki.messenger:id/profilePictureView']` | `//android.view.ViewGroup[@resource-id="network.loki.messenger:id/mainContainer"][.//android.widget.TextView[contains(@text,"${string}")]]//android.view.ViewGroup[@resource-id="network.loki.messenger:id/layout_emoji_container"]` | `//android.view.ViewGroup[@resource-id="network.loki.messenger:id/mainContainer"][.//android.widget.TextView[contains(@text,"${string}")]]//android.widget.TextView[@resource-id="network.loki.messenger:id/reactions_pill_count"][@text="${string}"]` + | `//android.view.ViewGroup[android.widget.TextView[@content-desc='Conversation list item' and @text='${string}']]/android.widget.ImageView[@resource-id='network.loki.messenger:id/iconPinned']` | `//android.widget.LinearLayout[.//android.widget.TextView[@content-desc="Conversation list item" and @text="${string}"]]//android.widget.TextView[@resource-id="network.loki.messenger:id/snippetTextView" and @text="${string}"]` | `//android.widget.TextView[@text="${string}"]` | `//android.widget.TextView[@text="Message"]/parent::android.view.View` @@ -150,6 +152,7 @@ export type XPath = | `//XCUIElementTypeStaticText[contains(@name, '00:')]` | `//XCUIElementTypeStaticText[contains(@name, "Version")]` | `//XCUIElementTypeStaticText[starts-with(@name,'${string}')]` + | `//XCUIElementTypeStaticText` | `//XCUIElementTypeSwitch[@name="Read Receipts, Send read receipts in one-to-one chats."]` | `/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.ScrollView/android.widget.LinearLayout/android.widget.LinearLayout/android.widget.LinearLayout[2]/android.widget.Button[1]` | `/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.ListView/android.widget.LinearLayout` @@ -159,16 +162,13 @@ export type XPath = | `/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.ScrollView/android.widget.TabHost/android.widget.LinearLayout/android.widget.FrameLayout/androidx.viewpager.widget.ViewPager/android.widget.RelativeLayout/android.widget.GridView/android.widget.LinearLayout/android.widget.LinearLayout[2]`; export type UiAutomatorQuery = - | 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("Appearance"))' - | 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("Conversations"))' - | 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("path-menu-item"))' - | 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text("Select app icon"))' - | 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textStartsWith("Version"))' - | 'new UiSelector().resourceId("cta-button-negative").childSelector(new UiSelector().className("android.widget.TextView"))' - | 'new UiSelector().resourceId("cta-button-positive").childSelector(new UiSelector().className("android.widget.TextView"))' | 'new UiSelector().resourceId("network.loki.messenger:id/messageStatusTextView").text("Sent")' | 'new UiSelector().text("Enter your display name")' + | `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId(${string}))` + | `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text(${string}))` + | `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textStartsWith(${string}))` | `new UiSelector().resourceId("Conversation header name").childSelector(new UiSelector().resourceId("pro-badge-text"))` + | `new UiSelector().resourceId(${string}).childSelector(new UiSelector().className("android.widget.TextView"))` | `new UiSelector().text(${string})`; export type AccessibilityId = @@ -185,6 +185,7 @@ export type AccessibilityId = | 'Allow' | 'Allow Access to All Photos' | 'Allow Full Access' + | 'Allow Paste' | 'Allow voice and video calls' | 'All Photos' | 'Answer call' @@ -196,6 +197,8 @@ export type AccessibilityId = | 'back' | 'Back' | 'BackButton' + | 'Ban and Delete All' + | 'Ban User' | 'Blinded ID' | 'Block' | 'Block contacts - Navigation' @@ -211,6 +214,7 @@ export type AccessibilityId = | 'Clear all' | 'Close' | 'Close button' + | 'Collections' | 'Community invitation' | 'Community Message Requests' | 'Configuration message' @@ -234,6 +238,10 @@ export type AccessibilityId = | 'Copy URL' | 'Create account button' | 'Create group' + | 'cta-body' + | 'cta-button-negative' + | 'cta-button-positive' + | 'cta-heading' | 'Decline message request' | 'Delete' | 'Delete Contact' @@ -288,6 +296,8 @@ export type AccessibilityId = | 'Hide Note to Self' | 'Hide recovery password button' | 'Hide Recovery Password Permanently' + | 'https://getsession.org/privacy-policy' + | 'https://getsession.org/terms-of-service' | 'Image picker' | 'Images folder' | 'Invite' @@ -352,13 +362,12 @@ export type AccessibilityId = | 'open-survey-button' | 'Open' | 'Open URL' + | 'Paste' | 'Path' | 'Photo library' | 'Photos' | 'Pin' | 'Please enter a shorter group name' - | 'Privacy Policy' - | 'qa-blocked-contacts-settings-item' | 'rate-app-button' | 'Read Receipts - Switch' | 'Recents' @@ -404,15 +413,17 @@ export type AccessibilityId = | 'space' | 'Staking reward pool amount' | 'TabBarItemTitle' - | 'Terms of Service' | 'test_file, pdf' | 'Time selector' + | 'Unban User' | 'Unblock' + | 'Unpin' | 'Untrusted attachment message' | 'Upload' | 'URL' | 'Username' | 'Username input' + | ' users can upload GIFs' // Yes this is an intentional whitespace | 'User settings' | 'Version warning banner' | 'Videos' @@ -424,6 +435,7 @@ export type AccessibilityId = | 'Your message request has been accepted.' | `${DISAPPEARING_TIMES} - Radio` | `${GROUPNAME}` + | `cta-feature-${number}` | `Disappear after ${DisappearActions} option`; export type Id = @@ -433,8 +445,8 @@ export type Id = | 'android:id/aerr_close' | 'android:id/aerr_wait' | 'android:id/alertTitle' + | 'android:id/button1' | 'android:id/content_preview_text' - | 'android:id/summary' | 'android:id/title' | 'android.widget.TextView' | 'Appearance' @@ -456,6 +468,7 @@ export type Id = | 'com.android.settings:id/switch_text' | 'com.google.android.apps.photos:id/sign_in_button' | 'Community input' + | 'Confirm' | 'Confirm invite button' | 'Contact' | 'Contact status' @@ -503,6 +516,7 @@ export type Id = | 'Hide recovery password button' | 'Image button' | 'Image picker' + | 'invite-accountid-menu-option' | 'invite-contacts-menu-option' | 'Invite button' | 'Invite friend button' @@ -511,12 +525,16 @@ export type Id = | 'Last updated timestamp' | 'Learn about staking link' | 'Learn more link' + | 'leave-group-cancel-button' | 'leave-group-confirm-button' | 'leave-group-menu-option' | 'Leave' | 'Loading animation' + | 'manage-admins-menu-option' | 'manage-members-menu-option' | 'Market cap amount' + | 'mediapicker-folder-item-thumbnail-0' + | 'mediapicker-image-item-thumbnail-0' | 'MeetingSE option' | 'Modal description' | 'Modal heading' @@ -529,13 +547,12 @@ export type Id = | 'network.loki.messenger:id/callSubtitle' | 'network.loki.messenger:id/callTitle' | 'network.loki.messenger:id/characterLimitText' + | 'network.loki.messenger:id/context_menu_item_title' | 'network.loki.messenger:id/crop_image_menu_crop' | 'network.loki.messenger:id/emptyStateContainer' | 'network.loki.messenger:id/endCallButton' | 'network.loki.messenger:id/layout_emoji_container' | 'network.loki.messenger:id/linkPreviewView' - | 'network.loki.messenger:id/mediapicker_folder_item_thumbnail' - | 'network.loki.messenger:id/mediapicker_image_item_thumbnail' | 'network.loki.messenger:id/messageStatusTextView' | 'network.loki.messenger:id/openGroupTitleTextView' | 'network.loki.messenger:id/play_overlay' @@ -551,6 +568,7 @@ export type Id = | 'network.loki.messenger:id/theme_option_classic_light' | 'network.loki.messenger:id/thumbnail_load_indicator' | 'network.loki.messenger:id/title' + | 'network.loki.messenger:id/unpinTextView' | 'New direct message' | 'Next' | 'nickname-input' @@ -560,27 +578,40 @@ export type Id = | 'open-survey-button' | 'Open' | 'Open URL' + | 'preferences-dialog-option-enable' + | 'preferences-option-blocked-contacts' + | 'preferences-option-read-receipt' + | 'preferences-option-whitelist-toggle' | 'preferred-display-name' | 'Privacy' | 'Privacy policy button' | 'pro-badge-text' + | 'promote-members-menu-option' + | 'Promote' | 'qa-collapsing-footer-action_invite' + | 'qa-collapsing-footer-action_promote' + | 'qa-collapsing-footer-action_remove' | 'Quit' | 'rate-app-button' | 'Recovery password container' | 'Recovery password menu item' | 'Recovery phrase input' + | 'remove-member-messages-option' + | 'remove-member-option' | 'Remove' | 'Remove contact button' | 'Restore your session button' | 'Reveal recovery phrase button' | 'Save' | 'Select All' + | 'Send Invite' | 'SESH price' | 'session-network-menu-item' | 'Session id input box' | 'set-nickname-confirm-button' | 'Set button' + | 'share-message-history-option' + | 'share-new-messages-option' | 'Share button' | 'show-nts-confirm-button' | 'Show' @@ -593,6 +624,8 @@ export type Id = | 'update-username-confirm-button' | 'User settings' | 'Version warning banner' + | 'whitelist-cancel-button' + | 'whitelist-confirm-button' | 'Yes' | `All ${AppName} notifications` | `cta-feature-${number}` @@ -607,6 +640,7 @@ export type ScreenshotFileNames = | 'conversation_alice' | 'conversation_bob' | 'cta_donate' + | 'cta_pro_activated' | 'landingpage_new_account' | 'landingpage_restore_account' | 'settings_appearance' diff --git a/run/types/tuple.d.ts b/run/types/tuple.d.ts deleted file mode 100644 index 8455b6874..000000000 --- a/run/types/tuple.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type TupleOf> = R['length'] extends N - ? R - : TupleOf; - -export type Tuple = N extends N - ? number extends N - ? Array - : TupleOf - : never; diff --git a/scripts/ci.sh b/scripts/ci.sh index 647c403e1..369231110 100644 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -57,18 +57,26 @@ function create_emulators() { # Set the RAM size to 4GB (4192MB) sed -i 's/^hw\.ramSize=.*/hw.ramSize=4192/' "$CONFIG_FILE" + # Use virtualscene camera so injectEmulatorCameraImage works + sed -i 's/^hw\.camera\.back=.*/hw.camera.back=virtualscene/' "$CONFIG_FILE" done cd } +# Start a single emulator to snapshot or start all 4 at once function start_for_snapshots() { - for i in {1..4} - do - DISPLAY=:0 emulator @emulator$i -gpu host -accel on -no-snapshot-load & - sleep 20 - done + local i=${1:-} + if [[ -n "$i" ]]; then + DISPLAY=:0 emulator @emulator$i -gpu host -accel on -no-snapshot-load & + else + for i in {1..4} + do + DISPLAY=:0 emulator @emulator$i -gpu host -accel on -no-snapshot-load & + sleep 20 + done + fi } # let the emulators start and be ready (check cpu usage) before calling this. diff --git a/scripts/create_ios_simulators.ts b/scripts/create_ios_simulators.ts index 79a008534..051616aac 100644 --- a/scripts/create_ios_simulators.ts +++ b/scripts/create_ios_simulators.ts @@ -44,7 +44,7 @@ const DEVICE_CONFIG = { const MEDIA_ROOT = path.join('run', 'test', 'media'); const MEDIA_FILES = { - images: ['profile_picture.jpg', 'test_image.jpg'], + images: ['profile_picture.jpg', 'test_image.jpg', 'animated_profile_picture.gif'], videos: ['test_video.mp4'], pdfs: ['test_file.pdf'], }; diff --git a/scripts/emulator_health.ts b/scripts/emulator_health.ts new file mode 100644 index 000000000..3a3fafb11 --- /dev/null +++ b/scripts/emulator_health.ts @@ -0,0 +1,138 @@ +import { sleepFor } from '../run/test/utils'; +import { getAdbFullPath, getEmulatorFullPath } from '../run/test/utils/binaries'; +import { runScriptAndLog } from '../run/test/utils/utilities'; + +const EMULATOR_CONFIG = { + 1: 5554, + 2: 5556, + 3: 5558, + 4: 5560, +} as const; + +async function getRunningEmulators(): Promise { + const output = await runScriptAndLog(`${getAdbFullPath()} devices`); + return output + .split('\n') + .map(line => { + // Match only lines with emulator-PORT followed by 'device' state + const match = line.match(/emulator-(\d+)\s+device$/); + return match ? parseInt(match[1]) : null; + }) + .filter((port): port is number => port !== null); +} + +function portToEmulatorNum(port: number): number | undefined { + const entry = Object.entries(EMULATOR_CONFIG).find(([_, p]) => p === port); + return entry ? parseInt(entry[0]) : undefined; +} + +async function getMissingEmulators(): Promise { + const running = await getRunningEmulators(); + const allNums = Object.keys(EMULATOR_CONFIG).map(Number); + const runningNums = running.map(portToEmulatorNum).filter((n): n is number => n !== undefined); + return allNums.filter(n => !runningNums.includes(n)); +} + +async function waitForEmulatorBoot( + emulatorNum: number, + timeoutMs: number = 300_000 +): Promise { + const port = EMULATOR_CONFIG[emulatorNum as keyof typeof EMULATOR_CONFIG]; + const udid = `emulator-${port}`; + const startTime = Date.now(); + const maxAttempts = Math.floor(timeoutMs / 5_000); + + console.log(`Waiting for emulator ${emulatorNum} to boot...`); + + for (let i = 0; i < maxAttempts; i++) { + try { + const result = await runScriptAndLog( + `${getAdbFullPath()} -s ${udid} shell getprop sys.boot_completed 2>/dev/null`, + false + ); + + if (result.trim() === '1') { + const elapsed = Math.round((Date.now() - startTime) / 1000); + console.log(`Emulator ${emulatorNum} booted (${elapsed}s)`); + return true; + } + } catch { + // Emulator not ready yet + } + + await sleepFor(5_000); + } + + console.log(`Emulator ${emulatorNum} failed to boot within ${timeoutMs / 1000}s`); + return false; +} + +export async function recoverEmulator(emulatorNum: number): Promise { + const port = EMULATOR_CONFIG[emulatorNum as keyof typeof EMULATOR_CONFIG]; + const udid = `emulator-${port}`; + const avdName = `emulator${emulatorNum}`; + + console.warn(`[Recovery] ${udid} not running — attempting to recover ${avdName}...`); + + // Kill any zombie process + try { + await runScriptAndLog(`${getAdbFullPath()} -s ${udid} emu kill`, false); + await sleepFor(2_000); + } catch { + // Already dead, that's fine + } + + // Restart from snapshot (mirrors ci.sh start_with_snapshots) + const configFile = `$HOME/.android/avd/${avdName}.avd/emulator-user.ini`; + const windowX = 100 + (emulatorNum - 1) * 400; + await runScriptAndLog(`sed -i "s/^window.x.*/window.x=${windowX}/" ${configFile}`, false); + + await runScriptAndLog( + `DISPLAY=:0 nohup ${getEmulatorFullPath()} @${avdName} -gpu host -accel on -no-snapshot-save -snapshot plop.snapshot -force-snapshot-load > /dev/null 2>&1 &`, + false + ); + + const booted = await waitForEmulatorBoot(emulatorNum); + if (!booted) { + throw new Error(`[Recovery] ${udid} failed to boot`); + } +} + +async function restartMissingEmulators(): Promise { + const missing = await getMissingEmulators(); + + if (missing.length === 0) { + console.log('All emulators running'); + return; + } + + console.log(`Missing emulators: ${missing.join(', ')} — recovering...`); + + const results = await Promise.allSettled(missing.map(num => recoverEmulator(num))); + if (results.some(r => r.status === 'rejected')) { + throw new Error('Emulator recovery failed'); + } +} + +const SCRIPT_TIMEOUT_MS = 5 * 60_000; // 5 minutes + +async function main(): Promise { + const timeout = setTimeout(() => { + console.error(`Script timed out after ${SCRIPT_TIMEOUT_MS / 1000}s`); + process.exit(1); + }, SCRIPT_TIMEOUT_MS); + + try { + await restartMissingEmulators(); + process.exit(0); + } catch (error) { + console.error('Recovery failed:', error); + process.exit(1); + } finally { + clearTimeout(timeout); + } +} + +if (require.main === module) { + void main(); +} diff --git a/scripts/resources/Toren1BD.posters b/scripts/resources/Toren1BD.posters new file mode 100644 index 000000000..ff975f61d --- /dev/null +++ b/scripts/resources/Toren1BD.posters @@ -0,0 +1,11 @@ +poster wall +size 2 2 +position -0.807 0.320 5.316 +rotation 0 -150 0 +default poster.png + +poster table +size 2 2 +position 0 0 -1.5 +rotation 0 0 0 +default placeholder.png \ No newline at end of file diff --git a/scripts/resources/placeholder.png b/scripts/resources/placeholder.png new file mode 100644 index 000000000..03c66ba47 --- /dev/null +++ b/scripts/resources/placeholder.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:885ca9b7463b30b0b13d5bf0f874084282d674ca5e07d3e0a3f897f8c509b847 +size 53060 diff --git a/scripts/setup_virtual_scene.ts b/scripts/setup_virtual_scene.ts new file mode 100644 index 000000000..8b6eb9042 --- /dev/null +++ b/scripts/setup_virtual_scene.ts @@ -0,0 +1,52 @@ +/** + * Copies virtual scene config files from the repo to local Android SDK folder if necessary. + * + * NOTE: This only works if the emulators' back camera is set to `virtualscene`: + * The config.ini must have a `hw.camera.back=virtualscene` entry. + * + * The Toren1BD.posters file keeps track of where to show posters (images) in the virtual camera scene. + * It has been modified so that the `table` poster shows right in front of where the camera opens, + * scaled up 2x, positioned at x: 0, y: 0, z: -1.5. + * This is necessary because appium's injection method manipulates this specific poster's image content. + * This has been the only reliable way to get this working other than patching appium and the android driver. + * + * The file is global for all emulators on the host machine but each appium session can temporarily modify the image. + * + * CI: This script runs before emulator boot. + * Local dev: Run `pnpm setup-virtual-scene` once and reboot emulators for the changes to take effect. + */ + +import { copyFileSync, readFileSync } from 'fs'; +import path from 'path'; + +const sdkRoot = process.env.ANDROID_SDK_ROOT; +if (!sdkRoot) { + throw new Error('ANDROID_SDK_ROOT is not set'); +} + +const resourcesDir = path.join(sdkRoot, 'emulator', 'resources'); + +const files = ['placeholder.png', 'Toren1BD.posters']; + +function syncFile(filename: string) { + const repoFile = path.join(__dirname, 'resources', filename); + const sdkFile = path.join(resourcesDir, filename); + + const repoContent = readFileSync(repoFile); + + let needsCopy = true; + try { + needsCopy = !repoContent.equals(readFileSync(sdkFile)); + } catch { + // File doesn't exist in SDK yet + } + + if (!needsCopy) { + console.log(`${filename} already up to date`); + } else { + copyFileSync(repoFile, sdkFile); + console.log(`${filename} updated at ${sdkFile}`); + } +} + +files.forEach(syncFile); diff --git a/tsconfig.json b/tsconfig.json index ccdc2c1b8..a842d7e6f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "strictNullChecks": true, "esModuleInterop": true, "types": ["@wdio/types", "node"], - "target": "es2021", + "target": "es2022", "outDir": "./dist", "rootDir": "./", "isolatedModules": false