diff --git a/tests/automation/delete_account.spec.ts b/tests/automation/delete_account.spec.ts index fa0a372..d56da62 100644 --- a/tests/automation/delete_account.spec.ts +++ b/tests/automation/delete_account.spec.ts @@ -60,7 +60,10 @@ sessionTestTwoWindows( windowA, englishStrippedStr('clear').toString(), ); - await waitForLoadingAnimationToFinish(windowA, 'loading-spinner'); + await waitForLoadingAnimationToFinish( + windowA, + Global.loadingSpinner.selector, + ); // await sleepFor(7500); // Wait for window to close and reopen diff --git a/tests/automation/enforce_localized_str.spec.ts b/tests/automation/enforce_localized_str.spec.ts index 98f7a3b..9e2da46 100644 --- a/tests/automation/enforce_localized_str.spec.ts +++ b/tests/automation/enforce_localized_str.spec.ts @@ -263,6 +263,26 @@ function getExpectedStringFromKey( return 'Message Too Long'; case 'modalMessageTooLongDescription': return 'Please shorten your message to {limit} characters or less.'; + case 'groupDelete': + return 'Delete Group'; + case 'groupDeleteDescription': + return 'Are you sure you want to delete {group_name}? This will remove all members and delete all group content.'; + case 'groupDeletedMemberDescription': + return '{group_name} has been deleted by a group admin. You will not be able to send any more messages.'; + case 'urlOpen': + return 'Open URL'; + case 'urlOpenDescription': + return 'Are you sure you want to open this URL in your browser? {url}'; + case 'updated': + return 'Last updated {relative_time} ago'; + case 'warning': + return 'Warning'; + case 'onboardingBackAccountCreation': + return 'You cannot go back further. In order to cancel your account creation, Session needs to quit.'; + case 'onboardingBackLoadAccount': + return 'You cannot go back further. In order to stop loading your account, Session needs to quit.'; + case 'quitButton': + return 'Quit'; default: // returning null means we don't have an expected string yet for this key. // This will make the test fail diff --git a/tests/automation/linked_device_group.spec.ts b/tests/automation/linked_device_group.spec.ts index 73587ce..1d14063 100644 --- a/tests/automation/linked_device_group.spec.ts +++ b/tests/automation/linked_device_group.spec.ts @@ -21,6 +21,7 @@ import { clickOn, clickOnMatchingText, clickOnWithText, + hasElementBeenDeleted, waitForTestIdWithText, } from './utilities/utils'; @@ -311,3 +312,48 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ]); }, ); + +test_group_Alice_2W_Bob_1W_Charlie_1W( + 'Delete group linked device', + async ({ + aliceWindow1, + aliceWindow2, + bobWindow1, + charlieWindow1, + groupCreated, + }) => { + await clickOn(aliceWindow1, Conversation.conversationSettingsIcon); + await clickOn(aliceWindow1, ConversationSettings.leaveOrDeleteGroupOption); + await checkModalStrings( + aliceWindow1, + englishStrippedStr('groupDelete').toString(), + englishStrippedStr('groupDeleteDescription') + .withArgs({ group_name: groupCreated.userName }) + .toString(), + 'confirmModal', + ); + await clickOn(aliceWindow1, Global.confirmButton); + await Promise.all( + [bobWindow1, charlieWindow1].map(async (w) => { + await waitForTestIdWithText( + w, + 'empty-conversation-control-message', + englishStrippedStr('groupDeletedMemberDescription') + .withArgs({ group_name: groupCreated.userName }) + .toString(), + ); + }), + ); + await Promise.all( + [aliceWindow1, aliceWindow2].map(async (w) => { + await hasElementBeenDeleted( + w, + 'data-testid', + HomeScreen.conversationItemName.selector, + 10_000, + groupCreated.userName, + ); + }), + ); + }, +); diff --git a/tests/automation/linked_device_user.spec.ts b/tests/automation/linked_device_user.spec.ts index 7714017..a7e4bfc 100644 --- a/tests/automation/linked_device_user.spec.ts +++ b/tests/automation/linked_device_user.spec.ts @@ -146,7 +146,10 @@ test_Alice_2W( // allow for the image to be resized before we try to save it await sleepFor(500); await clickOn(aliceWindow1, Settings.saveProfileUpdateButton); - await waitForLoadingAnimationToFinish(aliceWindow1, 'loading-spinner'); + await waitForLoadingAnimationToFinish( + aliceWindow1, + Global.loadingSpinner.selector, + ); await clickOnMatchingText( aliceWindow1, englishStrippedStr('save').toString(), diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index 5279174..a854361 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -121,6 +121,7 @@ export class ConversationSettings extends Locator { static readonly inviteContactsOption = this.testId( 'invite-contacts-menu-option', ); + static readonly leaveOrDeleteGroupOption = this.testId('leave-group-button'); static readonly manageMembersOption = this.testId( 'manage-members-menu-option', ); @@ -149,10 +150,25 @@ export class Settings extends Locator { static readonly messageRequestsMenuItem = this.testId( 'message-requests-settings-menu-item', ); + static readonly networkPageMenuItem = this.testId( + 'session-network-settings-menu-item', + ); static readonly privacyMenuItem = this.testId('privacy-settings-menu-item'); static readonly recoveryPasswordMenuItem = this.testId( 'recovery-password-settings-menu-item', ); + // Session Network + static readonly lastUpdatedTimestamp = this.testId('last-updated-timestamp'); + static readonly learnMoreAboutStakingLink = this.testId( + 'learn-about-staking-link', + ); + static readonly learnMoreNetworkLink = this.testId('learn-more-network-link'); + static readonly marketCapAmount = this.testId('market-cap-amount'); + static readonly refreshButton = this.testId('refresh-button'); + static readonly seshPrice = this.testId('sent-price'); + static readonly stakingRewardPoolAmount = this.testId( + 'staking-reward-pool-amount', + ); // Privacy static readonly changePasswordSettingsButton = this.testId( 'change-password-settings-button', @@ -207,6 +223,7 @@ export class Global extends Locator { static readonly contextMenuItem = this.testId('context-menu-item'); static readonly continueButton = this.testId('continue-button'); static readonly errorMessage = this.testId('error-message'); + static readonly loadingSpinner = this.testId('loading-spinner'); static readonly modalBackButton = this.testId('modal-back-button'); static readonly modalCloseButton = this.testId('modal-close-button'); static readonly toast = this.testId('session-toast'); diff --git a/tests/automation/network_page.spec.ts b/tests/automation/network_page.spec.ts new file mode 100644 index 0000000..a2a0265 --- /dev/null +++ b/tests/automation/network_page.spec.ts @@ -0,0 +1,112 @@ +import { englishStrippedStr } from '../localization/englishStrippedStr'; +import { sleepFor } from '../promise_utils'; +import { Global, LeftPane, Settings } from './locators'; +import { test_Alice_1W } from './setup/sessionTest'; +import { validateNetworkData } from './utilities/network_api'; +import { + assertUrlIsReachable, + checkModalStrings, + clickOn, + waitForLoadingAnimationToFinish, + waitForTestIdWithText, +} from './utilities/utils'; + +test_Alice_1W('Network page values', async ({ aliceWindow1 }) => { + await clickOn(aliceWindow1, LeftPane.settingsButton); + await clickOn(aliceWindow1, Settings.networkPageMenuItem); + + const response = await fetch('http://networkv1.getsession.org/info'); + if (!response.ok) { + throw new Error(`Network API returned ${response.status}`); + } + const data = await response.json(); + validateNetworkData(data); + + // SESH Price - 2 decimals "$1.23 USD" + const seshPrice = `$${data.price.usd.toFixed(2)} USD`; + + // Staking Reward Pool - whole number with commas "1,234,567 SESH" + const stakingRewardPool = `${data.token.staking_reward_pool.toLocaleString( + 'en-US', + )} SESH`; + + // Market Cap - round to whole number with commas, "$1,234,567 USD" + const marketCap = `$${Math.round(data.price.usd_market_cap).toLocaleString( + 'en-US', + )} USD`; + + await waitForTestIdWithText( + aliceWindow1, + Settings.seshPrice.selector, + seshPrice, + ); + await waitForTestIdWithText( + aliceWindow1, + Settings.stakingRewardPoolAmount.selector, + stakingRewardPool, + ); + await waitForTestIdWithText( + aliceWindow1, + Settings.marketCapAmount.selector, + marketCap, + ); +}); + +test_Alice_1W('Network page network link', async ({ aliceWindow1 }) => { + const url = 'https://docs.getsession.org/session-network'; + await clickOn(aliceWindow1, LeftPane.settingsButton); + await clickOn(aliceWindow1, Settings.networkPageMenuItem); + await clickOn(aliceWindow1, Settings.learnMoreNetworkLink); + await checkModalStrings( + aliceWindow1, + englishStrippedStr('urlOpen').toString(), + englishStrippedStr('urlOpenDescription').withArgs({ url }).toString(), + 'openUrlModal', + ); + await assertUrlIsReachable(url); +}); + +test_Alice_1W('Network page staking link', async ({ aliceWindow1 }) => { + const url = 'https://docs.getsession.org/session-network/staking'; + await clickOn(aliceWindow1, LeftPane.settingsButton); + await clickOn(aliceWindow1, Settings.networkPageMenuItem); + await clickOn(aliceWindow1, Settings.learnMoreAboutStakingLink); + await checkModalStrings( + aliceWindow1, + englishStrippedStr('urlOpen').toString(), + englishStrippedStr('urlOpenDescription').withArgs({ url }).toString(), + 'openUrlModal', + ); + await assertUrlIsReachable(url); +}); + +test_Alice_1W('Network page refresh', async ({ aliceWindow1 }) => { + const zeroMinAgoText = englishStrippedStr('updated') + .withArgs({ relative_time: '0m' }) + .toString(); + const oneMinAgoText = englishStrippedStr('updated') + .withArgs({ relative_time: '1m' }) + .toString(); + await clickOn(aliceWindow1, LeftPane.settingsButton); + await clickOn(aliceWindow1, Settings.networkPageMenuItem); + await waitForLoadingAnimationToFinish( + aliceWindow1, + Global.loadingSpinner.selector, + ); + await sleepFor(65_000); // Wait 60+5 seconds to ensure timestamp changes to "1m ago" + await waitForTestIdWithText( + aliceWindow1, + Settings.lastUpdatedTimestamp.selector, + oneMinAgoText, + ); + await clickOn(aliceWindow1, Settings.refreshButton); + await waitForLoadingAnimationToFinish( + aliceWindow1, + Global.loadingSpinner.selector, + ); + await waitForTestIdWithText( + aliceWindow1, + Settings.lastUpdatedTimestamp.selector, + zeroMinAgoText, + ); +}); diff --git a/tests/automation/onboarding.spec.ts b/tests/automation/onboarding.spec.ts new file mode 100644 index 0000000..a7213fb --- /dev/null +++ b/tests/automation/onboarding.spec.ts @@ -0,0 +1,60 @@ +import { englishStrippedStr } from '../localization/englishStrippedStr'; +import { Global, Onboarding } from './locators'; +import { sessionTestOneWindow } from './setup/sessionTest'; +import { + checkModalStrings, + clickOn, + clickOnWithText, + typeIntoInput, +} from './utilities/utils'; + +sessionTestOneWindow('Warning modal new account', async ([aliceWindow1]) => { + await clickOn(aliceWindow1, Onboarding.createAccountButton); + await clickOn(aliceWindow1, Global.backButton); + await checkModalStrings( + aliceWindow1, + englishStrippedStr('warning').toString(), + englishStrippedStr('onboardingBackAccountCreation').toString(), + 'confirmModal', + ); + await clickOnWithText( + aliceWindow1, + Global.confirmButton, + englishStrippedStr('quitButton').toString(), + ); + // Wait for window to close (confirms restart was triggered) + await aliceWindow1.waitForEvent('close', { timeout: 5000 }); + + // Test ends - app is restarting but we can't verify the aftermath + // Playwright cannot keep track of Electron's `window.restart` IPC call + // so this will have to do +}); + +sessionTestOneWindow( + 'Warning modal restore account', + async ([aliceWindow1]) => { + const seedPhrase = + 'eldest fazed hybrid buzzer nasty domestic digit pager unusual purged makeup assorted domestic'; + await clickOn(aliceWindow1, Onboarding.iHaveAnAccountButton); + await typeIntoInput(aliceWindow1, 'recovery-phrase-input', seedPhrase); + await clickOn(aliceWindow1, Global.continueButton); + await clickOn(aliceWindow1, Global.backButton); + await checkModalStrings( + aliceWindow1, + englishStrippedStr('warning').toString(), + englishStrippedStr('onboardingBackLoadAccount').toString(), + 'confirmModal', + ); + await clickOnWithText( + aliceWindow1, + Global.confirmButton, + englishStrippedStr('quitButton').toString(), + ); + // Wait for window to close (confirms restart was triggered) + await aliceWindow1.waitForEvent('close', { timeout: 5000 }); + + // Test ends - app is restarting but we can't verify the aftermath + // Playwright cannot keep track of Electron's `window.restart` IPC call + // so this will have to do + }, +); diff --git a/tests/automation/setup/closeWindows.ts b/tests/automation/setup/closeWindows.ts index 357cd79..3a69fbc 100644 --- a/tests/automation/setup/closeWindows.ts +++ b/tests/automation/setup/closeWindows.ts @@ -1,10 +1,26 @@ import { Page } from '@playwright/test'; +import { execSync } from 'child_process'; import { sleepFor } from '../../promise_utils'; +import { getTrackedElectronPids } from './open'; export const forceCloseAllWindows = async (windows: Array) => { - return Promise.race([ + await Promise.race([ Promise.all(windows.map((w) => w.close())), - async () => sleepFor(4000), + sleepFor(4000), ]); + + // Also kill child processes + const pids = getTrackedElectronPids(); + pids.forEach((pid) => { + try { + const killCommand = + process.platform === 'win32' + ? `taskkill /F /T /PID ${pid}` // /T kills child processes on Windows + : `pkill -9 -P ${pid}; kill -9 ${pid}`; // Kill children then parent on Unix + execSync(killCommand, { stdio: 'ignore' }); + } catch (e) { + // This is fine - process already dead or doesn't exist + } + }); }; diff --git a/tests/automation/setup/open.ts b/tests/automation/setup/open.ts index 4f98ba4..dfb42ab 100644 --- a/tests/automation/setup/open.ts +++ b/tests/automation/setup/open.ts @@ -7,6 +7,7 @@ import { v4 } from 'uuid'; export const NODE_ENV = 'production'; export const MULTI_PREFIX = 'test-integration-'; const multisAvailable = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +let electronPids: Array = []; export function getAppRootPath() { if (isEmpty(process.env.SESSION_DESKTOP_ROOT)) { @@ -40,6 +41,17 @@ const openElectronAppOnly = async (multi: string) => { '--force-device-scale-factor=1', // Normalizes Retina and non-Retina mac screens ], }); + + // When a test closes a window on purpose, + // the restarted app is considered a child process of the original electronApp. + // However Playwright only tracks the original processes. + // In order to close all Electron windows during teardown + // we need to keep track of the opened PIDs. + const pid = electronApp.process()?.pid; + if (pid) { + electronPids.push(pid); + } + return electronApp; } catch (e) { console.info( @@ -92,3 +104,11 @@ export async function openApp(windowsToCreate: number) { ); return toRet; } + +export function getTrackedElectronPids(): Array { + return electronPids; +} + +export function resetTrackedElectronPids() { + electronPids = []; +} diff --git a/tests/automation/setup/sessionTest.ts b/tests/automation/setup/sessionTest.ts index 3b4e2c2..b4116f9 100644 --- a/tests/automation/setup/sessionTest.ts +++ b/tests/automation/setup/sessionTest.ts @@ -10,7 +10,7 @@ import { linkedDevice } from '../utilities/linked_device'; import { forceCloseAllWindows } from './closeWindows'; import { createGroup } from './create_group'; import { newUser } from './new_user'; -import { openApp } from './open'; +import { openApp, resetTrackedElectronPids } from './open'; // This is not ideal, most of our test needs to open a specific number of windows and close them once the test is done or failed. // This file contains a bunch of utility function to use to open those windows and clean them afterwards. @@ -46,6 +46,7 @@ function sessionTest>( count: T, ) { return test(testName, async ({}, testinfo) => { + resetTrackedElectronPids(); const windows = await openApp(count); try { @@ -224,6 +225,29 @@ export function test_Alice_1W_no_network( ); } +export function test_Alice_1W( + testname: string, + testCallback: ( + details: WithAlice & WithAliceWindow1, + testInfo: TestInfo, + ) => Promise, +) { + return sessionTestGeneric( + testname, + 1, + { waitForNetwork: true }, + ({ mainWindows, users }, testInfo) => { + return testCallback( + { + alice: users[0], + aliceWindow1: mainWindows[0], + }, + testInfo, + ); + }, + ); +} + /** * Setup the test with 1 user and 2 windows total: * - Alice with 2 windows. diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index a2cad1d..1014127 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -72,7 +72,6 @@ export type WithPage = { window: Page }; export type WithMaxWait = { maxWait?: number }; export type WithRightButton = { rightButton?: boolean }; -export type LoaderType = 'loading-animation' | 'loading-spinner'; export type MediaType = 'audio' | 'file' | 'image' | 'video'; export type Strategy = ':has-text' | 'class' | 'data-testid'; @@ -118,6 +117,7 @@ export type DataTestId = | 'dropdownitem-5-seconds' | 'edit-group-name' | 'edit-profile-icon' + | 'empty-conversation-control-message' | 'empty-conversation-notification' | 'enable-calls-settings-row' | 'enable-communities-message-requests-settings-row' @@ -137,13 +137,18 @@ export type DataTestId = | 'join-community-button' | 'join-community-conversation' | 'label-device_and_network' + | 'last-updated-timestamp' + | 'learn-about-staking-link' + | 'learn-more-network-link' | 'leave-group-button' | 'leftpane-primary-avatar' | 'link-device' | 'link-preview-image' | 'link-preview-title' + | 'loading-animation' | 'loading-spinner' | 'manage-members-menu-option' + | 'market-cap-amount' | 'mentions-popup-row' | 'message-content' | 'message-input-text-area' @@ -172,20 +177,24 @@ export type DataTestId = | 'recovery-password-seed-modal' | 'recovery-password-settings-menu-item' | 'recovery-phrase-input' + | 'refresh-button' | 'restore-using-recovery' | 'reveal-recovery-phrase' | 'save-button-profile-update' | 'scroll-to-bottom-button' | 'send-message-button' + | 'sent-price' | 'session-confirm-cancel-button' | 'session-confirm-ok-button' | 'session-id-signup' + | 'session-network-settings-menu-item' | 'session-recovery-password' | 'session-toast' | 'set-nickname-confirm-button' | 'set-password-button' | 'set-password-settings-button' | 'settings-section' + | 'staking-reward-pool-amount' | 'theme-section' | 'tooltip-character-count' | 'unblock-button-settings-screen' @@ -200,4 +209,5 @@ export type ModalId = | 'confirmModal' | 'deleteAccountModal' | 'hideRecoveryPasswordModal' + | 'openUrlModal' | 'userSettingsModal'; diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index 4347e34..b676cd7 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -183,7 +183,10 @@ test_Alice_1W_no_network( // allow for the image to be resized before we try to save it await sleepFor(500); await clickOn(aliceWindow1, Settings.saveProfileUpdateButton); - await waitForLoadingAnimationToFinish(aliceWindow1, 'loading-spinner'); + await waitForLoadingAnimationToFinish( + aliceWindow1, + Global.loadingSpinner.selector, + ); await clickOnMatchingText( aliceWindow1, englishStrippedStr('save').toString(), diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index d6dd074..c8f96c6 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; import { testCommunityLink } from '../constants/community'; -import { HomeScreen } from '../locators'; +import { Global, HomeScreen } from '../locators'; import { clickOn, typeIntoInput, @@ -18,5 +18,5 @@ export const joinCommunity = async (window: Page) => { testCommunityLink, ); await clickOn(window, HomeScreen.joinCommunityButton); - await waitForLoadingAnimationToFinish(window, 'loading-spinner'); + await waitForLoadingAnimationToFinish(window, Global.loadingSpinner.selector); }; diff --git a/tests/automation/utilities/network_api.ts b/tests/automation/utilities/network_api.ts new file mode 100644 index 0000000..e6330d5 --- /dev/null +++ b/tests/automation/utilities/network_api.ts @@ -0,0 +1,36 @@ +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/tests/automation/utilities/send_media.ts b/tests/automation/utilities/send_media.ts index a9ce17f..ccc7822 100644 --- a/tests/automation/utilities/send_media.ts +++ b/tests/automation/utilities/send_media.ts @@ -93,7 +93,7 @@ export const sendLinkPreview = async (window: Page, testLink: string) => { Global.confirmButton, englishStrippedStr('enable').toString(), ); - await waitForLoadingAnimationToFinish(window, 'loading-spinner'); + await waitForLoadingAnimationToFinish(window, Global.loadingSpinner.selector); await waitForTestIdWithText(window, 'link-preview-image'); await waitForTestIdWithText( window, diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 78e53ad..343cbbc 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -8,7 +8,6 @@ import { sleepFor } from '../../promise_utils'; import { DataTestId, DMTimeOption, - LoaderType, ModalId, Strategy, StrategyExtractionObj, @@ -168,7 +167,7 @@ export async function waitForMatchingPlaceholder( } export async function waitForLoadingAnimationToFinish( window: Page, - loader: LoaderType, + loader: DataTestId, maxWait?: number, ) { let loadingAnimation: ElementHandle | undefined; @@ -581,3 +580,12 @@ export function formatTimeOption(option: DMTimeOption) { const formattedTime = timePart.replace(/-/g, ' '); return formattedTime; } + +export async function assertUrlIsReachable(url: string): Promise { + const response = await fetch(url); + if (response.status !== 200) { + throw new Error( + `Expected status 200 but got ${response.status} for URL: ${url}`, + ); + } +}