diff --git a/.github/workflows/allure-rollback.yml b/.github/workflows/allure-rollback.yml new file mode 100644 index 000000000..5fb2b4c98 --- /dev/null +++ b/.github/workflows/allure-rollback.yml @@ -0,0 +1,42 @@ +name: Roll-back Allure Report + +# This workflow removes (unpublishes) the last Allure report from gh-pages. +# +# When to use: +# - Did not intend to create a new report (e.g. accidentally published a debug run) +# - Incorrect inputs (wrong build, metadta, Appium branch, etc.) +# - Test is corrupted by any other reason, don't want to muddy the result history + +on: + workflow_dispatch: + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Checkout gh-pages branch + uses: actions/checkout@v4 + with: + ref: gh-pages + lfs: true + fetch-depth: 0 # Need full history for other workflows that rely on file ages + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Reset, force push and prune + run: | + echo "Resetting gh-pages by 1 commit..." + git reset HEAD~1 + git push --force-with-lease + git lfs prune --verify-remote || echo "LFS prune failed (non-fatal)" + + - name: Summary + run: | + echo "Successfully removed last commit from gh-pages" >> $GITHUB_STEP_SUMMARY + echo "Pruned orphaned LFS objects" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Triggered by: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY + echo "Current HEAD: $(git rev-parse HEAD)" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/android-regression.yml b/.github/workflows/android-regression.yml index ac5d80671..171f7bce9 100644 --- a/.github/workflows/android-regression.yml +++ b/.github/workflows/android-regression.yml @@ -95,18 +95,58 @@ jobs: - name: Download APK & extract it 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 + + if [ "$DEVNET_ACCESSIBLE" = "false" ]; then + echo "Devnet is not accessible after 3 attempts" + fi + + # 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 - if ls extracted/*automaticQa.apk; then - mv extracted/*automaticQa.apk extracted/session-android.apk - echo "IS_AUTOMATIC_QA=true" >> $GITHUB_ENV - elif ls extracted/*qa.apk; then + # 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)" + 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 + 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 else - echo "Error: No .qa APK found (only .qa builds are supported)" + echo "No suitable APK found" exit 1 fi diff --git a/package.json b/package.json index 45eb54c94..19b408ae6 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "prettier": "^3.3.3", "sharp": "^0.34.2", "sinon": "^19.0.2", + "ssim.js": "^3.5.0", "sync-request-curl": "^3.3.3", "ts-node": "^10.9.1", "typescript": "^5.6.3", diff --git a/run/screenshots/android/app_disguise.png b/run/screenshots/android/app_disguise.png index 6349657d9..605c2d421 100644 --- a/run/screenshots/android/app_disguise.png +++ b/run/screenshots/android/app_disguise.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c979e249014dc0c7083020ef91d922e44a63aace080176c8551f40e6ccf8b223 -size 125751 +oid sha256:0174d2fcb331bf2bc8876fdff2f024116ef721d58df6a75c8cb78cceec0df574 +size 147441 diff --git a/run/screenshots/android/conversation_alice.png b/run/screenshots/android/conversation_alice.png new file mode 100644 index 000000000..0ee18c700 --- /dev/null +++ b/run/screenshots/android/conversation_alice.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:075707255598a95252744a10f63b009aab26f51261fd1886b30bfe8ec37e8873 +size 88351 diff --git a/run/screenshots/android/conversation_bob.png b/run/screenshots/android/conversation_bob.png new file mode 100644 index 000000000..cb618ed78 --- /dev/null +++ b/run/screenshots/android/conversation_bob.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f4197c0c9eea91fcbc5bdf200a18b6b3701a2d5e78ed6b8f22aebaa696fb43b +size 93516 diff --git a/run/screenshots/android/landingpage_new_account.png b/run/screenshots/android/landingpage_new_account.png index 03808de9a..5a5de8b36 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:67df5e6fbc1b9c3be58275db41b3c6bc64911c0755e38196082062a585df457b -size 81721 +oid sha256:aec8609e7d0013725d3be235b552a9399e94e2143f3edbeda4c7172c1a29f0ff +size 103496 diff --git a/run/screenshots/android/landingpage_restore_account.png b/run/screenshots/android/landingpage_restore_account.png index 19e9d6978..f979b2f26 100644 --- a/run/screenshots/android/landingpage_restore_account.png +++ b/run/screenshots/android/landingpage_restore_account.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:774a934a8ce00a720d29d5b2306aa1f257c290b064c65b0afd8a60cfbd10d5a4 -size 57649 +oid sha256:9cfdee6c04fc5c6e395e77fc92b03ef674d31254fe75fff72a326c9a1ab3ec30 +size 79441 diff --git a/run/screenshots/android/settings.png b/run/screenshots/android/settings.png new file mode 100644 index 000000000..e4bbcc15f --- /dev/null +++ b/run/screenshots/android/settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e290aecdb8ba4e13606c65c3cf549b381c20abcdb3dbbfb882c7bceb328b41d0 +size 134845 diff --git a/run/screenshots/android/settings_appearance.png b/run/screenshots/android/settings_appearance.png new file mode 100644 index 000000000..7803ea423 --- /dev/null +++ b/run/screenshots/android/settings_appearance.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d513f037ba20f8b3026f159678e3bf5eeb049e09b539f836993b8cafa3ec8cda +size 128602 diff --git a/run/screenshots/android/settings_conversations.png b/run/screenshots/android/settings_conversations.png new file mode 100644 index 000000000..983c94eab --- /dev/null +++ b/run/screenshots/android/settings_conversations.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:183f7aac856c61eb5f46638fc25dbb1755215712752b50f721698f3301a232d8 +size 156289 diff --git a/run/screenshots/android/settings_notifications.png b/run/screenshots/android/settings_notifications.png new file mode 100644 index 000000000..0cdc813c4 --- /dev/null +++ b/run/screenshots/android/settings_notifications.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:846ea6ce60f9014e0fa36823e176da62f6f27fc63026ea1291f66b69cebf0730 +size 142963 diff --git a/run/screenshots/android/settings_privacy.png b/run/screenshots/android/settings_privacy.png new file mode 100644 index 000000000..7afe312b0 --- /dev/null +++ b/run/screenshots/android/settings_privacy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2db83338c651b6e56b70f23b7faeae92dc98bb652e24b52c0ac961ec6d7b3e47 +size 200618 diff --git a/run/screenshots/android/upm_home.png b/run/screenshots/android/upm_home.png new file mode 100644 index 000000000..f5b2a0fa7 --- /dev/null +++ b/run/screenshots/android/upm_home.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4059b3b2f68ba36ebd6c86d33a720ece3f6bdf4ba069bf6ed6e28d1a5e033a9 +size 117288 diff --git a/run/screenshots/ios/app_disguise.png b/run/screenshots/ios/app_disguise.png index 7950444a3..c7609276a 100644 --- a/run/screenshots/ios/app_disguise.png +++ b/run/screenshots/ios/app_disguise.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f215acd1deb44135705057cc83a0f3997ce999c456783fa3c1209a33b1ca3c48 -size 477016 +oid sha256:47b1c7cb55a03ffa0818b71661eeb2cac6c98f70766d5735944b9d8203e4e037 +size 501359 diff --git a/run/screenshots/ios/conversation_alice.png b/run/screenshots/ios/conversation_alice.png new file mode 100644 index 000000000..86e13bfe5 --- /dev/null +++ b/run/screenshots/ios/conversation_alice.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be209f73e146af31ae72d5555881144d27a66b3e638e1417056c29ebcb019896 +size 316129 diff --git a/run/screenshots/ios/conversation_bob.png b/run/screenshots/ios/conversation_bob.png new file mode 100644 index 000000000..b660c3370 --- /dev/null +++ b/run/screenshots/ios/conversation_bob.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7164fb001a04afa53c685cec45f0115cb0562becf0af23da3d7f3d7aa4d915be +size 319475 diff --git a/run/screenshots/ios/landingpage_new_account.png b/run/screenshots/ios/landingpage_new_account.png index 28afdcb85..06d6e2cf7 100644 --- a/run/screenshots/ios/landingpage_new_account.png +++ b/run/screenshots/ios/landingpage_new_account.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c920f37c84d06f6d90fdb2b5cf5867863af7321bf3df8c33b010536d91cbe2da -size 159637 +oid sha256:8b86a2977d43b32cdc619aecd01d9deb4cdc069a9540aed82ffb8fd3b1c741cd +size 197547 diff --git a/run/screenshots/ios/landingpage_restore_account.png b/run/screenshots/ios/landingpage_restore_account.png index c5252cbf5..5ece9b270 100644 --- a/run/screenshots/ios/landingpage_restore_account.png +++ b/run/screenshots/ios/landingpage_restore_account.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f64723ba961cc8574cff8f6dd20ec40c12b3b2e3049511074529d39016f3b69 -size 143198 +oid sha256:f3c636dc9de34f2490c2242c260e7c6d9ee37d905a4786de1522db8b1f76a81a +size 175782 diff --git a/run/screenshots/ios/settings.png b/run/screenshots/ios/settings.png new file mode 100644 index 000000000..61fe0874b --- /dev/null +++ b/run/screenshots/ios/settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c34aed56b3a5be48831fab0309da694272237905f5d6ae7614a1bc7494fdd295 +size 223059 diff --git a/run/screenshots/ios/settings_appearance.png b/run/screenshots/ios/settings_appearance.png new file mode 100644 index 000000000..b611304d7 --- /dev/null +++ b/run/screenshots/ios/settings_appearance.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c216cc7d671847e2d9a340027d2456a13f7836447fa7eaee0ccb12c9213b454 +size 206412 diff --git a/run/screenshots/ios/settings_conversations.png b/run/screenshots/ios/settings_conversations.png new file mode 100644 index 000000000..c7f853cf7 --- /dev/null +++ b/run/screenshots/ios/settings_conversations.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef3370a4a1296aa734982c76ac6e989b9fb8acdada5ca585c47e9cf1f94cad0d +size 162443 diff --git a/run/screenshots/ios/settings_notifications.png b/run/screenshots/ios/settings_notifications.png new file mode 100644 index 000000000..dcbd1172f --- /dev/null +++ b/run/screenshots/ios/settings_notifications.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c286f4276d71c13261f0991dbbcfd3a6ddbf74714a907c8b1a1f8bfa52bb3ba +size 170000 diff --git a/run/screenshots/ios/settings_privacy.png b/run/screenshots/ios/settings_privacy.png new file mode 100644 index 000000000..910221e13 --- /dev/null +++ b/run/screenshots/ios/settings_privacy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49e67fe26e57958963d97190d339e181983ee974ccf1d0a108d283d529bb5e3e +size 257612 diff --git a/run/test/specs/app_disguise_icons.spec.ts b/run/test/specs/app_disguise_icons.spec.ts index 312c9a5d2..d64fc2996 100644 --- a/run/test/specs/app_disguise_icons.spec.ts +++ b/run/test/specs/app_disguise_icons.spec.ts @@ -6,11 +6,10 @@ import { USERNAME } from '../../types/testing'; import { AppearanceMenuItem, SelectAppIcon, UserSettings } from './locators/settings'; import { newUser } from './utils/create_account'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; -import { AppDisguisePageScreenshot } from './utils/screenshot_paths'; -import { verifyElementScreenshot } from './utils/verify_screenshots'; +import { verifyPageScreenshot } from './utils/verify_screenshots'; bothPlatformsIt({ - title: 'App disguise icons', + title: 'Check app disguise icon layout', risk: 'medium', countOfDevicesNeeded: 1, testCb: appDisguiseIcons, @@ -31,9 +30,9 @@ async function appDisguiseIcons(platform: SupportedPlatformsType, testInfo: Test await device.clickOnElementAll(new UserSettings(device)); await device.clickOnElementAll(new AppearanceMenuItem(device)); }); - await test.step(TestSteps.VERIFY.ELEMENT_SCREENSHOT('app disguise icons'), async () => { + await test.step(TestSteps.VERIFY.SCREENSHOT('app disguise icons'), async () => { await device.clickOnElementAll(new SelectAppIcon(device)); - await verifyElementScreenshot(device, new AppDisguisePageScreenshot(device), testInfo); + await verifyPageScreenshot(device, platform, 'app_disguise', testInfo, 0.99); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); diff --git a/run/test/specs/check_avatar_color.spec.ts b/run/test/specs/check_avatar_color.spec.ts index 80dbbb1ae..786e892cd 100644 --- a/run/test/specs/check_avatar_color.spec.ts +++ b/run/test/specs/check_avatar_color.spec.ts @@ -10,12 +10,13 @@ import { isSameColor } from './utils/check_colour'; import { closeApp, SupportedPlatformsType } from './utils/open_app'; bothPlatformsIt({ - title: 'Avatar color', + title: 'Check placeholder avatar color', risk: 'medium', countOfDevicesNeeded: 2, testCb: avatarColor, allureSuites: { parent: 'Visual Checks', + suite: 'Settings', }, allureDescription: `Verifies that a user's placeholder avatar color appears the same to a contact`, }); diff --git a/run/test/specs/community_emoji_react.spec.ts b/run/test/specs/community_emoji_react.spec.ts index 5d3dbca26..e3619ecd9 100644 --- a/run/test/specs/community_emoji_react.spec.ts +++ b/run/test/specs/community_emoji_react.spec.ts @@ -18,6 +18,9 @@ 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) { diff --git a/run/test/specs/community_requests_off.spec.ts b/run/test/specs/community_requests_off.spec.ts new file mode 100644 index 000000000..89057bdf3 --- /dev/null +++ b/run/test/specs/community_requests_off.spec.ts @@ -0,0 +1,56 @@ +import { test, type TestInfo } from '@playwright/test'; +import { USERNAME } from '@session-foundation/qa-seeder'; + +import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { CommunityMessageAuthor, UPMMessageButton } from './locators/conversation'; +import { newUser } from './utils/create_account'; +import { joinCommunity } from './utils/join_community'; +import { closeApp, openAppTwoDevices, SupportedPlatformsType } from './utils/open_app'; + +bothPlatformsIt({ + title: 'Community message requests off', + risk: 'medium', + testCb: blindedMessageRequests, + countOfDevicesNeeded: 2, + allureSuites: { parent: 'Settings', suite: 'Community Message Requests' }, + allureDescription: + 'Verifies that a message request cannot be sent when Community Message Requests are off.', +}); + +async function blindedMessageRequests(platform: SupportedPlatformsType, testInfo: TestInfo) { + const message = `I do not accept blinded message requests + ${platform} + ${Date.now()}`; + const { device1, device2 } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device1, device2 } = await openAppTwoDevices(platform, testInfo); + await Promise.all([ + newUser(device1, USERNAME.ALICE, { saveUserData: false }), + newUser(device2, USERNAME.BOB, { saveUserData: false }), + ]); + return { device1, device2 }; + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + await Promise.all( + [device1, device2].map(async device => { + await joinCommunity(device, testCommunityLink, testCommunityName); + }) + ); + }); + await test.step(TestSteps.SEND.MESSAGE(USERNAME.BOB, testCommunityName), 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 () => { + const messageButton = await device1.waitForTextElementToBePresent( + new UPMMessageButton(device1) + ); + const attr = await device1.getAttribute('enabled', messageButton.ELEMENT); + if (attr !== 'false') { + device1.log(`Message button attribute is 'enabled = ${attr}'`); + throw new Error(`Message button should be disabled but it is not`); + } + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device1, device2); + }); +} diff --git a/run/test/specs/community_requests_on.spec.ts b/run/test/specs/community_requests_on.spec.ts new file mode 100644 index 000000000..de0c98ece --- /dev/null +++ b/run/test/specs/community_requests_on.spec.ts @@ -0,0 +1,91 @@ +import { test, type TestInfo } from '@playwright/test'; +import { USERNAME } from '@session-foundation/qa-seeder'; + +import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { CloseSettings } from './locators'; +import { + CommunityMessageAuthor, + ConversationHeaderName, + MessageBody, + MessageRequestAcceptDescription, + MessageRequestPendingDescription, + UPMMessageButton, +} from './locators/conversation'; +import { MessageRequestsBanner } from './locators/home'; +import { CommunityMessageRequestSwitch, PrivacyMenuItem, UserSettings } from './locators/settings'; +import { newUser } from './utils/create_account'; +import { joinCommunity } from './utils/join_community'; +import { closeApp, openAppTwoDevices, SupportedPlatformsType } from './utils/open_app'; + +bothPlatformsIt({ + title: 'Community message requests on', + risk: 'medium', + testCb: blindedMessageRequests, + countOfDevicesNeeded: 2, + allureSuites: { parent: 'Settings', suite: 'Community Message Requests' }, + allureDescription: + 'Verifies that a message request can be sent when Community Message Requests are on.', + allureLinks: { + ios: 'SES-4722', + }, +}); + +async function blindedMessageRequests(platform: SupportedPlatformsType, testInfo: TestInfo) { + const message = `I accept blinded message requests + ${platform} + ${Date.now()}`; + const messageRequestMessage = 'Howdy'; + const messageRequestReply = 'Howdy back'; + const { device1, device2, alice, bob } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device1, device2 } = await openAppTwoDevices(platform, testInfo); + const [alice, bob] = await Promise.all([ + newUser(device1, USERNAME.ALICE, { saveUserData: false }), + newUser(device2, USERNAME.BOB, { saveUserData: false }), + ]); + return { device1, device2, alice, bob }; + }); + await test.step('Bob enables Community Message Requests', async () => { + await device2.clickOnElementAll(new UserSettings(device2)); + await device2.clickOnElementAll(new PrivacyMenuItem(device2)); + await device2.clickOnElementAll(new CommunityMessageRequestSwitch(device2)); + await device2.navigateBack(); + await device2.clickOnElementAll(new CloseSettings(device2)); + }); + await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { + await Promise.all( + [device1, device2].map(async device => { + await joinCommunity(device, testCommunityLink, testCommunityName); + }) + ); + }); + + await test.step(TestSteps.SEND.MESSAGE(bob.userName, testCommunityName), async () => { + 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 device1.clickOnElementAll(new UPMMessageButton(device1)); + await device1.clickOnElementAll(new ConversationHeaderName(device1, bob.userName)); + await device1.waitForTextElementToBePresent(new MessageRequestPendingDescription(device1)); + await device1.sendMessage(messageRequestMessage); + }); + 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.waitForTextElementToBePresent( + new ConversationHeaderName(device2, alice.userName) + ); + await device2.waitForTextElementToBePresent(new MessageBody(device2, messageRequestMessage)); + await device2.waitForTextElementToBePresent(new MessageRequestAcceptDescription(device2)); + }); + // Send message from Bob to Alice + await test.step(TestSteps.SEND.MESSAGE(bob.userName, alice.userName), async () => { + await device2.sendMessage(messageRequestReply); + await device1.waitForTextElementToBePresent(new MessageBody(device1, messageRequestReply)); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device1, device2); + }); +} diff --git a/run/test/specs/community_tests_image.spec.ts b/run/test/specs/community_tests_image.spec.ts index 6aca09d0a..d6e83c3a4 100644 --- a/run/test/specs/community_tests_image.spec.ts +++ b/run/test/specs/community_tests_image.spec.ts @@ -14,7 +14,7 @@ bothPlatformsIt({ risk: 'medium', countOfDevicesNeeded: 2, testCb: sendImageCommunity, - allureSuites: { parent: 'Sending Messages', suite: 'Attachments' }, + allureSuites: { parent: 'Sending Messages', suite: 'Message types' }, allureDescription: 'Verifies that an image can be sent and received in a community', }); diff --git a/run/test/specs/disappearing_call.spec.ts b/run/test/specs/disappearing_call.spec.ts index 1ce067e4b..93a901080 100644 --- a/run/test/specs/disappearing_call.spec.ts +++ b/run/test/specs/disappearing_call.spec.ts @@ -2,7 +2,7 @@ import type { TestInfo } from '@playwright/test'; import { bothPlatformsItSeparate } from '../../types/sessionIt'; import { DISAPPEARING_TIMES } from '../../types/testing'; -import { CallButton, NotificationSettings, NotificationSwitch } from './locators/conversation'; +import { CallButton, NotificationsModalButton, NotificationSwitch } from './locators/conversation'; import { ContinueButton } from './locators/global'; import { open_Alice1_Bob1_friends } from './state_builder'; import { sleepFor } from './utils'; @@ -123,7 +123,7 @@ async function disappearingCallMessage1o1Android( await alice1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' ); - await alice1.clickOnElementAll(new NotificationSettings(alice1)); + await alice1.clickOnElementAll(new NotificationsModalButton(alice1)); await alice1.clickOnElementAll(new NotificationSwitch(alice1)); // Return to conversation await alice1.navigateBack(false); diff --git a/run/test/specs/group_message_delete.spec.ts b/run/test/specs/group_message_delete.spec.ts index 9d80bfd3a..a11dac5ec 100644 --- a/run/test/specs/group_message_delete.spec.ts +++ b/run/test/specs/group_message_delete.spec.ts @@ -8,10 +8,16 @@ import { open_Alice1_Bob1_Charlie1_friends_group } from './state_builder'; import { closeApp, SupportedPlatformsType } from './utils/open_app'; bothPlatformsIt({ - title: 'Delete message in group', + title: 'Delete message locally in group', risk: 'high', countOfDevicesNeeded: 3, testCb: deleteMessageGroup, + allureSuites: { + parent: 'User Actions', + suite: 'Delete Message', + }, + allureDescription: + 'Verifies that local deletion in a group does not delete a message for other participants.', }); async function deleteMessageGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_message_document.spec.ts b/run/test/specs/group_message_document.spec.ts index edd6378b2..1d5bbd552 100644 --- a/run/test/specs/group_message_document.spec.ts +++ b/run/test/specs/group_message_document.spec.ts @@ -16,6 +16,12 @@ bothPlatformsItSeparate({ android: { testCb: sendDocumentGroupAndroid, }, + allureSuites: { + parent: 'Sending Messages', + suite: 'Message types', + }, + allureDescription: + 'Verifies that a PDF can be sent to a group, all members receive the document, and replying to a document works as expected', }); async function sendDocumentGroupiOS(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_message_gif.spec.ts b/run/test/specs/group_message_gif.spec.ts index ae3a5f5b6..d1ec33d2d 100644 --- a/run/test/specs/group_message_gif.spec.ts +++ b/run/test/specs/group_message_gif.spec.ts @@ -16,6 +16,12 @@ bothPlatformsItSeparate({ android: { testCb: sendGifGroupAndroid, }, + allureSuites: { + parent: 'Sending Messages', + suite: 'Message types', + }, + allureDescription: + 'Verifies that a GIF can be sent to a group, all members receive the document, and replying to a document works as expected', }); async function sendGifGroupiOS(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_message_image.spec.ts b/run/test/specs/group_message_image.spec.ts index 61fa50522..63dc2c2f8 100644 --- a/run/test/specs/group_message_image.spec.ts +++ b/run/test/specs/group_message_image.spec.ts @@ -16,6 +16,12 @@ bothPlatformsItSeparate({ android: { testCb: sendImageGroupAndroid, }, + allureSuites: { + parent: 'Sending Messages', + suite: 'Message types', + }, + allureDescription: + 'Verifies that an image can be sent to a group, all members receive the document, and replying to a document works as expected', }); async function sendImageGroupiOS(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_message_link_preview.spec.ts b/run/test/specs/group_message_link_preview.spec.ts index f3b73a5e1..07b3f0a32 100644 --- a/run/test/specs/group_message_link_preview.spec.ts +++ b/run/test/specs/group_message_link_preview.spec.ts @@ -24,6 +24,12 @@ bothPlatformsItSeparate({ android: { testCb: sendLinkGroupAndroid, }, + allureSuites: { + parent: 'Sending Messages', + suite: 'Message types', + }, + allureDescription: + 'Verifies that a link with preview can be sent to a group, all members receive the document, and replying to a document works as expected', }); async function sendLinkGroupiOS(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_message_long_text.spec.ts b/run/test/specs/group_message_long_text.spec.ts index 9122cb964..3f4b103da 100644 --- a/run/test/specs/group_message_long_text.spec.ts +++ b/run/test/specs/group_message_long_text.spec.ts @@ -11,6 +11,10 @@ bothPlatformsIt({ risk: 'low', countOfDevicesNeeded: 3, testCb: sendLongMessageGroup, + allureSuites: { + parent: 'Sending Messages', + suite: 'Message types', + }, allureDescription: 'Verifies that a long message can be sent to a group', }); diff --git a/run/test/specs/group_message_unsend.spec.ts b/run/test/specs/group_message_unsend.spec.ts index 3ecd759fa..6d4176067 100644 --- a/run/test/specs/group_message_unsend.spec.ts +++ b/run/test/specs/group_message_unsend.spec.ts @@ -8,10 +8,16 @@ import { open_Alice1_Bob1_Charlie1_friends_group } from './state_builder'; import { closeApp, SupportedPlatformsType } from './utils/open_app'; bothPlatformsIt({ - title: 'Unsend message in group', + title: 'Delete message for all in group', risk: 'high', countOfDevicesNeeded: 3, testCb: unsendMessageGroup, + allureSuites: { + parent: 'User Actions', + suite: 'Delete Message', + }, + allureDescription: + 'Verifies that global deletion in a group deletes a message for every participant.', }); async function unsendMessageGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_message_video.spec.ts b/run/test/specs/group_message_video.spec.ts index c3d7af6e8..e97d37dbf 100644 --- a/run/test/specs/group_message_video.spec.ts +++ b/run/test/specs/group_message_video.spec.ts @@ -15,6 +15,12 @@ bothPlatformsItSeparate({ android: { testCb: sendVideoGroupAndroid, }, + allureSuites: { + parent: 'Sending Messages', + suite: 'Message types', + }, + allureDescription: + 'Verifies that a video can be sent to a group, all members receive the document, and replying to a document works as expected', }); async function sendVideoGroupiOS(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_message_voice.spec.ts b/run/test/specs/group_message_voice.spec.ts index 2efd9bf0a..32115f500 100644 --- a/run/test/specs/group_message_voice.spec.ts +++ b/run/test/specs/group_message_voice.spec.ts @@ -10,6 +10,12 @@ bothPlatformsIt({ risk: 'high', countOfDevicesNeeded: 3, testCb: sendVoiceMessageGroup, + allureSuites: { + parent: 'Sending Messages', + 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', }); async function sendVoiceMessageGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_tests_add_contact.spec.ts b/run/test/specs/group_tests_add_contact.spec.ts index 1981a6331..ce4cecae8 100644 --- a/run/test/specs/group_tests_add_contact.spec.ts +++ b/run/test/specs/group_tests_add_contact.spec.ts @@ -19,6 +19,11 @@ bothPlatformsIt({ risk: 'high', testCb: addContactToGroup, countOfDevicesNeeded: 4, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: 'Create four accounts, create a group with three, add the fourth member', }); async function addContactToGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { const testGroupName = 'Group to test adding contact'; diff --git a/run/test/specs/group_tests_change_group_description.spec.ts b/run/test/specs/group_tests_change_group_description.spec.ts index 6e76b38ed..2678ef778 100644 --- a/run/test/specs/group_tests_change_group_description.spec.ts +++ b/run/test/specs/group_tests_change_group_description.spec.ts @@ -32,6 +32,8 @@ bothPlatformsItSeparate({ parent: 'Groups', suite: 'Edit Group', }, + allureDescription: + 'Verifies that a group description can be at most 200 chars and that every member can see a valid change.', }); // Setup diff --git a/run/test/specs/group_tests_change_group_name.spec.ts b/run/test/specs/group_tests_change_group_name.spec.ts index d505178c0..4e50f94e2 100644 --- a/run/test/specs/group_tests_change_group_name.spec.ts +++ b/run/test/specs/group_tests_change_group_name.spec.ts @@ -22,6 +22,11 @@ bothPlatformsItSeparate({ android: { testCb: changeGroupNameAndroid, }, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: 'Verifies that a group name change syncs to every member.', }); async function changeGroupNameIos(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_tests_create_group.spec.ts b/run/test/specs/group_tests_create_group.spec.ts index b2158f3f0..440f797cb 100644 --- a/run/test/specs/group_tests_create_group.spec.ts +++ b/run/test/specs/group_tests_create_group.spec.ts @@ -11,6 +11,11 @@ bothPlatformsIt({ risk: 'high', testCb: groupCreation, countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Create Group', + }, + allureDescription: 'Verifies that a group of 3 can be created successfully via the UI', }); async function groupCreation(platform: SupportedPlatformsType, testInfo: TestInfo) { 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 9d192d5ea..a5f57c4a8 100644 --- a/run/test/specs/group_tests_create_group_banner.spec.ts +++ b/run/test/specs/group_tests_create_group_banner.spec.ts @@ -13,6 +13,12 @@ androidIt({ risk: 'high', testCb: createGroupBanner, countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Groups', + suite: 'Create Group', + }, + allureDescription: + 'Verifies that the latest release banner is present on the Create Group screen', }); async function createGroupBanner(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_tests_delete_group.spec.ts b/run/test/specs/group_tests_delete_group.spec.ts new file mode 100644 index 000000000..c260824a3 --- /dev/null +++ b/run/test/specs/group_tests_delete_group.spec.ts @@ -0,0 +1,87 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { englishStrippedStr } from '../../localizer/englishStrippedStr'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { ConversationSettings } 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'; +import { closeApp, SupportedPlatformsType } from './utils/open_app'; + +bothPlatformsIt({ + title: 'Delete group linked device', + risk: 'high', + testCb: deleteGroup, + countOfDevicesNeeded: 4, + allureSuites: { + parent: 'Groups', + suite: 'Leave/Delete Group', + }, + 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.`, +}); + +async function deleteGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { + const testGroupName = 'Delete group'; + const { + devices: { alice1, bob1, charlie1, alice2 }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice2_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + }); + await test.step('Admin deletes group', async () => { + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new DeleteGroupMenuItem(alice1)); + await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Delete Group'), async () => { + await alice1.checkModalStrings( + englishStrippedStr('groupDelete').toString(), + englishStrippedStr('groupDeleteDescription') + .withArgs({ group_name: testGroupName }) + .toString() + ); + }); + await alice1.clickOnElementAll(new DeleteGroupConfirm(alice1)); + }); + await test.step('Verify group is deleted for all members', async () => { + // Members + if (platform === 'ios') { + await Promise.all( + [bob1, charlie1].map(device => + device.waitForControlMessageToBePresent( + englishStrippedStr('groupDeletedMemberDescription') + .withArgs({ group_name: testGroupName }) + .toString() + ) + ) + ); + } else { + // Android uses the empty state for this "control message" + await Promise.all( + [bob1, charlie1].map(device => + device.waitForTextElementToBePresent({ + strategy: 'accessibility id', + selector: 'Empty list', + text: englishStrippedStr('groupDeletedMemberDescription') + .withArgs({ group_name: testGroupName }) + .toString(), + }) + ) + ); + } + // Admins + await Promise.all( + [alice1, alice2].map(async device => { + await device.waitForTextElementToBePresent(new PlusButton(device)); // Ensure we're on the home screen + await device.verifyElementNotPresent(new ConversationItem(device, testGroupName).build()); + }) + ); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1, charlie1, alice2); + }); +} 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 a06ce76d3..0e9d55c69 100644 --- a/run/test/specs/group_tests_edit_group_banner.spec.ts +++ b/run/test/specs/group_tests_edit_group_banner.spec.ts @@ -12,6 +12,11 @@ androidIt({ risk: 'medium', testCb: editGroupBanner, countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: 'Verifies that the latest release banner is present on the Edit Group screen', }); async function editGroupBanner(platform: SupportedPlatformsType, testInfo: TestInfo) { 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 ccabaebe7..a6bd351e4 100644 --- a/run/test/specs/group_tests_invite_contact_banner.spec.ts +++ b/run/test/specs/group_tests_invite_contact_banner.spec.ts @@ -13,6 +13,12 @@ androidIt({ risk: 'medium', testCb: inviteContactGroupBanner, countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Edit Group', + }, + allureDescription: + 'Verifies that the latest release banner is present on the Invite Contacts screen', }); async function inviteContactGroupBanner(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/group_tests_leave_group.spec.ts b/run/test/specs/group_tests_leave_group.spec.ts index 21adc4fa0..e3dbde48a 100644 --- a/run/test/specs/group_tests_leave_group.spec.ts +++ b/run/test/specs/group_tests_leave_group.spec.ts @@ -14,6 +14,11 @@ bothPlatformsIt({ risk: 'high', testCb: leaveGroup, countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Groups', + suite: 'Leave/Delete Group', + }, + allureDescription: 'Verifies that a non-admin member can leave a group successfully via the UI', }); async function leaveGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/invite_a_friend_share.spec.ts b/run/test/specs/invite_a_friend_share.spec.ts index 88ae0d575..a1afa55d0 100644 --- a/run/test/specs/invite_a_friend_share.spec.ts +++ b/run/test/specs/invite_a_friend_share.spec.ts @@ -14,6 +14,11 @@ bothPlatformsIt({ risk: 'medium', testCb: inviteAFriend, countOfDevicesNeeded: 1, + allureSuites: { + parent: 'New Conversation', + suite: 'Invite a Friend', + }, + allureDescription: `Verifies that the 'Invite a Friend' share functionality opens the native share sheet and the user's Account ID is present in the message.`, }); async function inviteAFriend(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -43,12 +48,10 @@ async function inviteAFriend(platform: SupportedPlatformsType, testInfo: TestInf } // Retrieve the Share message and validate that it contains the user's Account ID const retrievedShareMessage = await device.getTextFromElement(messageElement); - if (retrievedShareMessage.includes(user.accountID)) { - device.log("The Invite a Friend message snippet contains the user's Account ID"); - } else { - throw new Error( - `The Invite a Friend message snippet does not contain the user's Account ID\nThe message goes ${retrievedShareMessage}` - ); + if (!retrievedShareMessage.includes(user.accountID)) { + console.log(`Expected Share Message to contain Account ID: ${user.accountID}`); + console.log(`Actual Share Message: ${retrievedShareMessage}`); + throw new Error(`The Invite a Friend message snippet does not contain the user's Account ID.`); } await closeApp(device); } diff --git a/run/test/specs/landing_page_new_account.spec.ts b/run/test/specs/landing_page_new_account.spec.ts index cb9904970..fe1389ecf 100644 --- a/run/test/specs/landing_page_new_account.spec.ts +++ b/run/test/specs/landing_page_new_account.spec.ts @@ -1,28 +1,27 @@ -import type { TestInfo } from '@playwright/test'; +import { type TestInfo } from '@playwright/test'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { newUser } from './utils/create_account'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; -import { EmptyLandingPageScreenshot } from './utils/screenshot_paths'; -import { verifyElementScreenshot } from './utils/verify_screenshots'; +import { verifyPageScreenshot } from './utils/verify_screenshots'; bothPlatformsIt({ - title: 'Landing page new account', + title: 'Check landing page (new account) layout', risk: 'low', testCb: landingPageNewAccount, countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Visual Checks', + suite: 'Onboarding', + }, + allureDescription: `Verifies that the landing page for a new account matches the expected baseline`, }); async function landingPageNewAccount(platform: SupportedPlatformsType, testInfo: TestInfo) { const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); await newUser(device, USERNAME.ALICE); // Verify that the party popper is shown on the landing page - await verifyElementScreenshot( - device, - new EmptyLandingPageScreenshot(device), - testInfo, - 'new_account' - ); + await verifyPageScreenshot(device, platform, 'landingpage_new_account', testInfo, 0.995); await closeApp(device); } diff --git a/run/test/specs/landing_page_restore_account.spec.ts b/run/test/specs/landing_page_restore_account.spec.ts index 70f36b639..c030c12d8 100644 --- a/run/test/specs/landing_page_restore_account.spec.ts +++ b/run/test/specs/landing_page_restore_account.spec.ts @@ -5,14 +5,18 @@ import { USERNAME } from '@session-foundation/qa-seeder'; import { bothPlatformsIt } from '../../types/sessionIt'; import { linkedDevice } from './utils/link_device'; import { closeApp, openAppTwoDevices, SupportedPlatformsType } from './utils/open_app'; -import { EmptyLandingPageScreenshot } from './utils/screenshot_paths'; -import { verifyElementScreenshot } from './utils/verify_screenshots'; +import { verifyPageScreenshot } from './utils/verify_screenshots'; bothPlatformsIt({ - title: 'Landing page restore account', + title: 'Check landing page (restored account) layout', risk: 'low', testCb: landingPageRestoreAccount, countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Visual Checks', + suite: 'Onboarding', + }, + allureDescription: `Verifies that the landing page for a restored account matches the expected baseline`, }); async function landingPageRestoreAccount(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -20,11 +24,6 @@ async function landingPageRestoreAccount(platform: SupportedPlatformsType, testI const { device1: alice1, device2: alice2 } = await openAppTwoDevices(platform, testInfo); await linkedDevice(alice1, alice2, USERNAME.ALICE); // Verify that the Session logo is shown on the landing page - await verifyElementScreenshot( - alice2, - new EmptyLandingPageScreenshot(alice2), - testInfo, - 'restore_account' - ); + await verifyPageScreenshot(alice2, platform, 'landingpage_restore_account', testInfo, 0.995); await closeApp(alice1, alice2); } diff --git a/run/test/specs/linked_device_avatar_color.spec.ts b/run/test/specs/linked_device_avatar_color.spec.ts index cb8136b2e..cd411b340 100644 --- a/run/test/specs/linked_device_avatar_color.spec.ts +++ b/run/test/specs/linked_device_avatar_color.spec.ts @@ -11,11 +11,15 @@ bothPlatformsIt({ risk: 'medium', testCb: avatarColorLinkedDevice, countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Visual Checks', + suite: 'Onboarding', + }, + allureDescription: `Verifies that a user's avatar color is consistent across linked devices.`, }); async function avatarColorLinkedDevice(platform: SupportedPlatformsType, testInfo: TestInfo) { const { devices: { alice1, alice2 }, - prebuilt: { alice }, } = await open_Alice2({ platform, testInfo }); // Get Alice's avatar color on device 1 (Home Screen avatar) and turn it into a hex value @@ -25,9 +29,9 @@ async function avatarColorLinkedDevice(platform: SupportedPlatformsType, testInf // Color matching devices 1 and 2 const colorMatch = isSameColor(alice1PixelColor, alice2PixelColor); if (!colorMatch) { - throw new Error( - `The avatar color of ${alice.userName} does not match across devices. The colors are ${alice1PixelColor} and ${alice2PixelColor}` - ); + console.log(`Device 1 pixel color: ${alice1PixelColor}`); + console.log(`Device 2 pixel color: ${alice2PixelColor}`); + throw new Error(`The user's placeholder avatar color does not match across linked devices.`); } await closeApp(alice1, alice2); } diff --git a/run/test/specs/linked_device_change_username.spec.ts b/run/test/specs/linked_device_change_username.spec.ts index 38e93bb34..7193e883f 100644 --- a/run/test/specs/linked_device_change_username.spec.ts +++ b/run/test/specs/linked_device_change_username.spec.ts @@ -12,6 +12,11 @@ bothPlatformsIt({ risk: 'medium', countOfDevicesNeeded: 2, testCb: changeUsernameLinked, + allureSuites: { + parent: 'User Actions', + suite: 'Change Username', + }, + allureDescription: `Verifies that a username change syncs to a linked device.`, }); async function changeUsernameLinked(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/linked_device_create_group.spec.ts b/run/test/specs/linked_device_create_group.spec.ts index 40899c26d..64931e5dc 100644 --- a/run/test/specs/linked_device_create_group.spec.ts +++ b/run/test/specs/linked_device_create_group.spec.ts @@ -26,6 +26,12 @@ bothPlatformsItSeparate({ android: { testCb: linkedGroupAndroid, }, + allureSuites: { + parent: 'Groups', + suite: 'Create Group', + }, + allureDescription: + 'Verifies that a group created on one device appears on a linked device, and that a group name change syncs to all members.', }); async function linkedGroupiOS(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/linked_device_delete_message.spec.ts b/run/test/specs/linked_device_delete_message.spec.ts index 1f21683f6..46a4d612d 100644 --- a/run/test/specs/linked_device_delete_message.spec.ts +++ b/run/test/specs/linked_device_delete_message.spec.ts @@ -13,6 +13,12 @@ bothPlatformsIt({ risk: 'high', testCb: deletedMessageLinkedDevice, countOfDevicesNeeded: 3, + allureSuites: { + parent: 'User Actions', + suite: 'Delete Message', + }, + allureDescription: + 'Verifies that when a message is deleted on one device, it shows as deleted on a linked device too.', }); async function deletedMessageLinkedDevice(platform: SupportedPlatformsType, testInfo: TestInfo) { const { diff --git a/run/test/specs/linked_device_hide_note_to_self.spec.ts b/run/test/specs/linked_device_hide_note_to_self.spec.ts index 19db9dff1..e6e02d740 100644 --- a/run/test/specs/linked_device_hide_note_to_self.spec.ts +++ b/run/test/specs/linked_device_hide_note_to_self.spec.ts @@ -18,6 +18,7 @@ bothPlatformsIt({ parent: 'User Actions', suite: 'Hide Note to Self', }, + allureDescription: 'Verifies that Hide Note To Self syncs to a linked device.', }); async function hideNoteToSelf(platform: SupportedPlatformsType, testInfo: TestInfo) { diff --git a/run/test/specs/linked_device_profile_picture_syncs.spec.ts b/run/test/specs/linked_device_profile_picture_syncs.spec.ts index 447da819e..d0247023b 100644 --- a/run/test/specs/linked_device_profile_picture_syncs.spec.ts +++ b/run/test/specs/linked_device_profile_picture_syncs.spec.ts @@ -21,6 +21,7 @@ bothPlatformsIt({ async function avatarRestored(platform: SupportedPlatformsType, testInfo: TestInfo) { const expectedPixelHexColor = '04cbfe'; // This is the color of the profile picture image stored in the repo + const tolerance = 5; // Slightly higher than default tolerance because of jpeg compression const { devices: { alice1, alice2 }, } = await open_Alice2({ platform, testInfo }); @@ -29,7 +30,8 @@ async function avatarRestored(platform: SupportedPlatformsType, testInfo: TestIn await test.step(TestSteps.VERIFY.PROFILE_PICTURE_CHANGED, async () => { await alice2.waitForElementColorMatch( { ...new UserAvatar(alice2).build(), maxWait: 20_000 }, - expectedPixelHexColor + expectedPixelHexColor, + tolerance ); }); await closeApp(alice1, alice2); diff --git a/run/test/specs/linked_device_restore_group.spec.ts b/run/test/specs/linked_device_restore_group.spec.ts index e90c6e485..289e53d83 100644 --- a/run/test/specs/linked_device_restore_group.spec.ts +++ b/run/test/specs/linked_device_restore_group.spec.ts @@ -14,6 +14,12 @@ bothPlatformsIt({ risk: 'high', testCb: restoreGroup, countOfDevicesNeeded: 4, + allureSuites: { + parent: 'Groups', + suite: 'Create Group', + }, + allureDescription: + 'Verifies that a group (and its messages) created on one device appears when restoring on a second device.', }); async function restoreGroup(platform: SupportedPlatformsType, testInfo: TestInfo) { const testGroupName = 'Restore group'; diff --git a/run/test/specs/locators/conversation.ts b/run/test/specs/locators/conversation.ts index 960237075..66cfce859 100644 --- a/run/test/specs/locators/conversation.ts +++ b/run/test/specs/locators/conversation.ts @@ -1,6 +1,7 @@ import type { DeviceWrapper } from '../../../types/DeviceWrapper'; import { testCommunityName } from '../../../constants/community'; +import { englishStrippedStr } from '../../../localizer/englishStrippedStr'; import { StrategyExtractionObj } from '../../../types/testing'; import { getAppDisplayName } from '../utils/devnet'; import { LocatorsInterface } from './index'; @@ -180,9 +181,9 @@ export class OutgoingMessageStatusSent extends LocatorsInterface { switch (this.platform) { case 'android': return { - strategy: '-android uiautomator', - selector: - 'new UiSelector().resourceId("network.loki.messenger.qa:id/messageStatusTextView").text("Sent")', + strategy: 'id', + selector: 'network.loki.messenger.qa:id/messageStatusTextView', + text: 'Sent', } as const; case 'ios': return { @@ -234,12 +235,16 @@ export class ConversationHeaderName extends LocatorsInterface { } } -export class NotificationSettings extends LocatorsInterface { +export class NotificationsModalButton extends LocatorsInterface { public build() { - return { - strategy: 'accessibility id', - selector: 'Notifications', - } as const; + switch (this.platform) { + case 'android': + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Notifications', + } as const; + } } } @@ -585,3 +590,124 @@ export class EmojiReactsCount extends LocatorsInterface { } } } + +export class MessageLengthCountdown extends LocatorsInterface { + constructor( + device: DeviceWrapper, + private length?: string + ) { + super(device); + } + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'network.loki.messenger.qa:id/characterLimitText', + text: this.length, + } as const; + case 'ios': + return { + strategy: 'xpath', + selector: `//XCUIElementTypeStaticText[@name="${this.length}"]`, + text: this.length, + } as const; + } + } +} + +export class MessageLengthOkayButton extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { strategy: 'id', selector: 'Okay' } as const; + case 'ios': + return { strategy: 'xpath', selector: '//XCUIElementTypeButton[@name="Okay"]' } as const; + } + } +} + +export class CommunityMessageAuthor extends LocatorsInterface { + public text: string; + constructor(device: DeviceWrapper, text: string) { + super(device); + this.text = text; + } + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + // Identify the profile picture of a message with a specific text + return { + strategy: 'xpath', + selector: `//android.view.ViewGroup[@resource-id='network.loki.messenger.qa:id/mainContainer'][.//android.widget.TextView[contains(@text,'${this.text}')]]//androidx.compose.ui.platform.ComposeView[@resource-id='network.loki.messenger.qa:id/profilePictureView']`, + } as const; + case 'ios': + // Identify the display name of a blinded sender of a message with a specific text + return { + strategy: 'xpath', + selector: `//XCUIElementTypeCell[.//XCUIElementTypeOther[@name='Message body' and contains(@label,'${this.text}')]]//XCUIElementTypeStaticText[contains(@value,'(15')]`, + } as const; + } + } +} + +export class UPMMessageButton extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'xpath', + selector: `//android.widget.TextView[@text="Message"]/parent::android.view.View`, + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Message', + } as const; + } + } +} + +export class MessageRequestPendingDescription extends LocatorsInterface { + public build() { + const messageRequestPendingDescription = englishStrippedStr( + 'messageRequestPendingDescription' + ).toString(); + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'network.loki.messenger.qa:id/textSendAfterApproval', + text: messageRequestPendingDescription, + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Control message', + text: messageRequestPendingDescription, + } as const; + } + } +} + +export class MessageRequestAcceptDescription extends LocatorsInterface { + public build() { + const messageRequestsAcceptDescription = englishStrippedStr( + 'messageRequestsAcceptDescription' + ).toString(); + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'network.loki.messenger.qa:id/sendAcceptsTextView', + text: messageRequestsAcceptDescription, + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Control message', + text: messageRequestsAcceptDescription, + } as const; + } + } +} diff --git a/run/test/specs/locators/groups.ts b/run/test/specs/locators/groups.ts index f994e7e9f..89f1439f9 100644 --- a/run/test/specs/locators/groups.ts +++ b/run/test/specs/locators/groups.ts @@ -196,6 +196,38 @@ export class LeaveGroupConfirm extends LocatorsInterface { } } } +export class DeleteGroupMenuItem extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'delete-group-menu-option', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Leave group', // yep this is leave even for the delete option + } as const; + } + } +} +export class DeleteGroupConfirm extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'delete-group-confirm-button', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Delete', + } as const; + } + } +} export class LatestReleaseBanner extends LocatorsInterface { public build() { switch (this.platform) { diff --git a/run/test/specs/locators/index.ts b/run/test/specs/locators/index.ts index 8a61169cc..955f7c001 100644 --- a/run/test/specs/locators/index.ts +++ b/run/test/specs/locators/index.ts @@ -1,6 +1,6 @@ import { ANDROID_XPATHS, IOS_XPATHS } from '../../../constants'; import { DeviceWrapper } from '../../../types/DeviceWrapper'; -import { ElementStates, StrategyExtractionObj } from '../../../types/testing'; +import { StrategyExtractionObj } from '../../../types/testing'; import { getAppDisplayName } from '../utils/devnet'; import { SupportedPlatformsType } from '../utils/open_app'; @@ -44,11 +44,6 @@ export function describeLocator(locator: StrategyExtractionObj & { text?: string return text ? `${base} and text "${text}"` : base; } -// Returns the expected screenshot path for a locator, optionally varying by state -export abstract class LocatorsInterfaceScreenshot extends LocatorsInterface { - abstract screenshotFileName(state?: ElementStates): string; -} - export class ApplyChanges extends LocatorsInterface { public build() { switch (this.platform) { diff --git a/run/test/specs/locators/network_page.ts b/run/test/specs/locators/network_page.ts index dfb736cc9..6b577f89e 100644 --- a/run/test/specs/locators/network_page.ts +++ b/run/test/specs/locators/network_page.ts @@ -1,4 +1,6 @@ import { LocatorsInterface } from '.'; +import { englishStrippedStr } from '../../../localizer/englishStrippedStr'; +import { DeviceWrapper } from '../../../types/DeviceWrapper'; export class SessionNetworkMenuItem extends LocatorsInterface { public build() { @@ -52,17 +54,25 @@ export class SessionNetworkLearnMoreStaking extends LocatorsInterface { } export class LastUpdatedTimeStamp extends LocatorsInterface { + private expectedText: string; + + constructor(device: DeviceWrapper, relative_time: string) { + super(device); + this.expectedText = englishStrippedStr('updated').withArgs({ relative_time }).toString(); + } public build() { switch (this.platform) { case 'android': return { strategy: 'id', selector: 'Last updated timestamp', + text: this.expectedText, } as const; case 'ios': return { strategy: 'accessibility id', selector: 'Last updated timestamp', + text: this.expectedText, } as const; } } @@ -84,3 +94,83 @@ export class OpenLinkButton extends LocatorsInterface { } } } +export class SESHPrice extends LocatorsInterface { + private expectedText: string; + + constructor(device: DeviceWrapper, priceValue: number) { + super(device); + this.expectedText = `$${priceValue.toFixed(2)} USD`; + } + + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'SESH price', + text: this.expectedText, + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'SENT price', + text: this.expectedText, + } as const; + } + } +} + +export class StakingRewardPoolAmount extends LocatorsInterface { + private expectedText: string; + + constructor(device: DeviceWrapper, amount: number) { + super(device); + // Format with commas and SESH suffix + this.expectedText = `${amount.toLocaleString('en-US')} SESH`; + } + + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'Staking reward pool amount', + text: this.expectedText, + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Staking reward pool amount', + text: this.expectedText, + } as const; + } + } +} + +export class MarketCapAmount extends LocatorsInterface { + private expectedText: string; + + constructor(device: DeviceWrapper, amount: number) { + super(device); + // Round to whole number, then format with commas and USD suffix + const rounded = Math.round(amount); + this.expectedText = `$${rounded.toLocaleString('en-US')} USD`; + } + + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'Market cap amount', + text: this.expectedText, + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Market cap amount', + text: this.expectedText, + } as const; + } + } +} diff --git a/run/test/specs/locators/onboarding.ts b/run/test/specs/locators/onboarding.ts index dcf7c1fe1..869e16c8b 100644 --- a/run/test/specs/locators/onboarding.ts +++ b/run/test/specs/locators/onboarding.ts @@ -105,7 +105,7 @@ export class TermsOfServiceButton extends LocatorsInterface { case 'android': return { strategy: 'id', - selector: 'Terms of Service', + selector: 'Terms of Service', // will be Terms of service *button* past 1.29.0 } as const; case 'ios': return { @@ -122,7 +122,7 @@ export class PrivacyPolicyButton extends LocatorsInterface { case 'android': return { strategy: 'id', - selector: 'Privacy Policy', + selector: 'Privacy Policy', // will be Privacy policy *button* past 1.29.0 } as const; case 'ios': return { diff --git a/run/test/specs/locators/settings.ts b/run/test/specs/locators/settings.ts index b485be8aa..02ae1349b 100644 --- a/run/test/specs/locators/settings.ts +++ b/run/test/specs/locators/settings.ts @@ -112,6 +112,23 @@ export class RecoveryPhraseContainer extends LocatorsInterface { } } +export class CommunityMessageRequestSwitch extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { + strategy: '-android uiautomator', + selector: 'new UiSelector().text("Community Message Requests")', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Community Message Requests', + } as const; + } + } +} + export class SaveProfilePictureButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -177,6 +194,23 @@ export class ConversationsMenuItem extends LocatorsInterface { } } +export class NotificationsMenuItem extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'Notifications', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Notifications', + } as const; + } + } +} + export class AppearanceMenuItem extends LocatorsInterface { public build() { switch (this.platform) { diff --git a/run/test/specs/message_length.spec.ts b/run/test/specs/message_length.spec.ts new file mode 100644 index 000000000..e2c1fea4f --- /dev/null +++ b/run/test/specs/message_length.spec.ts @@ -0,0 +1,105 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { englishStrippedStr } from '../../localizer/englishStrippedStr'; +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { + MessageBody, + MessageInput, + MessageLengthCountdown, + MessageLengthOkayButton, + SendButton, +} from './locators/conversation'; +import { PlusButton } from './locators/home'; +import { EnterAccountID, NewMessageOption, NextButton } from './locators/start_conversation'; +import { newUser } from './utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; + +const maxChars = 2000; +const countdownThreshold = 1800; + +const messageLengthTestCases = [ + { + 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' }, + { + length: 2001, + char: 'd', + shouldSend: false, + description: 'countdown shows -1, cannot send message', + }, +]; + +for (const testCase of messageLengthTestCases) { + bothPlatformsIt({ + title: `Message length limit (${testCase.length} chars)`, + risk: 'high', + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Sending Messages', + suite: 'Rules', + }, + allureDescription: `Verifies message length behavior at ${testCase.length} characters - ${testCase.description}`, + testCb: async (platform: SupportedPlatformsType, testInfo: TestInfo) => { + const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const alice = await newUser(device, USERNAME.ALICE); + return { device, alice }; + }); + + // Send message to self to bring up Note to Self conversation + await test.step(TestSteps.OPEN.NTS, async () => { + 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 test.step(`Type ${testCase.length} chars, check countdown`, async () => { + const expectedCount = + testCase.length < countdownThreshold ? null : (maxChars - testCase.length).toString(); + + // Construct the string of desired length + const message = testCase.char.repeat(testCase.length); + await device.inputText(message, new MessageInput(device)); + + // Does the countdown appear? + if (expectedCount) { + await device.waitForTextElementToBePresent( + new MessageLengthCountdown(device, expectedCount) + ); + } else { + await device.verifyElementNotPresent(new MessageLengthCountdown(device)); + } + + await device.clickOnElementAll(new SendButton(device)); + + // Is the message short enough to send? + if (testCase.shouldSend) { + await device.waitForTextElementToBePresent(new MessageBody(device, message)); + } else { + // Modal appears, verify and dismiss + await device.checkModalStrings( + englishStrippedStr('modalMessageTooLongTitle').toString(), + englishStrippedStr('modalMessageTooLongDescription') + .withArgs({ limit: maxChars.toString() }) + .toString() + ); + await device.clickOnElementAll(new MessageLengthOkayButton(device)); + await device.verifyElementNotPresent(new MessageBody(device, message)); + } + }); + + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); + }, + }); +} diff --git a/run/test/specs/network_page_link_network.spec.ts b/run/test/specs/network_page_link_network.spec.ts index b85fb25e3..574cadf80 100644 --- a/run/test/specs/network_page_link_network.spec.ts +++ b/run/test/specs/network_page_link_network.spec.ts @@ -20,6 +20,11 @@ bothPlatformsIt({ risk: 'medium', testCb: networkPageLearnMore, countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Network Page', + }, + allureDescription: + 'Verifies that the "Learn More" link on the Network Page opens the correct URL in the device browser.', }); async function networkPageLearnMore(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -50,8 +55,6 @@ async function networkPageLearnMore(platform: SupportedPlatformsType, testInfo: throw new Error( `The retrieved URL does not match the expected. The retrieved URL is ${fullRetrievedURL}` ); - } else { - device.log('The URLs match.'); } await assertUrlIsReachable(linkURL); // Close browser and app diff --git a/run/test/specs/network_page_link_staking.spec.ts b/run/test/specs/network_page_link_staking.spec.ts index 2a8e9a254..114dcc2bf 100644 --- a/run/test/specs/network_page_link_staking.spec.ts +++ b/run/test/specs/network_page_link_staking.spec.ts @@ -20,6 +20,11 @@ bothPlatformsIt({ risk: 'medium', testCb: networkPageLearnMore, countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Network Page', + }, + allureDescription: + 'Verifies that the "Learn More" link on the Network Page for Staking opens the correct URL in the device browser.', }); async function networkPageLearnMore(platform: SupportedPlatformsType, testInfo: TestInfo) { @@ -51,8 +56,6 @@ async function networkPageLearnMore(platform: SupportedPlatformsType, testInfo: throw new Error( `The retrieved URL does not match the expected. The retrieved URL is ${fullRetrievedURL}` ); - } else { - device.log('The URLs match.'); } await assertUrlIsReachable(linkURL); // Close browser and app diff --git a/run/test/specs/network_page_refresh_page.spec.ts b/run/test/specs/network_page_refresh_page.spec.ts index 55201d690..2bce3961c 100644 --- a/run/test/specs/network_page_refresh_page.spec.ts +++ b/run/test/specs/network_page_refresh_page.spec.ts @@ -4,34 +4,36 @@ import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { LastUpdatedTimeStamp, SessionNetworkMenuItem } from './locators/network_page'; import { UserSettings } from './locators/settings'; +import { sleepFor } from './utils'; import { newUser } from './utils/create_account'; -import { openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; bothPlatformsIt({ - title: 'Refresh network page', + title: 'Network page refresh', risk: 'low', testCb: refreshNetworkPage, countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Network Page', + }, + allureDescription: `Verifies that the Network Page refreshes and updates the "Last updated" timestamp correctly.`, }); async function refreshNetworkPage(platform: SupportedPlatformsType, testInfo: TestInfo) { - const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const zeroMinutesAgo = '0m'; + const oneMinuteAgo = '1m'; - const lastUpdatedExpected = 'Last updated 0m ago'; + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); await newUser(device, USERNAME.ALICE, { saveUserData: false }); await device.clickOnElementAll(new UserSettings(device)); await device.onAndroid().scrollDown(); await device.clickOnElementAll(new SessionNetworkMenuItem(device)); - // Check for loading states - await device.waitForLoadingMedia(); + await device.waitForLoadingMedia(); // Wait for fetch to complete + await device.waitForTextElementToBePresent(new LastUpdatedTimeStamp(device, zeroMinutesAgo)); + await sleepFor(65_000); // 60+5 seconds to ensure the last updated value changes + await device.waitForTextElementToBePresent(new LastUpdatedTimeStamp(device, oneMinuteAgo)); await device.pullToRefresh(); await device.waitForLoadingMedia(); - await device.onAndroid().scrollDown(); - const timeStampEl = await device.waitForTextElementToBePresent(new LastUpdatedTimeStamp(device)); - const lastUpdatedActual = await device.getTextFromElement(timeStampEl); - if (lastUpdatedActual !== lastUpdatedExpected) { - throw new Error( - `The retrieved last updated time does not match the expected. The retrieved last updated time is ${lastUpdatedActual}` - ); - } + await device.waitForTextElementToBePresent(new LastUpdatedTimeStamp(device, zeroMinutesAgo)); + await closeApp(device); } diff --git a/run/test/specs/network_page_values.spec.ts b/run/test/specs/network_page_values.spec.ts new file mode 100644 index 000000000..e8558cb5c --- /dev/null +++ b/run/test/specs/network_page_values.spec.ts @@ -0,0 +1,78 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { + MarketCapAmount, + SESHPrice, + SessionNetworkMenuItem, + StakingRewardPoolAmount, +} from './locators/network_page'; +import { UserSettings } from './locators/settings'; +import { newUser } from './utils/create_account'; +import { validateNetworkData } from './utils/network_api'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; + +bothPlatformsIt({ + title: 'Network page values', + risk: 'medium', + testCb: networkPageValues, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Network Page', + }, + allureDescription: + 'Verifies that the Session Network Page displays the values fetched from the network API correctly.', +}); + +async function networkPageValues(platform: SupportedPlatformsType, testInfo: TestInfo) { + let data: { + price: { usd: number; usd_market_cap: number }; + token: { staking_reward_pool: number }; + }; + + 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.OPEN.GENERIC('Session Network Page'), async () => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new SessionNetworkMenuItem(device)); + }); + + await test.step('Fetch and validate Network API data', async () => { + const response = await fetch('http://networkv1.getsession.org/info'); + if (!response.ok) { + throw new Error(`Network API returned ${response.status}`); + } + data = await response.json(); + validateNetworkData(data); + + console.log(`Price: ${data.price.usd}`); + console.log(`Staking Reward Pool: ${data.token.staking_reward_pool}`); + console.log(`Market Cap: ${data.price.usd_market_cap}`); + }); + + await test.step('Verify SESH price is displayed correctly', async () => { + await device.waitForTextElementToBePresent(new SESHPrice(device, data.price.usd)); + }); + + await test.step('Verify Staking Reward Pool is displayed correctly', async () => { + await device.waitForTextElementToBePresent( + new StakingRewardPoolAmount(device, data.token.staking_reward_pool) + ); + }); + + await test.step('Verify Market Cap is displayed correctly', async () => { + await device.waitForTextElementToBePresent( + new MarketCapAmount(device, data.price.usd_market_cap) + ); + }); + + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} diff --git a/run/test/specs/state_builder/index.ts b/run/test/specs/state_builder/index.ts index 885ddea05..975cdaf5a 100644 --- a/run/test/specs/state_builder/index.ts +++ b/run/test/specs/state_builder/index.ts @@ -203,6 +203,67 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ }; } +export async function open_Alice2_Bob1_Charlie1_friends_group({ + platform, + groupName, + focusGroupConvo, + testInfo, +}: WithPlatform & + WithFocusGroupConvo & { + groupName: string; + testInfo: TestInfo; + }) { + const stateToBuildKey = '3friendsInGroup'; + const appsToOpen = 4; + const result = await openAppsWithState({ + platform, + appsToOpen, + stateToBuildKey, + groupName, + testInfo, + }); + result.devices[0].setDeviceIdentity('alice1'); + result.devices[1].setDeviceIdentity('bob1'); + result.devices[2].setDeviceIdentity('charlie1'); + result.devices[3].setDeviceIdentity('alice2'); + + const alice = result.prebuilt.users[0]; + const bob = result.prebuilt.users[1]; + const charlie = result.prebuilt.users[2]; + + const seedPhrases = [alice.seedPhrase, bob.seedPhrase, charlie.seedPhrase, alice.seedPhrase]; + await linkDevices(result.devices, seedPhrases); + + const alice1 = result.devices[0]; + const bob1 = result.devices[1]; + const charlie1 = result.devices[2]; + const alice2 = result.devices[3]; + + const formattedGroup = { group: result.prebuilt.group }; + const formattedDevices = { + alice1, + bob1, + charlie1, + alice2, + }; + const formattedUsers: WithUsers<3> = { + alice, + bob, + charlie, + }; + if (focusGroupConvo) { + await focusConvoOnDevices({ + devices: result.devices, + convoName: result.prebuilt.group.groupName, + }); + } + + return { + devices: formattedDevices, + prebuilt: { ...formattedUsers, ...formattedGroup }, + }; +} + /** * Open 4 devices, one for Alice, one for Bob, one for Charlie, and one extra, unlinked. * This function is used for testing that we can do a bunch of actions without having a linked device, diff --git a/run/test/specs/upm_homescreen.spec.ts b/run/test/specs/upm_homescreen.spec.ts new file mode 100644 index 000000000..ce36da7b5 --- /dev/null +++ b/run/test/specs/upm_homescreen.spec.ts @@ -0,0 +1,61 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { TestSteps } from '../../types/allure'; +import { androidIt } from '../../types/sessionIt'; +import { open_Alice1_Bob1_friends } from './state_builder'; +import { closeApp, SupportedPlatformsType } from './utils/open_app'; +import { verifyPageScreenshot } from './utils/verify_screenshots'; + +androidIt({ + title: 'User Profile Modal Home Screen', + risk: 'high', + testCb: upmHomeScreen, + countOfDevicesNeeded: 2, +}); + +async function upmHomeScreen(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { + devices: { alice1, bob1 }, + prebuilt: { bob }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_friends({ + platform, + focusFriendsConvo: false, + testInfo, + }); + }); + await test.step('Open User Profile Modal on home screen', async () => { + await alice1.longPressConversation(bob.userName); + await alice1.clickOnElementAll({ + strategy: 'accessibility id', + selector: 'Details', + }); + }); + await test.step(TestSteps.VERIFY.SCREENSHOT('user profile modal'), async () => { + await verifyPageScreenshot(alice1, platform, 'upm_home', testInfo); + }); + await test.step(`Verify ${bob.userName} display name in user profile modal`, async () => { + await alice1.waitForTextElementToBePresent({ + strategy: 'id', + selector: 'pro-badge-text', + text: bob.userName, + }); + }); + await test.step(`Verify ${bob.userName} account id in user profile modal`, async () => { + const el = await alice1.waitForTextElementToBePresent({ + strategy: 'id', + selector: 'account-id', + }); + const elText = await alice1.getTextFromElement(el); + const normalized = elText.replace(/\s+/g, ''); // account id comes in two lines + const expected = bob.sessionId.trim(); + if (normalized !== expected) { + console.log(`Expected: ${expected} + Observed: ${normalized}`); + throw new Error('Incorrect Account ID in the User Profile Modal'); + } + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} diff --git a/run/test/specs/user_actions_change_profile_picture.spec.ts b/run/test/specs/user_actions_change_profile_picture.spec.ts index bdca8f7fd..5c3ed7c59 100644 --- a/run/test/specs/user_actions_change_profile_picture.spec.ts +++ b/run/test/specs/user_actions_change_profile_picture.spec.ts @@ -22,6 +22,7 @@ bothPlatformsIt({ async function changeProfilePicture(platform: SupportedPlatformsType, testInfo: TestInfo) { const expectedPixelHexColor = '04cbfe'; // This is the color of the profile picture image stored in the repo + const tolerance = 5; // Slightly higher than default tolerance because of jpeg compression const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); await newUser(device, USERNAME.ALICE, { saveUserData: false }); @@ -33,7 +34,8 @@ async function changeProfilePicture(platform: SupportedPlatformsType, testInfo: await test.step(TestSteps.VERIFY.PROFILE_PICTURE_CHANGED, async () => { await device.waitForElementColorMatch( { ...new UserAvatar(device).build(), maxWait: 10_000 }, - expectedPixelHexColor + expectedPixelHexColor, + tolerance ); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { diff --git a/run/test/specs/utils/capabilities_ios.ts b/run/test/specs/utils/capabilities_ios.ts index 713457a2a..e355be25b 100644 --- a/run/test/specs/utils/capabilities_ios.ts +++ b/run/test/specs/utils/capabilities_ios.ts @@ -31,7 +31,7 @@ const sharediOSCapabilities: AppiumXCUITestCapabilities = { 'appium:processArguments': { env: { debugDisappearingMessageDurations: 'true', - communityPollLimit: 5, + communityPollLimit: 3, }, }, } as AppiumXCUITestCapabilities; diff --git a/run/test/specs/utils/check_colour.ts b/run/test/specs/utils/check_colour.ts index 21fd8a9f9..5b09032b3 100644 --- a/run/test/specs/utils/check_colour.ts +++ b/run/test/specs/utils/check_colour.ts @@ -38,13 +38,15 @@ export async function parseDataImage(base64: string) { return hexColor; } - // Determines if two colors look "the same" for humans even if they are not an exact match -export function isSameColor(hex1: string, hex2: string) { +export function isSameColor(hex1: string, hex2: string, tolerance?: number) { // Convert the hex strings to RGB objects const rgb1 = hexToRgbObject(hex1); const rgb2 = hexToRgbObject(hex2); - // Perform the color comparison using the looks-same library - const isSameColor = colors(rgb1, rgb2); - return isSameColor; + // Only pass options if tolerance is provided + if (tolerance !== undefined) { + return colors(rgb1, rgb2, { tolerance }); + } + // Call without options for default behavior + return colors(rgb1, rgb2); } diff --git a/run/test/specs/utils/devnet.ts b/run/test/specs/utils/devnet.ts index 26fbc527a..150d85ed6 100644 --- a/run/test/specs/utils/devnet.ts +++ b/run/test/specs/utils/devnet.ts @@ -1,4 +1,5 @@ import { buildStateForTest } from '@session-foundation/qa-seeder'; +import { execSync } from 'child_process'; import request from 'sync-request-curl'; import type { SupportedPlatformsType } from './open_app'; @@ -14,18 +15,32 @@ type NetworkType = Parameters[2]; // Using sync HTTP here to avoid cascading async changes through test init // This runs at test startup, so blocking is acceptable function canReachDevnet(): boolean { + const isCI = process.env.CI === '1'; + const maxAttempts = isCI ? 3 : 1; + const timeout = isCI ? 10_000 : 2_000; // Check if devnet is available - try { - const response = request('GET', DEVNET_URL, { - timeout: 2000, - }); - - console.log(`Internal devnet is accessible (HTTP ${response.statusCode})`); - return true; - } catch { - console.log('Internal devnet is not accessible'); - return false; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (maxAttempts > 1) { + console.log(`Checking devnet accessibility (attempt ${attempt}/${maxAttempts})...`); + } + + const response = request('GET', DEVNET_URL, { timeout }); + console.log(`Internal devnet is accessible (HTTP ${response.statusCode})`); + return true; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + + if (attempt === maxAttempts) { + console.log(`Internal devnet is not accessible: ${errorMsg}`); + } else { + console.log(`Attempt ${attempt} failed: ${errorMsg}, retrying...`); + execSync(`sleep ${attempt}`); + } + } } + + return false; } function isAutomaticQABuildAndroid(apkPath: string): boolean { // Check env var first (for CI), then filename (for local) @@ -49,6 +64,14 @@ export function getNetworkTarget(platform: SupportedPlatformsType): NetworkType const apkPath = getAndroidApk(); const isAQA = isAutomaticQABuildAndroid(apkPath); + + // Early exit for non AQA builds - no need to check devnet + if (!isAQA) { + process.env.DETECTED_NETWORK_TARGET = 'mainnet'; + console.log('Network target: mainnet'); + return 'mainnet'; + } + const canAccessDevnet = canReachDevnet(); // If you pass an AQA build in the .env but can't access devnet, tests will fail if (isAQA && !canAccessDevnet) { diff --git a/run/test/specs/utils/network_api.ts b/run/test/specs/utils/network_api.ts new file mode 100644 index 000000000..598baa705 --- /dev/null +++ b/run/test/specs/utils/network_api.ts @@ -0,0 +1,32 @@ +import { isFinite } from 'lodash'; + +export type NetworkData = { + price: { usd: number; usd_market_cap: number }; + token: { staking_reward_pool: number }; +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isPositiveFiniteNumber(n: unknown): n is number { + return typeof n === 'number' && isFinite(n) && n > 0; +} + +export function validateNetworkData(data: unknown): asserts data is NetworkData { + if (!isObject(data)) { + throw new Error('Invalid network API response: not an object'); + } + + if (!isObject(data.price) || !isObject(data.token)) { + throw new Error('Invalid network API response: missing price or token'); + } + + if ( + !isPositiveFiniteNumber(data.price.usd) || + !isPositiveFiniteNumber(data.price.usd_market_cap) || + !isPositiveFiniteNumber(data.token.staking_reward_pool) + ) { + throw new Error('Invalid network API response: numeric fields must be positive and finite'); + } +} diff --git a/run/test/specs/utils/open_app.ts b/run/test/specs/utils/open_app.ts index f523109ca..723237e83 100644 --- a/run/test/specs/utils/open_app.ts +++ b/run/test/specs/utils/open_app.ts @@ -291,27 +291,30 @@ const openiOSApp = async ( device: DeviceWrapper; }> => { console.info('openiOSApp'); - + let actualCapabilitiesIndex: number; const parallelIndex = parseInt(process.env.TEST_PARALLEL_INDEX || '0'); - - // NOTE: This assumes DEVICES_PER_TEST_COUNT=4 is set in CI for iOS (not applicable to Android) - // Worker pools are fixed at 4 devices each regardless of actual test size: - // Worker 0: devices 0-3, Worker 1: devices 4-7, Worker 2: devices 8-11 - const devicesPerWorker = getDevicesPerTestCount(); - const workerBaseOffset = devicesPerWorker * parallelIndex; - - // Apply retry offset, but wrap within the worker's device pool only - // This means when retrying, alice/bob etc won't be the same device as before within a worker's pool - // This is to avoid any issues where a device might be in a bad state for some reason - // (e.g. not accessing photo library on iOS) - const retryOffset = testInfo.retry || 0; - const deviceIndexWithinWorker = (capabilitiesIndex + retryOffset) % devicesPerWorker; - const actualCapabilitiesIndex = workerBaseOffset + deviceIndexWithinWorker; - - if (retryOffset > 0) { - console.info( - `Retry offset applied (#${retryOffset}), rotating device allocations within worker` - ); + if (process.env.CI === '1') { + // NOTE: This assumes DEVICES_PER_TEST_COUNT=4 is set in CI for iOS (not applicable to Android) + // Worker pools are fixed at 4 devices each regardless of actual test size: + // Worker 0: devices 0-3, Worker 1: devices 4-7, Worker 2: devices 8-11 + const devicesPerWorker = getDevicesPerTestCount(); + const workerBaseOffset = devicesPerWorker * parallelIndex; + + // Apply retry offset, but wrap within the worker's device pool only + // This means when retrying, alice/bob etc won't be the same device as before within a worker's pool + // This is to avoid any issues where a device might be in a bad state for some reason + // (e.g. not accessing photo library on iOS) + const retryOffset = testInfo.retry || 0; + const deviceIndexWithinWorker = (capabilitiesIndex + retryOffset) % devicesPerWorker; + actualCapabilitiesIndex = workerBaseOffset + deviceIndexWithinWorker; + + if (retryOffset > 0) { + console.info( + `Retry offset applied (#${retryOffset}), rotating device allocations within worker` + ); + } + } else { + actualCapabilitiesIndex = capabilitiesIndex; } const opts: XCUITestDriverOpts = { diff --git a/run/test/specs/utils/screenshot_paths.ts b/run/test/specs/utils/screenshot_paths.ts deleted file mode 100644 index 1bff8b8e5..000000000 --- a/run/test/specs/utils/screenshot_paths.ts +++ /dev/null @@ -1,20 +0,0 @@ -import path from 'path'; - -import { EmptyLandingPage } from '../locators/home'; -import { AppDisguisePage } from '../locators/settings'; - -// Extends locator classes with baseline screenshot paths for visual regression testing -// If a locator appears in multiple states, a state argument must be provided to screenshotFileName() - -export class EmptyLandingPageScreenshot extends EmptyLandingPage { - // The landing page has two different states depending on the onboarding flow taken - public screenshotFileName(state: 'new_account' | 'restore_account'): string { - return path.join('run', 'screenshots', this.platform, `landingpage_${state}.png`); - } -} - -export class AppDisguisePageScreenshot extends AppDisguisePage { - public screenshotFileName(): string { - return path.join('run', 'screenshots', this.platform, 'app_disguise.png'); - } -} diff --git a/run/test/specs/utils/utilities.ts b/run/test/specs/utils/utilities.ts index b89a2464b..bc07a9e30 100644 --- a/run/test/specs/utils/utilities.ts +++ b/run/test/specs/utils/utilities.ts @@ -2,12 +2,10 @@ import { exec as execNotPromised } from 'child_process'; import * as fs from 'fs'; import { pick } from 'lodash'; import path from 'path'; -import sharp from 'sharp'; import * as util from 'util'; -import { v4 as uuidv4 } from 'uuid'; import { DeviceWrapper } from '../../../types/DeviceWrapper'; -import { Suffix } from '../../../types/testing'; + const exec = util.promisify(execNotPromised); export async function runScriptAndLog(toRun: string, verbose = false): Promise { @@ -69,40 +67,6 @@ export function ensureHttpsURL(url: string): string { return url.startsWith('https://') ? url : `https://${url}`; } -export async function cropScreenshot(device: DeviceWrapper, screenshotBuffer: Buffer) { - const { width, height } = await device.getWindowRect(); - const cropTop = 250; - const cropLeft = 0; - const cropWidth = width; - const cropHeight = height - 250; - - const croppedBuf = await sharp(screenshotBuffer) - .extract({ left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight }) - .png() - .toBuffer(); - - return croppedBuf; -} - -export async function saveImage( - source: Buffer | { save: (fullPath: string) => Promise }, - directory: string, - suffix: Suffix, - customFileName?: string -) { - const name = customFileName ?? `${uuidv4()}_${suffix}.png`; - const fullPath = path.join(directory, name); - - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - - if (Buffer.isBuffer(source)) { - fs.writeFileSync(fullPath, source); - } else { - await source.save(fullPath); - } - return fullPath; -} - export function getDiffDirectory() { const diffsDir = path.join('test-results', 'diffs'); fs.mkdirSync(diffsDir, { recursive: true }); @@ -115,3 +79,52 @@ export async function assertUrlIsReachable(url: string): Promise { throw new Error(`Expected status 200 but got ${response.status} for URL: ${url}`); } } + +/** + * Eliminate any potential mismatches by mocking the status bar to always be the same + */ +export async function setConsistentStatusBar(device: DeviceWrapper): Promise { + if (device.isIOS()) { + // Time: 4:20, 100% battery, full wifi signal + device.log(`[DEBUG]: Attempting to set fake status bar`); + await runScriptAndLog( + `xcrun simctl status_bar ${device.udid} override --time "04:20" --batteryLevel 100 --batteryState charged --wifiBars 3`, + true + ); + device.log(`[DEBUG]: Fake status bar command has been sent`); + } else if (device.isAndroid()) { + // Enable demo mode to set consistent status bar elements + await runScriptAndLog(`adb -s ${device.udid} shell settings put global sysui_demo_allowed 1`); + // Dismiss notifications + await runScriptAndLog( + `adb -s ${device.udid} shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false` + ); + // Time: 4:20 + await runScriptAndLog( + `adb -s ${device.udid} shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0420` + ); + // 100% battery + await runScriptAndLog( + `adb -s ${device.udid} shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false` + ); + // Full wifi (for some reason shows an ! next to the icon but that's fine) + await runScriptAndLog( + `adb -s ${device.udid} shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4` + ); + } +} + +export async function clearStatusBarOverrides(device: DeviceWrapper): Promise { + try { + if (device.isIOS()) { + await runScriptAndLog(`xcrun simctl status_bar ${device.udid} clear`); + } else if (device.isAndroid()) { + await runScriptAndLog( + `adb -s ${device.udid} shell am broadcast -a com.android.systemui.demo -e command exit` + ); + } + } catch (error) { + console.warn('Failed to clear status bar overrides:', error); + // Don't throw - this is cleanup, shouldn't fail the test + } +} diff --git a/run/test/specs/utils/verify_screenshots.ts b/run/test/specs/utils/verify_screenshots.ts index f949acce4..ecdcd5dc6 100644 --- a/run/test/specs/utils/verify_screenshots.ts +++ b/run/test/specs/utils/verify_screenshots.ts @@ -2,11 +2,15 @@ import { TestInfo } from '@playwright/test'; import * as fs from 'fs'; import looksSame from 'looks-same'; import * as path from 'path'; +import sharp from 'sharp'; +import { ssim } from 'ssim.js'; import { v4 as uuidv4 } from 'uuid'; import { DeviceWrapper } from '../../../types/DeviceWrapper'; -import { LocatorsInterfaceScreenshot } from '../locators'; +import { ScreenshotFileNames } from '../../../types/testing'; +import { SupportedPlatformsType } from './open_app'; import { getDiffDirectory } from './utilities'; +import { clearStatusBarOverrides, setConsistentStatusBar } from './utilities'; type Attachment = { name: string; @@ -14,7 +18,13 @@ type Attachment = { contentType: string; }; -export async function pushAttachmentsToReport( +interface ImageData { + data: Uint8ClampedArray; + width: number; + height: number; +} + +async function pushAttachmentsToReport( testInfo: TestInfo, attachments: Attachment[] ): Promise { @@ -24,105 +34,178 @@ export async function pushAttachmentsToReport( } /** - * Takes a screenshot of a UI element and verifies it against a saved baseline image. - * - * Requires Playwright's `testInfo` for attaching visual comparison artifacts to the test report. - * Supports locators with multiple states; enforces correct state usage via type constraints. - * If no baseline image exists, the element screenshot is saved and an error is thrown. - * On mismatch, a pixel-by-pixel comparison is performed and a visual diff is attached (when CI + ALLURE_ENABLED). - * Baseline screenshots are assumed to have been taken on:Pixel 6 (1080x2061) and iPhone 16 Pro Max (1320x2868) - * - * Example usage: - * // Locator with multiple states: - * await verifyElementScreenshot(device, new EmptyLandingPageScreenshot(device), testInfo, 'new_account'); - * - * // Locator with a single state: - * await verifyElementScreenshot(device, new SomeSimpleLocatorScreenshot(device), testInfo); + * Converts image buffer to SSIM-compatible ImageData format */ +async function bufferToImageData(imageBuffer: Buffer): Promise { + const image = sharp(imageBuffer); + const { width, height } = await image.metadata(); + const rawBuffer = await image.raw().toBuffer(); -export async function verifyElementScreenshot< - T extends LocatorsInterfaceScreenshot & { screenshotFileName: (...args: any[]) => string }, ->( - device: DeviceWrapper, - element: T, + return { + data: new Uint8ClampedArray(rawBuffer), + width: width, + height: height, + }; +} + +/** + * Converts file path to SSIM-compatible ImageData format + */ +async function fileToImageData(filePath: string): Promise { + const image = sharp(filePath); + const { width, height } = await image.metadata(); + const rawBuffer = await image.raw().toBuffer(); + + return { + data: new Uint8ClampedArray(rawBuffer), + width: width, + height: height, + }; +} + +/** + * Performs SSIM comparison with optional fallback to looks-same for diff generation + * SSIM focuses on structural similarity rather than pixel-perfect matching, making it + * robust to minor rendering differences while still catching layout changes + */ +async function compareWithSSIM( + actualBuffer: Buffer, + baselineImagePath: string, testInfo: TestInfo, - ...args: Parameters // Enforces states when mandatory + threshold: number ): Promise { - // Declaring a UUID in advance so that the diff and screenshot files are matched alphanumerically - const uuid = uuidv4(); - // Using Playwright's default test-results folder ensures cleanup at the beginning of each run - const diffsDir = getDiffDirectory(); - // Get the element screenshot as base64 - const elementToScreenshot = await device.waitForTextElementToBePresent(element); - const elementScreenshotBase64: string = await device.getElementScreenshot( - elementToScreenshot.ELEMENT - ); - // Convert the base64 string to a Buffer and save it to disk as a png - const elementScreenshotPath = path.join(diffsDir, `${uuid}_screenshot.png`); - const screenshotBuffer = Buffer.from(elementScreenshotBase64, 'base64'); - fs.writeFileSync(elementScreenshotPath, screenshotBuffer); - // Check if baseline screenshot exists - const baselineScreenshotPath = element.screenshotFileName(...args); - if (!fs.existsSync(baselineScreenshotPath)) { + const actualImageData = await bufferToImageData(actualBuffer); + const baselineImageData = await fileToImageData(baselineImagePath); + + // Check dimensions match + if ( + actualImageData.width !== baselineImageData.width || + actualImageData.height !== baselineImageData.height + ) { throw new Error( - `No baseline image found at: ${baselineScreenshotPath}. A new screenshot has been saved at: ${elementScreenshotPath}` + `Image dimensions don't match: actual ${actualImageData.width}x${actualImageData.height}, \n + baseline ${baselineImageData.width}x${baselineImageData.height}` ); } - // Use looks-same to verify the element screenshot against the baseline - const { equal, diffImage } = await looksSame(elementScreenshotPath, baselineScreenshotPath, { - createDiffImage: true, - }); - if (!equal) { - const diffImagePath = path.join(diffsDir, `${uuid}_diffImage.png`); - await diffImage.save(diffImagePath); - - // For the CI, create a visual diff that renders in the Allure report - if (process.env.ALLURE_ENABLED === 'true' && process.env.CI === '1') { - // Load baseline and diff images - const baselineBase64 = fs.readFileSync(baselineScreenshotPath).toString('base64'); - const diffBase64 = fs.readFileSync(diffImagePath).toString('base64'); - - // Wrap them in the Allure visual diff format - const visualDiffPayload = { - actual: `data:image/png;base64,${elementScreenshotBase64}`, - expected: `data:image/png;base64,${baselineBase64}`, - diff: `data:image/png;base64,${diffBase64}`, - }; - - await pushAttachmentsToReport(testInfo, [ - { - name: 'Visual Comparison', - body: Buffer.from(JSON.stringify(visualDiffPayload), 'utf-8'), - contentType: 'application/vnd.allure.image.diff', - }, - { - name: 'Baseline Screenshot', - body: Buffer.from(baselineBase64, 'base64'), - contentType: 'image/png', - }, - { - name: 'Actual Screenshot', - body: Buffer.from(elementScreenshotBase64, 'base64'), - contentType: 'image/png', - }, - { - name: 'Diff Screenshot', - body: Buffer.from(diffBase64, 'base64'), - contentType: 'image/png', - }, - ]); - console.log(`Visual comparison failed. The diff has been saved to ${diffImagePath}`); - throw new Error(`The UI doesn't match expected appearance`); - } - // Cleanup of element screenshot file on success + const { mssim } = ssim(actualImageData, baselineImageData); + console.log(`SSIM similarity score: ${mssim.toFixed(4)}`); + + if (mssim < threshold) { + // Generate visual diff for debugging + const uuid = uuidv4(); + const diffsDir = getDiffDirectory(); + const actualPath = path.join(diffsDir, `${uuid}_actual.png`); + const diffPath = path.join(diffsDir, `${uuid}_diff.png`); + + fs.writeFileSync(actualPath, actualBuffer); + try { - fs.unlinkSync(elementScreenshotPath); - console.log('Temporary screenshot deleted successfully'); - } catch (err) { - if (err instanceof Error) { - console.error(`Error deleting file: ${err.message}`); + const { diffImage } = await looksSame(actualPath, baselineImagePath, { + createDiffImage: true, + }); + + if (diffImage) { + await diffImage.save(diffPath); + console.log(`Visual diff saved to: ${diffPath}`); } + + // Attach artifacts to report + if (process.env.ALLURE_ENABLED === 'true' && process.env.CI === '1') { + const baselineBase64 = fs.readFileSync(baselineImagePath).toString('base64'); + const diffBase64 = fs.readFileSync(diffPath).toString('base64'); + const actualBase64 = actualBuffer.toString('base64'); + const visualDiffPayload = { + actual: `data:image/png;base64,${actualBase64}`, + expected: `data:image/png;base64,${baselineBase64}`, + diff: `data:image/png;base64,${diffBase64}`, + }; + + await pushAttachmentsToReport(testInfo, [ + { + name: 'Visual Comparison', + body: Buffer.from(JSON.stringify(visualDiffPayload), 'utf-8'), + contentType: 'application/vnd.allure.image.diff', + }, + { + name: 'Baseline Screenshot', + body: Buffer.from(baselineBase64, 'base64'), + contentType: 'image/png', + }, + { + name: 'Actual Screenshot', + body: Buffer.from(actualBase64, 'base64'), + contentType: 'image/png', + }, + { + name: 'Diff Screenshot', + body: Buffer.from(diffBase64, 'base64'), + contentType: 'image/png', + }, + ]); + } + } catch (error) { + console.warn('Error processing visual diff', error); } + + console.log(`SSIM similarity score ${mssim.toFixed(4)} below threshold ${threshold}`); + throw new Error('The observed UI does not match the expected baseline'); + } +} + +/** + * Handles baseline creation for development + */ +function ensureBaseline(actualBuffer: Buffer, baselinePath: string): void { + if (!fs.existsSync(baselinePath)) { + const diffsDir = getDiffDirectory(); + const uuid = uuidv4(); + const tempPath = path.join(diffsDir, `${uuid}_new_baseline.png`); + fs.writeFileSync(tempPath, actualBuffer); + + // Uncomment these lines for local development to auto-create baselines + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }); + fs.writeFileSync(baselinePath, actualBuffer); + + throw new Error( + `No baseline image found at: ${baselinePath}. \n + A new screenshot has been saved at: ${tempPath}` + ); + } +} + +/** + * Takes a full page screenshot and verifies it against a saved baseline image using SSIM. + */ +export async function verifyPageScreenshot( + device: DeviceWrapper, + platform: SupportedPlatformsType, + screenshotName: ScreenshotFileNames, + testInfo: TestInfo, + threshold: number = 0.97 // Strict tolerance by default +): Promise { + // Validate threshold range + if (threshold < 0 || threshold > 1) { + throw new Error(`SSIM threshold must be between 0 and 1, got: ${threshold}`); + } + await setConsistentStatusBar(device); + try { + // Get full page screenshot and crop it + const pageScreenshotBase64 = await device.getScreenshot(); + const screenshotBuffer = Buffer.from(pageScreenshotBase64, 'base64'); + + // Get baseline path and ensure it exists + const baselineScreenshotPath = path.join( + 'run', + 'screenshots', + platform, + `${screenshotName}.png` + ); + ensureBaseline(screenshotBuffer, baselineScreenshotPath); + + // Perform SSIM comparison + await compareWithSSIM(screenshotBuffer, baselineScreenshotPath, testInfo, threshold); + } finally { + await clearStatusBarOverrides(device); } } diff --git a/run/test/specs/visual_message_bubbles.spec.ts b/run/test/specs/visual_message_bubbles.spec.ts new file mode 100644 index 000000000..eb0014939 --- /dev/null +++ b/run/test/specs/visual_message_bubbles.spec.ts @@ -0,0 +1,69 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { + MessageBody, + MessageInput, + OutgoingMessageStatusSent, + SendButton, +} from './locators/conversation'; +import { open_Alice1_Bob1_friends } from './state_builder'; +import { closeApp, SupportedPlatformsType } from './utils/open_app'; +import { verifyPageScreenshot } from './utils/verify_screenshots'; + +bothPlatformsIt({ + title: 'Check message bubble layout', + risk: 'high', + countOfDevicesNeeded: 2, + testCb: messageBubbleAppearance, + allureSuites: { + parent: 'Visual Checks', + suite: 'Conversation', + }, + allureDescription: `Verifies that message bubbles appear as expected (oneline and multiline messages, reply layout, outgoing/incoming)`, +}); + +async function messageBubbleAppearance(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { + devices: { alice1, bob1 }, + prebuilt: { alice, bob }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_friends({ + platform, + focusFriendsConvo: true, + testInfo, + }); + }); + // Alice sends a short message to Bob and Bob replies with a longer message + // This lets us verify four different message bubbles + const shortMessage = 'This is a short message'; + const replyMessage = + 'This is a longer reply message that is likely to span multiple lines which is great for testing'; + + await test.step(TestSteps.SEND.MESSAGE(alice.userName, bob.userName), async () => { + await alice1.sendMessage(shortMessage); + }); + await test.step(TestSteps.SEND.REPLY(bob.userName, alice.userName), async () => { + // Bob replies with a longer message + await bob1.longPressMessage(shortMessage); + await bob1.clickOnByAccessibilityID('Reply to message'); + await bob1.inputText(replyMessage, new MessageInput(bob1)); + await bob1.clickOnElementAll(new SendButton(bob1)); + await bob1.waitForTextElementToBePresent({ + ...new OutgoingMessageStatusSent(bob1).build(), + maxWait: 20_000, + }); + }); + await test.step(TestSteps.VERIFY.SCREENSHOT('conversation screen (Alice)'), async () => { + await alice1.waitForTextElementToBePresent(new MessageBody(alice1, replyMessage)); + await verifyPageScreenshot(alice1, platform, 'conversation_alice', testInfo); + }); + await test.step(TestSteps.VERIFY.SCREENSHOT('conversation screen (Bob)'), async () => { + await bob1.onAndroid().back(); // dismiss keyboard + await verifyPageScreenshot(bob1, platform, 'conversation_bob', testInfo); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} diff --git a/run/test/specs/visual_settings.spec.ts b/run/test/specs/visual_settings.spec.ts new file mode 100644 index 000000000..ac32322a2 --- /dev/null +++ b/run/test/specs/visual_settings.spec.ts @@ -0,0 +1,95 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { TestSteps } from '../../types/allure'; +import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { NotificationsMenuItem } from './locators/settings'; +import { + AppearanceMenuItem, + ConversationsMenuItem, + PrivacyMenuItem, + UserSettings, +} from './locators/settings'; +import { sleepFor } from './utils'; +import { newUser } from './utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; +import { verifyPageScreenshot } from './utils/verify_screenshots'; + +const testCases = [ + { + screenName: 'Settings page', + screenshotFile: 'settings', + navigation: async (device: DeviceWrapper) => { + await device.clickOnElementAll(new UserSettings(device)); + }, + }, + { + screenName: 'Privacy settings', + screenshotFile: 'settings_privacy', + navigation: async (device: DeviceWrapper) => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new PrivacyMenuItem(device)); + }, + }, + { + screenName: 'Conversations settings', + screenshotFile: 'settings_conversations', + navigation: async (device: DeviceWrapper) => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new ConversationsMenuItem(device)); + }, + }, + { + screenName: 'Notifications settings', + screenshotFile: 'settings_notifications', + navigation: async (device: DeviceWrapper) => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new NotificationsMenuItem(device)); + await sleepFor(1_000); // This one otherwise captures a black screen + }, + }, + { + screenName: 'Appearance settings', + screenshotFile: 'settings_appearance', + navigation: async (device: DeviceWrapper) => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new AppearanceMenuItem(device)); + }, + }, +] as const; + +for (const { screenName, screenshotFile, navigation } of testCases) { + bothPlatformsIt({ + title: `Check ${screenName} layout`, + risk: 'high', + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Visual Checks', + suite: 'Settings', + }, + allureDescription: `Verifies that the ${screenName} screen layout matches the expected baseline`, + testCb: async (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, + allowNotificationPermissions: false, + }); + return { device }; + }); + + await test.step(TestSteps.OPEN.GENERIC(screenName), async () => { + await navigation(device); + }); + + await test.step(TestSteps.VERIFY.SCREENSHOT(screenName), async () => { + await verifyPageScreenshot(device, platform, screenshotFile, testInfo, 0.96); + }); + + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); + }, + }); +} diff --git a/run/test/specs/voice_calls.spec.ts b/run/test/specs/voice_calls.spec.ts index 0df4e28b6..300c563b5 100644 --- a/run/test/specs/voice_calls.spec.ts +++ b/run/test/specs/voice_calls.spec.ts @@ -4,7 +4,7 @@ import { englishStrippedStr } from '../../localizer/englishStrippedStr'; import { TestSteps } from '../../types/allure'; import { bothPlatformsItSeparate } from '../../types/sessionIt'; import { CloseSettings } from './locators'; -import { CallButton, NotificationSettings, NotificationSwitch } from './locators/conversation'; +import { CallButton, NotificationsModalButton, NotificationSwitch } from './locators/conversation'; import { open_Alice1_Bob1_friends } from './state_builder'; import { sleepFor } from './utils/index'; import { closeApp, SupportedPlatformsType } from './utils/open_app'; @@ -194,7 +194,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test englishStrippedStr('sessionNotifications').toString(), englishStrippedStr('callsNotificationsRequired').toString() ); - await alice1.clickOnElementAll(new NotificationSettings(alice1)); + await alice1.clickOnElementAll(new NotificationsModalButton(alice1)); await alice1.clickOnElementAll(new NotificationSwitch(alice1)); }); await alice1.navigateBack(false); @@ -241,7 +241,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test await bob1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' ); - await bob1.clickOnElementAll(new NotificationSettings(bob1)); + await bob1.clickOnElementAll(new NotificationsModalButton(bob1)); await bob1.clickOnElementAll(new NotificationSwitch(bob1)); await bob1.navigateBack(false); await bob1.navigateBack(false); diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 201f5cac3..6b5dd0583 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -333,6 +333,7 @@ export class DeviceWrapper { { from: 'Voice message', to: 'New voice message' }, { from: 'Message sent status: Sent', to: 'Message sent status: Sending' }, { from: 'Done', to: 'Donate' }, + { from: 'New conversation button', to: 'conversation-options-avatar' }, ]; // System locators such as 'network.loki.messenger.qa:id' can cause false positives with too high similarity scores @@ -1641,7 +1642,8 @@ export class DeviceWrapper { public async waitForElementColorMatch( args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj), - expectedColor: string + expectedColor: string, + tolerance?: number ): Promise { const locator = args instanceof LocatorsInterface ? args.build() : args; const description = describeLocator({ ...locator, text: args.text }); @@ -1661,7 +1663,7 @@ export class DeviceWrapper { const base64 = await this.getElementScreenshot(element.ELEMENT); const actualColor = await parseDataImage(base64); - const matches = isSameColor(expectedColor, actualColor); + const matches = isSameColor(expectedColor, actualColor, tolerance); return { success: matches, @@ -2308,20 +2310,9 @@ export class DeviceWrapper { this.info('Scroll button not found, continuing'); } } - public async pullToRefresh() { - if (this.isAndroid()) { - await this.pressCoordinates( - InteractionPoints.NetworkPageAndroid.x, - InteractionPoints.NetworkPageAndroid.y, - true - ); - } else { - await this.pressCoordinates( - InteractionPoints.NetworkPageIOS.x, - InteractionPoints.NetworkPageIOS.y, - true - ); - } + public async pullToRefresh(): Promise { + const { width, height } = await this.getWindowRect(); + await this.scroll({ x: width / 2, y: height * 0.15 }, { x: width / 2, y: height * 0.55 }, 200); } public async navigateBack(newAndroid: boolean = true) { @@ -2375,7 +2366,7 @@ export class DeviceWrapper { if (this.isAndroid()) { const permissions = await this.doesElementExist({ ...locatorConfig, - maxWait: 2_000, + maxWait: 5_000, }); if (permissions) { diff --git a/run/types/allure.ts b/run/types/allure.ts index 606b5f40b..c9d5cb235 100644 --- a/run/types/allure.ts +++ b/run/types/allure.ts @@ -21,17 +21,19 @@ import { UserNameType } from '@session-foundation/qa-seeder'; export type AllureSuiteConfig = | { parent: 'Disappearing Messages'; suite: 'Conversation Types' | 'Message Types' | 'Rules' } - | { parent: 'Groups'; suite: 'Edit Group' } + | { parent: 'Groups'; suite: 'Create Group' | 'Edit Group' | 'Leave/Delete Group' } | { parent: 'In-App Review Prompt'; suite: 'Flows' | 'Triggers' } | { parent: 'Linkouts' } - | { parent: 'New Conversation'; suite: 'Join Community' | 'New Message' } - | { parent: 'Sending Messages'; suite: 'Attachments' | 'Emoji reacts' } - | { parent: 'Settings'; suite: 'App Disguise' } + | { parent: 'Network Page' } + | { parent: 'New Conversation'; suite: 'Invite a Friend' | 'Join Community' | 'New Message' } + | { parent: 'Sending Messages'; suite: 'Emoji reacts' | 'Message types' | 'Rules' } + | { parent: 'Settings'; suite: 'App Disguise' | 'Community Message Requests' } | { parent: 'User Actions'; suite: | 'Block/Unblock' | 'Change Profile Picture' + | 'Change Username' | 'Delete Contact' | 'Delete Conversation' | 'Delete Message' @@ -39,7 +41,7 @@ export type AllureSuiteConfig = | 'Set Nickname' | 'Share to Session'; } - | { parent: 'Visual Checks' } + | { parent: 'Visual Checks'; suite: 'Conversation' | 'Onboarding' | 'Settings' } | { parent: 'Voice Calls' }; /** @@ -71,12 +73,14 @@ export const TestSteps = { SEND: { MESSAGE: (sender: UserNameType, recipient: string) => `${sender} sends a message to ${recipient}`, + REPLY: (sender: UserNameType, recipient: string) => `${sender} replies to ${recipient}`, LINK: 'Send Link', IMAGE: 'Send Image', EMOJI_REACT: `Send an emoji react`, }, // Open/Navigate steps OPEN: { + GENERIC: (string: string) => `Open ${string}`, NTS: 'Open Note to Self', UPDATE_GROUP_INFO: `Open 'Update Group Information' modal`, PATH: 'Open Path screen', @@ -98,8 +102,7 @@ export const TestSteps = { }, // Verify steps VERIFY: { - ELEMENT_SCREENSHOT: (elementDesc: string) => - `Verify ${elementDesc} element screenshot matches baseline`, + SCREENSHOT: (desc: string) => `Verify ${desc} screenshot matches baseline`, GENERIC_MODAL: 'Verify modal strings', SPECIFIC_MODAL: (modalDesc: string) => `Verify ${modalDesc} modal strings`, MESSAGE_SYNCED: 'Verify message synced to linked device', diff --git a/run/types/testing.ts b/run/types/testing.ts index 0938cb918..b1e78dda5 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -52,8 +52,6 @@ export const InteractionPoints: Record = { GifButtonKeyboardClosed: { x: 36, y: 689 }, DocumentKeyboardOpen: { x: 36, y: 476 }, DocumentKeyboardClosed: { x: 36, y: 740 }, - NetworkPageAndroid: { x: 880, y: 1150 }, - NetworkPageIOS: { x: 308, y: 220 }, BackToSession: { x: 42, y: 42 }, }; @@ -134,20 +132,25 @@ export type XPath = | `//*[./*[@name='${DISAPPEARING_TIMES}']]/*[2]` | `//*[@resource-id='network.loki.messenger.qa:id/callTitle' and contains(@text, ':')]` | `//*[starts-with(@content-desc, "Photo taken on")]` + | `//android.view.ViewGroup[@resource-id='network.loki.messenger.qa:id/mainContainer'][.//android.widget.TextView[contains(@text,'${string}')]]//androidx.compose.ui.platform.ComposeView[@resource-id='network.loki.messenger.qa:id/profilePictureView']` | `//android.view.ViewGroup[@resource-id="network.loki.messenger.qa:id/mainContainer"][.//android.widget.TextView[contains(@text,"${string}")]]//android.view.ViewGroup[@resource-id="network.loki.messenger.qa:id/layout_emoji_container"]` | `//android.view.ViewGroup[@resource-id="network.loki.messenger.qa:id/mainContainer"][.//android.widget.TextView[contains(@text,"${string}")]]//android.widget.TextView[@resource-id="network.loki.messenger.qa:id/reactions_pill_count"][@text="${string}"]` | `//android.widget.LinearLayout[.//android.widget.TextView[@content-desc="Conversation list item" and @text="${string}"]]//android.widget.TextView[@resource-id="network.loki.messenger.qa:id/snippetTextView" and @text="${string}"]` | `//android.widget.TextView[@text="${string}"]` + | `//android.widget.TextView[@text="Message"]/parent::android.view.View` | `//XCUIElementTypeAlert//*//XCUIElementTypeButton` | `//XCUIElementTypeButton[@name="Continue"]` + | `//XCUIElementTypeButton[@name="Okay"]` | `//XCUIElementTypeButton[@name="Settings"]` | `//XCUIElementTypeCell[.//XCUIElementTypeOther[@label="${string}"]]//XCUIElementTypeStaticText[@value="😂"]` | `//XCUIElementTypeCell[.//XCUIElementTypeOther[@label="${string}"]]//XCUIElementTypeStaticText[@value="${string}"]` + | `//XCUIElementTypeCell[.//XCUIElementTypeOther[@name='Message body' and contains(@label,'${string}')]]//XCUIElementTypeStaticText[contains(@value,'(15')]` | `//XCUIElementTypeCell[@name="${string}"]` | `//XCUIElementTypeCell[@name="Conversation list item" and @label="${string}"]//XCUIElementTypeStaticText[@name="${string}"]` | `//XCUIElementTypeCell[@name="Session"]` | `//XCUIElementTypeImage` | `//XCUIElementTypeOther[contains(@name, "Hey,")][1]` + | `//XCUIElementTypeStaticText[@name="${string}"]` | `//XCUIElementTypeStaticText[@name="Paste"]` | `//XCUIElementTypeStaticText[@name="Videos"]` | `//XCUIElementTypeStaticText[contains(@name, '00:')]` @@ -195,6 +198,7 @@ export type AccessibilityId = | 'Awaiting Recipient Answer... 4/6' | 'back' | 'Back' + | 'Blinded ID' | 'Block' | 'Block contacts - Navigation' | 'blocked-banner' @@ -210,6 +214,7 @@ export type AccessibilityId = | 'Close' | 'Close button' | 'Community invitation' + | 'Community Message Requests' | 'Configuration message' | 'Confirm' | 'Confirm block' @@ -238,6 +243,7 @@ export type AccessibilityId = | 'Deleted message' | 'Delete for everyone' | 'Delete for me' + | 'Delete group' | 'Delete just for me' | 'Delete message' | 'Delete message request' @@ -307,10 +313,12 @@ export type AccessibilityId = | 'Loading animation' | 'Local Network Permission - Switch' | 'Manage Members' + | 'Market cap amount' | 'Media message' | 'MeetingSE' | 'Meetings option' | 'Mentions list' + | 'Message' | 'Message body' | 'Message composition' | 'Message input box' @@ -377,6 +385,7 @@ export type AccessibilityId = | 'Select alternate app icon' | 'Send' | 'Send message button' + | 'SENT price' | 'Session' | 'Session | Send Messages, Not Metadata. | Private Messenger' | 'Session ID generated' @@ -393,6 +402,7 @@ export type AccessibilityId = | 'Show roots' | 'Slow mode notifications button' | 'space' + | 'Staking reward pool amount' | 'TabBarItemTitle' | 'Terms of Service' | 'test_file, pdf' @@ -418,6 +428,7 @@ export type AccessibilityId = export type Id = | DISAPPEARING_TIMES + | 'account-id' | 'Account ID' | 'android:id/aerr_close' | 'android:id/aerr_wait' @@ -461,6 +472,8 @@ export type Id = | 'delete-conversation-confirm-button' | 'delete-conversation-menu-option' | 'delete-for-everyone' + | 'delete-group-confirm-button' + | 'delete-group-menu-option' | 'delete-only-on-this-device' | 'Delete' | 'Disable disappearing messages' @@ -497,6 +510,7 @@ export type Id = | 'Leave' | 'Loading animation' | 'manage-members-menu-option' + | 'Market cap amount' | 'MeetingSE option' | 'Modal description' | 'Modal heading' @@ -508,6 +522,7 @@ export type Id = | 'network.loki.messenger.qa:id/callInProgress' | 'network.loki.messenger.qa:id/callSubtitle' | 'network.loki.messenger.qa:id/callTitle' + | 'network.loki.messenger.qa:id/characterLimitText' | 'network.loki.messenger.qa:id/crop_image_menu_crop' | 'network.loki.messenger.qa:id/emptyStateContainer' | 'network.loki.messenger.qa:id/endCallButton' @@ -535,6 +550,7 @@ export type Id = | 'nickname-input' | 'not-now-button' | 'Notifications' + | 'Okay' | 'open-survey-button' | 'Open' | 'Open URL' @@ -553,6 +569,7 @@ export type Id = | 'Reveal recovery phrase button' | 'Save' | 'Select All' + | 'SESH price' | 'session-network-menu-item' | 'Session id input box' | 'set-nickname-confirm-button' @@ -561,6 +578,7 @@ export type Id = | 'show-nts-confirm-button' | 'Show' | 'Slow mode notifications button' + | 'Staking reward pool amount' | 'Terms of Service' | 'update-group-info-confirm-button' | 'update-group-info-description-input' @@ -574,8 +592,17 @@ export type Id = export type TestRisk = 'high' | 'low' | 'medium'; -export type ElementStates = 'new_account' | 'restore_account'; - -export type Suffix = 'diff' | 'screenshot'; - export type AppName = 'Session AQA' | 'Session QA'; + +export type ScreenshotFileNames = + | 'app_disguise' + | 'conversation_alice' + | 'conversation_bob' + | 'landingpage_new_account' + | 'landingpage_restore_account' + | 'settings_appearance' + | 'settings_conversations' + | 'settings_notifications' + | 'settings_privacy' + | 'settings' + | 'upm_home'; diff --git a/yarn.lock b/yarn.lock index 82c08a783..63ede4e6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7250,6 +7250,7 @@ __metadata: prettier: "npm:^3.3.3" sharp: "npm:^0.34.2" sinon: "npm:^19.0.2" + ssim.js: "npm:^3.5.0" sync-request-curl: "npm:^3.3.3" ts-node: "npm:^10.9.1" typescript: "npm:^5.6.3" @@ -7632,6 +7633,13 @@ __metadata: languageName: node linkType: hard +"ssim.js@npm:^3.5.0": + version: 3.5.0 + resolution: "ssim.js@npm:3.5.0" + checksum: 10c0/9e7101da17395d3acbd417aac712d8f156522e79059a27cb38882eedd5a8868e31871c8f58bec3a150f8cf0660883cf22310cbd2fd63b408c1fd0ab02fda9fbc + languageName: node + linkType: hard + "ssri@npm:^10.0.0": version: 10.0.6 resolution: "ssri@npm:10.0.6"