From 91a625ca72d2d40311ab91f55b32e1837d4ecd1c Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Mar 2026 15:20:26 +1100 Subject: [PATCH 01/17] fix: all tests passing with msg deletion also removed createContact msg req logic --- tests/automation/call_checks.spec.ts | 2 - tests/automation/community_tests.spec.ts | 4 + .../automation/enforce_localized_str.spec.ts | 15 +- tests/automation/group_testing.spec.ts | 3 +- .../automation/linked_device_requests.spec.ts | 14 +- tests/automation/linked_device_user.spec.ts | 75 +++++-- tests/automation/message_checks.spec.ts | 72 ++++--- .../automation/message_checks_groups.spec.ts | 197 ++++++++++++------ tests/automation/message_requests.spec.ts | 4 + tests/automation/user_actions.spec.ts | 50 +---- tests/automation/utilities/create_contact.ts | 34 +-- tests/automation/utilities/join_community.ts | 2 +- tests/automation/utilities/message.ts | 42 +++- tests/automation/utilities/rename_group.ts | 1 + tests/automation/utilities/utils.ts | 31 ++- 15 files changed, 345 insertions(+), 201 deletions(-) diff --git a/tests/automation/call_checks.spec.ts b/tests/automation/call_checks.spec.ts index 9c77e6a..1db3118 100644 --- a/tests/automation/call_checks.spec.ts +++ b/tests/automation/call_checks.spec.ts @@ -13,8 +13,6 @@ test_Alice_1W_Bob_1W( 'Voice calls', async ({ alice, aliceWindow1, bob, bobWindow1 }) => { await createContact(aliceWindow1, bobWindow1, alice, bob); - // Unfocus current conversation on receiver's end - await clickOn(bobWindow1, Global.backButton); await clickOn(bobWindow1, HomeScreen.plusButton); await clickOnWithText(bobWindow1, Global.contactItem, 'Note to Self'); await makeVoiceCall(aliceWindow1, bobWindow1); diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index ffb1692..44851fb 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -88,9 +88,11 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { await scrollToBottomIfNecessary(windowA); await clickOnWithText(windowA, Conversation.messageContent, msg1, { rightButton: true, + maxWait: 15_000, }); await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { strictMode: false, + maxWait: 10_000, }); await clickOn(windowA, Conversation.banUserButton); await pasteIntoInput(windowB, Conversation.messageInput.selector, msg2); @@ -127,9 +129,11 @@ sessionTestTwoWindows('Ban And delete all', async ([windowA, windowB]) => { await scrollToBottomIfNecessary(windowA); await clickOnWithText(windowA, Conversation.messageContent, msg1, { rightButton: true, + maxWait: 15_000, }); await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { strictMode: false, + maxWait: 10_000, }); await clickOn(windowA, Conversation.banAndDeleteAllButton); await hasElementBeenDeleted( diff --git a/tests/automation/enforce_localized_str.spec.ts b/tests/automation/enforce_localized_str.spec.ts index 523a3df..2102b6e 100644 --- a/tests/automation/enforce_localized_str.spec.ts +++ b/tests/automation/enforce_localized_str.spec.ts @@ -60,10 +60,7 @@ function getExpectedStringFromKey( return count === 1 ? 'Message deleted' : 'Messages deleted'; case 'deleteMessage': return count === 1 ? 'Delete Message' : 'Delete Messages'; - case 'deleteMessageConfirm': - return count === 1 - ? 'Are you sure you want to delete this message?' - : 'Are you sure you want to delete these messages?'; + default: return null; } @@ -105,8 +102,8 @@ function getExpectedStringFromKey( return 'Delete'; case 'copy': return 'Copy'; - case 'clearMessagesForEveryone': - return 'Clear for everyone'; + case 'deleteMessageEveryone': + return 'Delete for everyone'; case 'block': return 'Block'; case 'blockBlockedDescription': @@ -125,6 +122,12 @@ function getExpectedStringFromKey( return 'Clear for me'; case 'clearAll': return 'Clear All'; + case 'deleteMessageDeviceOnly': + return 'Delete on this device only'; + case 'deleteMessageDevicesAll': + return 'Delete on all my devices'; + case 'deleteMessageDeletedLocally': + return 'This message was deleted on this device'; case 'sessionMessageRequests': return 'Message Requests'; case 'done': diff --git a/tests/automation/group_testing.spec.ts b/tests/automation/group_testing.spec.ts index 7de72a8..fcefac6 100644 --- a/tests/automation/group_testing.spec.ts +++ b/tests/automation/group_testing.spec.ts @@ -94,7 +94,6 @@ test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( }, [aliceWindow1, bobWindow1, charlieWindow1], ); - await clickOn(draculaWindow1, Global.backButton); await clickOnWithText( draculaWindow1, HomeScreen.conversationItemName, @@ -117,11 +116,13 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await waitForMatchingText( bobWindow1, tStripped('groupNameNew', { group_name: newGroupName }), + 15_000, ); await clickOnMatchingText(charlieWindow1, newGroupName); await waitForMatchingText( charlieWindow1, tStripped('groupNameNew', { group_name: newGroupName }), + 15_000, ); // Click on conversation options // Check to see that you can't change group name to empty string diff --git a/tests/automation/linked_device_requests.spec.ts b/tests/automation/linked_device_requests.spec.ts index f238a87..0743020 100644 --- a/tests/automation/linked_device_requests.spec.ts +++ b/tests/automation/linked_device_requests.spec.ts @@ -44,10 +44,12 @@ test_Alice_2W_Bob_1W( await waitForMatchingText( aliceWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); await waitForMatchingText( aliceWindow2, tStripped('messageRequestsNonePending'), + 15_000, ); await sendMessage(aliceWindow1, testReply); await waitForTextMessage(bobWindow1, testReply); @@ -90,13 +92,11 @@ test_Alice_2W_Bob_1W( Global.confirmButton, tStripped('delete'), ); - await waitForMatchingText( - aliceWindow1, - tStripped('messageRequestsNonePending'), - ); - await waitForMatchingText( - aliceWindow2, - tStripped('messageRequestsNonePending'), + + await Promise.all( + [aliceWindow1, aliceWindow2].map((w) => + waitForMatchingText(w, tStripped('messageRequestsNonePending'), 15_000), + ), ); }, ); diff --git a/tests/automation/linked_device_user.spec.ts b/tests/automation/linked_device_user.spec.ts index f1aa10d..d1d563c 100644 --- a/tests/automation/linked_device_user.spec.ts +++ b/tests/automation/linked_device_user.spec.ts @@ -20,6 +20,7 @@ import { createContact } from './utilities/create_contact'; import { linkedDevice } from './utilities/linked_device'; import { sendMessage } from './utilities/message'; import { compareElementScreenshot } from './utilities/screenshot'; +import { sendNewMessage } from './utilities/send_message'; import { checkModalStrings, clickOn, @@ -178,7 +179,7 @@ test_Alice_2W_Bob_1W( ); test_Alice_2W_Bob_1W( - 'Deleted message syncs', + 'Delete message locally 1:1', async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { const messageToDelete = 'Testing deletion functionality for linked device'; await createContact(aliceWindow1, bobWindow1, alice, bob); @@ -190,11 +191,16 @@ test_Alice_2W_Bob_1W( bob.userName, ); await Promise.all([ - waitForTextMessage(aliceWindow2, messageToDelete), - waitForTextMessage(bobWindow1, messageToDelete), + waitForTextMessage(aliceWindow2, messageToDelete, 15_000), + waitForTextMessage(bobWindow1, messageToDelete, 15_000), ]); - await clickOnTextMessage(aliceWindow1, messageToDelete, true); + await clickOnTextMessage(aliceWindow1, messageToDelete, true, 1_000); await clickOnMatchingText(aliceWindow1, tStripped('delete')); + await clickOnMatchingText( + aliceWindow1, + tStripped('deleteMessageDeviceOnly'), + ); + await clickOnWithText( aliceWindow1, Global.confirmButton, @@ -205,18 +211,16 @@ test_Alice_2W_Bob_1W( 'session-toast', tStripped('deleteMessageDeleted', { count: 1 }), ); - await hasTextMessageBeenDeleted(aliceWindow1, messageToDelete, 6_000); - // linked device for deleted message - // Waiting for message to be removed - // Check for linked device - await hasTextMessageBeenDeleted(aliceWindow2, messageToDelete, 30_000); - // Still should exist for user B - await waitForMatchingText(bobWindow1, messageToDelete); + await Promise.all([ + hasTextMessageBeenDeleted(aliceWindow1, messageToDelete, 6_000), // should only be deleted locally + waitForMatchingText(aliceWindow2, messageToDelete, 15_000), // should still be here on linked device + waitForMatchingText(bobWindow1, messageToDelete, 15_000), // should still be here on bob + ]); }, ); test_Alice_2W_Bob_1W( - 'Unsent message syncs', + 'Delete message for everyone 1:1', async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { const unsentMessage = 'Testing unsending functionality for linked device'; await createContact(aliceWindow1, bobWindow1, alice, bob); @@ -233,10 +237,7 @@ test_Alice_2W_Bob_1W( ]); await clickOnTextMessage(aliceWindow1, unsentMessage, true); await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('clearMessagesForEveryone'), - ); + await clickOnMatchingText(aliceWindow1, tStripped('deleteMessageEveryone')); await clickOnElement({ window: aliceWindow1, strategy: 'data-testid', @@ -251,12 +252,53 @@ test_Alice_2W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('deleteMessageDeletedGlobally'), + 15_000, ); // linked device for deleted message await hasTextMessageBeenDeleted(aliceWindow2, unsentMessage, 5_000); }, ); +test_Alice_2W( + 'Delete message for all my devices NTS', + async ({ alice, aliceWindow1, aliceWindow2 }) => { + const unsentMessage = `Testing unsending functionality for NTS ${new Date().toISOString()}`; + await sendNewMessage(aliceWindow1, alice.accountid, unsentMessage); + // Navigate to conversation on linked device and for message from user A to user B + await clickOnWithText( + aliceWindow2, + HomeScreen.conversationItemName, + tStripped('noteToSelf'), + ); + await Promise.all([ + waitForTextMessage(aliceWindow1, unsentMessage), + waitForTextMessage(aliceWindow2, unsentMessage), + ]); + await clickOnTextMessage(aliceWindow1, unsentMessage, true); + await clickOnMatchingText(aliceWindow1, tStripped('delete')); + await clickOnMatchingText( + aliceWindow1, + tStripped('deleteMessageDevicesAll'), + ); + await clickOnElement({ + window: aliceWindow1, + strategy: 'data-testid', + selector: 'session-confirm-ok-button', + }); + await waitForTestIdWithText( + aliceWindow1, + 'session-toast', + tStripped('deleteMessageDeleted', { count: 1 }), + ); + // in NTS, a message deleted on all our devices is removed entirely (the tombstone is not left) + await Promise.all( + [aliceWindow1, aliceWindow2].map((w) => + hasTextMessageBeenDeleted(w, unsentMessage, 15_000), + ), + ); + }, +); + test_Alice_2W_Bob_1W( 'Blocked user syncs', async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { @@ -316,7 +358,6 @@ test_Alice_2W_Bob_1W( async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { // Create contact and send new message await createContact(aliceWindow1, bobWindow1, alice, bob); - await clickOn(bobWindow1, Global.backButton); await Promise.all( [aliceWindow1, aliceWindow2, bobWindow1].map((w) => clickOnElement({ diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index fa17517..c67697b 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -14,10 +14,11 @@ import { sessionTestTwoWindows, test_Alice_1W, test_Alice_1W_Bob_1W, + test_Alice_2W_Bob_1W, } from './setup/sessionTest'; import { createContact } from './utilities/create_contact'; import { joinCommunity } from './utilities/join_community'; -import { sendMessage } from './utilities/message'; +import { deleteMessageFor, sendMessage } from './utilities/message'; import { replyTo, replyToMedia } from './utilities/reply_message'; import { sendLinkPreview, @@ -191,10 +192,7 @@ test_Alice_1W_Bob_1W( await waitForTextMessage(bobWindow1, unsendMessage); await clickOnTextMessage(aliceWindow1, unsendMessage, true); await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('clearMessagesForEveryone'), - ); + await clickOnMatchingText(aliceWindow1, tStripped('deleteMessageEveryone')); await clickOnElement({ window: aliceWindow1, strategy: 'data-testid', @@ -209,32 +207,56 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('deleteMessageDeletedGlobally'), + 15_000, ); }, ); -test_Alice_1W_Bob_1W( - 'Delete message 1:1', - async ({ alice, aliceWindow1, bob, bobWindow1 }) => { - const deletedMessage = 'Testing deletion functionality'; +test_Alice_2W_Bob_1W( + 'Delete message locally in 1:1', + async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { + // send a message from alice to Bob, and then try to delete it locally from Alice's side + const deletedMessage1 = `Testing deletion functionality from ${alice.userName} to ${bob.userName} in 1:1 at ${new Date().toISOString()}`; await createContact(aliceWindow1, bobWindow1, alice, bob); - await sendMessage(aliceWindow1, deletedMessage); - await waitForTextMessage(bobWindow1, deletedMessage); - await clickOnTextMessage(aliceWindow1, deletedMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), + await sendMessage(aliceWindow1, deletedMessage1); + // focus the conversation on aliceWindow2 (not done as restored from seed) + await clickOnWithText( + aliceWindow2, + HomeScreen.conversationItemName, + bob.userName, + ); + + await Promise.all( + [aliceWindow2, bobWindow1].map((w) => + waitForTextMessage(w, deletedMessage1), + ), + ); + await deleteMessageFor(aliceWindow1, deletedMessage1, 'device_only'); + await hasTextMessageBeenDeleted(aliceWindow1, deletedMessage1, 1_000); + // Still should exist in Bob and aliceWindow2 + await Promise.all( + [aliceWindow2, bobWindow1].map((w) => + waitForMatchingText(w, deletedMessage1, 15_000), + ), + ); + + // same, but we know want validate that Bob can also delete locally Alice's message + const deletedMessage2 = `Testing deletion functionality from ${alice.userName} to ${bob.userName} in 1:1 at ${new Date().toISOString()}`; + await sendMessage(aliceWindow1, deletedMessage2); // alice sends it again + await Promise.all( + [aliceWindow2, bobWindow1].map((w) => + waitForTextMessage(w, deletedMessage2), + ), + ); + await deleteMessageFor(bobWindow1, deletedMessage2, 'device_only'); // Bob deletes Alice's message locally + + await hasTextMessageBeenDeleted(bobWindow1, deletedMessage2, 1_000); + // Still should exist in Bob and aliceWindow2 + await Promise.all( + [aliceWindow1, aliceWindow2].map((w) => + waitForMatchingText(w, deletedMessage2, 15_000), + ), ); - await hasTextMessageBeenDeleted(aliceWindow1, deletedMessage, 1000); - // Still should exist in window B - await waitForMatchingText(bobWindow1, deletedMessage); }, ); diff --git a/tests/automation/message_checks_groups.spec.ts b/tests/automation/message_checks_groups.spec.ts index be28226..a4e09b0 100644 --- a/tests/automation/message_checks_groups.spec.ts +++ b/tests/automation/message_checks_groups.spec.ts @@ -1,8 +1,12 @@ import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { longText, mediaArray, testLink } from './constants/variables'; -import { test_group_Alice_1W_Bob_1W_Charlie_1W } from './setup/sessionTest'; -import { sendMessage } from './utilities/message'; +import { HomeScreen } from './locators'; +import { + test_group_Alice_1W_Bob_1W_Charlie_1W, + test_group_Alice_2W_Bob_1W_Charlie_1W, +} from './setup/sessionTest'; +import { deleteMessageFor, sendMessage } from './utilities/message'; import { replyTo, replyToMedia } from './utilities/reply_message'; import { sendLinkPreview, @@ -11,8 +15,7 @@ import { } from './utilities/send_media'; import { clickOnElement, - clickOnMatchingText, - clickOnTextMessage, + clickOnWithText, hasTextMessageBeenDeleted, pasteIntoInput, waitForElement, @@ -155,76 +158,140 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( }, ); -test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Unsend message to group', - async ({ aliceWindow1, bobWindow1, charlieWindow1, groupCreated }) => { - const unsendMessage = `Testing unsend functionality in ${groupCreated.userName}`; - await sendMessage(aliceWindow1, unsendMessage); - await Promise.all([ - waitForTextMessage(bobWindow1, unsendMessage), - waitForTextMessage(charlieWindow1, unsendMessage), - ]); - await clickOnTextMessage(aliceWindow1, unsendMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('clearMessagesForEveryone'), +test_group_Alice_2W_Bob_1W_Charlie_1W( + 'Delete message for everyone in group', + async ({ + aliceWindow1, + aliceWindow2, + bobWindow1, + charlieWindow1, + groupCreated, + alice, + bob, + }) => { + // Note: Alice is the admin in this group, Bob is a member without admin rights + const unsendMessageFromBob1 = `Testing unsend functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; + // focus the conversation on aliceWindow2 (not done as restored from seed) + await clickOnWithText( + aliceWindow2, + HomeScreen.conversationItemName, + groupCreated.userName, ); - // To be implemented in Standardise Message Deletion feature - // await checkModalStrings( - // aliceWindow1, - // tStripped('deleteMessage', { count: 1 }), - // tStripped('deleteMessageConfirm'), - // ); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), + + await sendMessage(bobWindow1, unsendMessageFromBob1); + await Promise.all( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => + waitForTextMessage(w, unsendMessageFromBob1, 15_000), + ), ); - await sleepFor(1000); - await waitForMatchingText( - bobWindow1, - tStripped('deleteMessageDeletedGlobally'), + + // Bob sent this message, so should be able to delete it for everyone + await deleteMessageFor(bobWindow1, unsendMessageFromBob1, 'for_everyone'); + // message should be marked as deleted on all devices of all members + await Promise.all( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => + waitForMatchingText( + w, + tStripped('deleteMessageDeletedGlobally'), + 15_000, + ), + ), ); - await waitForMatchingText( - charlieWindow1, - tStripped('deleteMessageDeletedGlobally'), + + // Now, try to remove a new message as Alice (admin) sent by Bob + console.log( + `Now, try to remove a new message as ${alice.userName} (admin) sent by ${bob.userName}`, + ); + const unsendMessageFromBob2 = `Testing unsend functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; + await sendMessage(bobWindow1, unsendMessageFromBob2); + + await Promise.all( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => + waitForTextMessage(w, unsendMessageFromBob2), + ), + ); + // Bob sent this message, so should be able to delete it for everyone + await deleteMessageFor(aliceWindow1, unsendMessageFromBob2, 'for_everyone'); + // message should be marked as deleted on all devices of all members + await Promise.all( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => + waitForMatchingText( + w, + tStripped('deleteMessageDeletedGlobally'), + 15_000, + ), + ), ); }, ); -test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Delete message to group', - async ({ aliceWindow1, bobWindow1, charlieWindow1, groupCreated }) => { - const deletedMessage = `Testing delete message functionality in ${groupCreated.userName}`; - await sendMessage(aliceWindow1, deletedMessage); - await waitForTextMessage(bobWindow1, deletedMessage); - await waitForTextMessage(charlieWindow1, deletedMessage); - await clickOnTextMessage(aliceWindow1, deletedMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText(aliceWindow1, tStripped('clearMessagesForMe')); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), +test_group_Alice_2W_Bob_1W_Charlie_1W( + 'Delete message locally in group', + async ({ + aliceWindow1, + aliceWindow2, + bobWindow1, + charlieWindow1, + groupCreated, + bob, + }) => { + const deletedMessageFromBob1 = `Testing delete message functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; + // focus the conversation on aliceWindow2 (not done as restored from seed) + await clickOnWithText( + aliceWindow2, + HomeScreen.conversationItemName, + groupCreated.userName, + ); + + await sendMessage(bobWindow1, deletedMessageFromBob1); + await Promise.all( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => + waitForTextMessage(w, deletedMessageFromBob1, 15_000), + ), ); - await hasTextMessageBeenDeleted(aliceWindow1, deletedMessage, 5000); + // bob can remove locally his own message + await deleteMessageFor(bobWindow1, deletedMessageFromBob1, 'device_only'); + await hasTextMessageBeenDeleted(bobWindow1, deletedMessageFromBob1, 5000); await waitForMatchingText( - aliceWindow1, - tStripped('deleteMessageDeletedGlobally'), + bobWindow1, + tStripped('deleteMessageDeletedLocally'), + 15_000, + ); + // Should still be there for Alice and Charlie + await Promise.all( + [aliceWindow1, aliceWindow2, charlieWindow1].map((w) => + waitForMatchingText(w, deletedMessageFromBob1, 15_000), + ), + ); + + // Charlie (another normal member) can remove locally messages he didn't send + const deletedMessageFromBob2 = `Testing delete message functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; + await sendMessage(bobWindow1, deletedMessageFromBob2); + await Promise.all( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => + waitForTextMessage(w, deletedMessageFromBob2, 15_000), + ), + ); + await deleteMessageFor( + charlieWindow1, + deletedMessageFromBob2, + 'device_only', + ); + await hasTextMessageBeenDeleted( + charlieWindow1, + deletedMessageFromBob2, + 5000, + ); + await waitForMatchingText( + charlieWindow1, + tStripped('deleteMessageDeletedLocally'), + 15_000, + ); + // Should still be there for Alice and Bob + await Promise.all( + [aliceWindow1, aliceWindow1, bobWindow1].map((w) => + waitForMatchingText(w, deletedMessageFromBob2, 15_000), + ), ); - // Should still be there for user B and C - await waitForMatchingText(bobWindow1, deletedMessage); - await waitForMatchingText(charlieWindow1, deletedMessage); }, ); diff --git a/tests/automation/message_requests.spec.ts b/tests/automation/message_requests.spec.ts index f68f1e9..e346b35 100644 --- a/tests/automation/message_requests.spec.ts +++ b/tests/automation/message_requests.spec.ts @@ -51,6 +51,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); @@ -83,6 +84,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); @@ -122,6 +124,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); @@ -155,6 +158,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index 789f61c..6ddccb9 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -9,9 +9,7 @@ import { LeftPane, Settings, } from './locators'; -import { newUser } from './setup/new_user'; import { - sessionTestTwoWindows, test_Alice_1W_Bob_1W, test_Alice_1W_no_network, test_Alice_2W, @@ -38,41 +36,6 @@ const cancelString = tStripped('cancel'); const saveString = tStripped('save'); const removeString = tStripped('remove'); -// Send message in one to one conversation with new contact -sessionTestTwoWindows('Create contact', async ([windowA, windowB]) => { - // no fixture for that one - const [userA, userB] = await Promise.all([ - newUser(windowA, 'Alice'), - newUser(windowB, 'Bob'), - ]); - await createContact(windowA, windowB, userA, userB); - // Navigate to contacts tab in User B's window - await waitForTestIdWithText( - windowB, - Conversation.messageRequestAcceptControlMessage.selector, - tStripped('messageRequestYouHaveAccepted', { - name: userA.userName, - }), - ); - await clickOn(windowB, Global.backButton); - await Promise.all([ - clickOnElement({ - window: windowA, - strategy: 'data-testid', - selector: 'new-conversation-button', - }), - clickOnElement({ - window: windowB, - strategy: 'data-testid', - selector: 'new-conversation-button', - }), - ]); - await Promise.all([ - waitForTestIdWithText(windowA, Global.contactItem.selector, userB.userName), - waitForTestIdWithText(windowB, Global.contactItem.selector, userA.userName), - ]); -}); - test_Alice_1W_Bob_1W( 'Block user in conversation list', async ({ aliceWindow1, bobWindow1, alice, bob }) => { @@ -136,7 +99,11 @@ test_Alice_1W_Bob_1W( tStripped('blockUnblock'), ); // make sure no blocked contacts are listed - await waitForMatchingText(aliceWindow1, tStripped('blockBlockedNone')); + await waitForMatchingText( + aliceWindow1, + tStripped('blockBlockedNone'), + 1_000, + ); }, ); @@ -314,7 +281,6 @@ test_Alice_1W_Bob_1W( }); await clickOn(bobWindow1, Global.modalCloseButton); await sendMessage(aliceWindow1, 'Testing read receipts'); - await clickOn(bobWindow1, Global.backButton); await clickOnWithText( bobWindow1, HomeScreen.conversationItemName, @@ -329,7 +295,6 @@ test_Alice_1W_Bob_1W( async ({ aliceWindow1, bobWindow1, alice, bob }) => { // Create contact and send new message await createContact(aliceWindow1, bobWindow1, alice, bob); - await clickOn(bobWindow1, Global.backButton); await Promise.all( [aliceWindow1, bobWindow1].map((w) => clickOnElement({ @@ -351,9 +316,11 @@ test_Alice_1W_Bob_1W( alice.userName, ), ]); + await Promise.all( [aliceWindow1, bobWindow1].map((w) => clickOn(w, Global.backButton)), ); + // Delete contact await clickOnWithText( aliceWindow1, @@ -440,10 +407,11 @@ test_Alice_1W_no_network('Invite a friend', async ({ aliceWindow1, alice }) => { ); // Wait for copy to resolve await sleepFor(1000); - await waitForMatchingText(aliceWindow1, tStripped('accountIdCopied')); + await waitForMatchingText(aliceWindow1, tStripped('accountIdCopied'), 1_000); await waitForMatchingText( aliceWindow1, tStripped('shareAccountIdDescriptionCopied'), + 1_000, ); // To exit invite a friend await clickOn(aliceWindow1, Global.backButton); diff --git a/tests/automation/utilities/create_contact.ts b/tests/automation/utilities/create_contact.ts index 4b69332..777099f 100644 --- a/tests/automation/utilities/create_contact.ts +++ b/tests/automation/utilities/create_contact.ts @@ -1,10 +1,7 @@ import { Page } from '@playwright/test'; -import { HomeScreen } from '../locators'; import { User } from '../types/testing'; -import { replyTo } from './reply_message'; import { sendNewMessage } from './send_message'; -import { clickOnElement, clickOnWithText } from './utils'; export const createContact = async ( windowA: Page, @@ -12,31 +9,14 @@ export const createContact = async ( userA: User, userB: User, ) => { + const start = Date.now(); const testMessage = `${userA.userName} to ${userB.userName}`; const testReply = `${userB.userName} to ${userA.userName}`; // User A sends message to User B - await sendNewMessage(windowA, userB.accountid, testMessage); - await clickOnElement({ - window: windowB, - strategy: 'data-testid', - selector: 'message-request-banner', - }); - await clickOnWithText( - windowB, - HomeScreen.conversationItemName, - userA.userName, - ); - await clickOnElement({ - window: windowB, - strategy: 'data-testid', - selector: 'accept-message-request', - }); - // Note: when creating a contact, we want to make sure both sides are friends when we finish this function, - // so passing the windowA here is very important, so we wait for windowA to have received the reply - await replyTo({ - senderWindow: windowB, - textMessage: testMessage, - replyText: testReply, - receiverWindow: windowA, - }); + await Promise.all([ + sendNewMessage(windowA, userB.accountid, testMessage), + + sendNewMessage(windowB, userA.accountid, testReply), + ]); + console.warn(`createContact took ${Date.now() - start}ms`); }; diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index 2624a10..1d25f94 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -37,7 +37,7 @@ export const joinDefaultCommunity = async ( ) => { await clickOn(window, HomeScreen.plusButton); await clickOn(window, HomeScreen.joinCommunityOption); - await waitForMatchingText(window, communityName); + await waitForMatchingText(window, communityName, 15_000); await clickOnMatchingText(window, communityName); // Deliberately do not wait for loading spinner to finish because this takes forever await waitForTestIdWithText( diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index c897243..dfcb8ae 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -1,7 +1,15 @@ import { Page } from '@playwright/test'; +import { tStripped } from '../../localization/lib'; import { MessageStatus } from '../types/testing'; -import { clickOnElement, pasteIntoInput } from './utils'; +import { + checkModalStrings, + clickOnElement, + clickOnMatchingText, + clickOnTextMessage, + pasteIntoInput, + waitForTestIdWithText, +} from './utils'; export const waitForMessageStatus = async ( window: Page, @@ -29,3 +37,35 @@ export const sendMessage = async (window: Page, message: string) => { }); await waitForMessageStatus(window, message, 'sent'); }; + +export async function deleteMessageFor( + window: Page, + message: string, + deletionType: 'device_only' | 'for_all_my_devices' | 'for_everyone', +) { + await clickOnTextMessage(window, message, true); + await clickOnMatchingText(window, tStripped('delete')); + switch (deletionType) { + case 'device_only': + await clickOnMatchingText(window, tStripped('deleteMessageDeviceOnly')); + break; + case 'for_everyone': + await clickOnMatchingText(window, tStripped('deleteMessageEveryone')); + break; + case 'for_all_my_devices': + await clickOnMatchingText(window, tStripped('deleteMessageDevicesAll')); + break; + } + + await checkModalStrings(window, tStripped('deleteMessage', { count: 1 })); + await clickOnElement({ + window, + strategy: 'data-testid', + selector: 'session-confirm-ok-button', + }); + await waitForTestIdWithText( + window, + 'session-toast', + tStripped('deleteMessageDeleted', { count: 1 }), + ); +} diff --git a/tests/automation/utilities/rename_group.ts b/tests/automation/utilities/rename_group.ts index 3501296..8e7ad32 100644 --- a/tests/automation/utilities/rename_group.ts +++ b/tests/automation/utilities/rename_group.ts @@ -27,5 +27,6 @@ export const renameGroup = async ( await waitForMatchingText( window, tStripped('groupNameNew', { group_name: newGroupName }), + 5_000, ); }; diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index c35282d..9a89921 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -116,13 +116,20 @@ export async function waitForControlMessageWithText( export async function waitForMatchingText( window: Page, text: string, - maxWait?: number, + maxWait: number, ) { const builtSelector = `css=:has-text("${text}")`; const maxTimeout = maxWait ?? 55000; - console.info(`waitForMatchingText: ${text}`); + console.info(`waitForMatchingText: ${text} for maxWait: ${maxTimeout}ms`); + const start = Date.now(); - return window.waitForSelector(builtSelector, { timeout: maxTimeout }); + const found = await window.waitForSelector(builtSelector, { + timeout: maxTimeout, + }); + console.info( + `waitForMatchingText: found "${text}" in ${Date.now() - start}ms`, + ); + return found; } export async function waitForMatchingPlaceholder( @@ -565,7 +572,7 @@ function assertTextMatches( export async function checkModalStrings( window: Page, expectedHeading: string, - expectedDescription: string, + expectedDescription?: string, modalId?: ModalId, ) { let modalSelector = '[data-modal-id]'; // Base selector for modals @@ -583,16 +590,24 @@ export async function checkModalStrings( // Get elements within this specific modal const heading = targetModal.locator('[data-testid="modal-heading"]'); - const description = targetModal.locator('[data-testid="modal-description"]'); // Wait for these elements to be visible await heading.waitFor({ state: 'visible' }); - await description.waitFor({ state: 'visible' }); const headingText = await heading.innerText(); - const descriptionText = await description.innerText(); assertTextMatches(headingText, expectedHeading, 'Modal heading'); - assertTextMatches(descriptionText, expectedDescription, 'Modal description'); + if (expectedDescription) { + const description = targetModal.locator( + '[data-testid="modal-description"]', + ); + await description.waitFor({ state: 'visible' }); + const descriptionText = await description.innerText(); + assertTextMatches( + descriptionText, + expectedDescription, + 'Modal description', + ); + } } export async function verifyNoCTAShows(window: Page) { From 238f7f55952d03ad84818570cc93a2b5fb613200 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Mar 2026 15:55:05 +1100 Subject: [PATCH 02/17] chore: add shouldLog to waitForElement --- tests/automation/delete_account.spec.ts | 32 +++--- .../disappearing_message_checks.spec.ts | 41 ++++--- .../group_disappearing_messages.spec.ts | 27 +++-- tests/automation/landing_page.spec.ts | 45 ++++---- tests/automation/message_checks.spec.ts | 54 +++++----- .../automation/message_checks_groups.spec.ts | 27 +++-- tests/automation/utilities/reply_message.ts | 8 +- .../utilities/set_disappearing_messages.ts | 23 ++-- tests/automation/utilities/utils.ts | 101 +++++++++++++----- 9 files changed, 207 insertions(+), 151 deletions(-) diff --git a/tests/automation/delete_account.spec.ts b/tests/automation/delete_account.spec.ts index ce732ad..f77d524 100644 --- a/tests/automation/delete_account.spec.ts +++ b/tests/automation/delete_account.spec.ts @@ -134,6 +134,7 @@ sessionTestTwoWindows( tStripped('sessionClearData'), ); // Keep 'Clear Device only' selection + await clickOnMatchingText(windowA, tStripped('clearDeviceOnly')); // Confirm deletion by clicking Clear, twice await clickOnMatchingText(windowA, tStripped('clear')); await clickOnMatchingText(windowA, tStripped('clear')); @@ -143,22 +144,25 @@ sessionTestTwoWindows( await recoverFromSeed(restoringWindow, userA.recoveryPassword); await sleepFor(5000, true); // just to allow any messages from our swarm to show up // Check if message from user B is restored - await waitForElement( - restoringWindow, - 'data-testid', - HomeScreen.conversationItemName.selector, - 10000, - userB.userName, - ); + + await waitForElement({ + window: restoringWindow, + strategy: 'data-testid', + selector: HomeScreen.conversationItemName.selector, + maxWaitMs: 10_000, + shouldLog: true, + text: userB.userName, + }); // Check if contact is available in contacts section await clickOn(restoringWindow, HomeScreen.plusButton); - await waitForElement( - restoringWindow, - 'data-testid', - Global.contactItem.selector, - 1000, - userB.userName, - ); + await waitForElement({ + window: restoringWindow, + strategy: 'data-testid', + selector: Global.contactItem.selector, + maxWaitMs: 1000, + shouldLog: true, + text: userB.userName, + }); console.log('Contacts have been restored'); } finally { if (restoringWindows) { diff --git a/tests/automation/disappearing_message_checks.spec.ts b/tests/automation/disappearing_message_checks.spec.ts index e8d0e27..0175ab2 100644 --- a/tests/automation/disappearing_message_checks.spec.ts +++ b/tests/automation/disappearing_message_checks.spec.ts @@ -182,13 +182,14 @@ test_Alice_1W_Bob_1W( ), ]); await sendLinkPreview(aliceWindow1, testLink); - await waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - 3_000, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ); + await waitForElement({ + window: bobWindow1, + strategy: 'data-testid', + selector: 'msg-link-preview-title', + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }); // Wait 30 seconds for link preview to disappear await sleepFor(30_000); await hasElementBeenDeleted( @@ -254,22 +255,18 @@ test_Alice_1W_Bob_1W( HomeScreen.conversationItemName, bob.userName, ); - await Promise.all([ - waitForElement( - aliceWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, - ), - waitForElement( - bobWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, + await Promise.all( + [aliceWindow1, bobWindow1].map((w) => + waitForElement({ + window: w, + strategy: 'class', + selector: 'group-name', + maxWaitMs: 15_000, + shouldLog: true, + text: testCommunityName, + }), ), - ]); + ); // Wait 30 seconds for community invite to disappear await sleepFor(30000); await Promise.all( diff --git a/tests/automation/group_disappearing_messages.spec.ts b/tests/automation/group_disappearing_messages.spec.ts index 20e5fbc..9a62311 100644 --- a/tests/automation/group_disappearing_messages.spec.ts +++ b/tests/automation/group_disappearing_messages.spec.ts @@ -117,22 +117,19 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( disappearAction, ]); await sendLinkPreview(aliceWindow1, testLink); - await Promise.all([ - waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ), - waitForElement( - charlieWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', + await Promise.all( + [bobWindow1, charlieWindow1].map((w) => + waitForElement({ + window: w, + strategy: 'data-testid', + selector: 'msg-link-preview-title', + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }), ), - ]); + ); + await sleepFor(30000); await Promise.all( [bobWindow1, charlieWindow1].map((w) => diff --git a/tests/automation/landing_page.spec.ts b/tests/automation/landing_page.spec.ts index a06c9bf..fca50e4 100644 --- a/tests/automation/landing_page.spec.ts +++ b/tests/automation/landing_page.spec.ts @@ -8,10 +8,17 @@ import { test_Alice_2W( `Landing page states`, async ({ aliceWindow1, aliceWindow2 }, _testInfo) => { - await Promise.all([ - waitForElement(aliceWindow1, 'class', 'session-conversation'), - waitForElement(aliceWindow2, 'class', 'session-conversation'), - ]); + await Promise.all( + [aliceWindow1, aliceWindow2].map((w) => + waitForElement({ + window: w, + strategy: 'class', + selector: 'session-conversation', + maxWaitMs: 1000, + shouldLog: true, + }), + ), + ); // Check that the account created has all the required strings displayed await Promise.all( @@ -23,13 +30,14 @@ test_Alice_2W( tStripped('conversationsNone'), tStripped('onboardingHitThePlusButton'), ].map(async (builder) => - waitForElement( - aliceWindow1, - 'data-testid', - 'empty-msg-view-account-created', - 1000, - builder.toString(), - ), + waitForElement({ + window: aliceWindow1, + strategy: 'data-testid', + selector: 'empty-msg-view-account-created', + maxWaitMs: 1_000, + shouldLog: true, + text: builder.toString(), + }), ), ); @@ -39,13 +47,14 @@ test_Alice_2W( tStripped('conversationsNone'), tStripped('onboardingHitThePlusButton'), ].map(async (builder) => - waitForElement( - aliceWindow2, - 'data-testid', - 'empty-msg-view-welcome', - 1000, - builder.toString(), - ), + waitForElement({ + window: aliceWindow2, + strategy: 'data-testid', + selector: 'empty-msg-view-welcome', + maxWaitMs: 1_000, + shouldLog: true, + text: builder.toString(), + }), ), ); diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index c67697b..a01a1bd 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -121,13 +121,14 @@ test_Alice_1W_Bob_1W( await createContact(aliceWindow1, bobWindow1, alice, bob); await sendLinkPreview(aliceWindow1, testLink); - await waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ); + await waitForElement({ + window: bobWindow1, + strategy: 'data-testid', + selector: 'msg-link-preview-title', + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }); await replyTo({ senderWindow: bobWindow1, textMessage: testLink, @@ -163,22 +164,18 @@ test_Alice_1W_Bob_1W( HomeScreen.conversationItemName, bob.userName, ); - await Promise.all([ - waitForElement( - aliceWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, - ), - waitForElement( - bobWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, + await Promise.all( + [aliceWindow1, bobWindow1].map((w) => + waitForElement({ + window: w, + strategy: 'class', + selector: 'group-name', + maxWaitMs: 15_000, + shouldLog: true, + text: testCommunityName, + }), ), - ]); + ); }, ); @@ -330,12 +327,13 @@ messageLengthTestCases.forEach((testCase) => { } else { // Verify countdown tooltip is not present try { - await waitForElement( - aliceWindow1, - 'data-testid', - 'tooltip-character-count', - 1000, - ); + await waitForElement({ + window: aliceWindow1, + strategy: 'data-testid', + selector: 'tooltip-character-count', + maxWaitMs: 1_000, + shouldLog: true, + }); throw new Error( `Countdown should not be visible for messages under ${countdownThreshold} chars`, ); diff --git a/tests/automation/message_checks_groups.spec.ts b/tests/automation/message_checks_groups.spec.ts index a4e09b0..5d45404 100644 --- a/tests/automation/message_checks_groups.spec.ts +++ b/tests/automation/message_checks_groups.spec.ts @@ -133,22 +133,19 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( }) => { const testReply = `${bob.userName} replying to link from ${alice.userName} in ${groupCreated.userName}`; await sendLinkPreview(aliceWindow1, testLink); - await Promise.all([ - waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ), - waitForElement( - charlieWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', + await Promise.all( + [bobWindow1, charlieWindow1].map((w) => + waitForElement({ + window: w, + strategy: 'data-testid', + selector: 'msg-link-preview-title', + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }), ), - ]); + ); + await replyTo({ senderWindow: bobWindow1, textMessage: testLink, diff --git a/tests/automation/utilities/reply_message.ts b/tests/automation/utilities/reply_message.ts index 1591b61..c72c529 100644 --- a/tests/automation/utilities/reply_message.ts +++ b/tests/automation/utilities/reply_message.ts @@ -92,7 +92,13 @@ export const replyToMedia = async ({ receiverWindow: Page; senderWindow: Page; }) => { - const selc = await waitForElement(senderWindow, strategy, selector); + const selc = await waitForElement({ + window: senderWindow, + strategy, + selector, + shouldLog: true, + maxWaitMs: 20_000, + }); // the right click context menu, for some reasons, often doesn't show up on the first try. Let's loop a few times for (let index = 0; index < 5; index++) { diff --git a/tests/automation/utilities/set_disappearing_messages.ts b/tests/automation/utilities/set_disappearing_messages.ts index 8dab2ff..7593ae4 100644 --- a/tests/automation/utilities/set_disappearing_messages.ts +++ b/tests/automation/utilities/set_disappearing_messages.ts @@ -49,20 +49,25 @@ export const setDisappearingMessages = async ( let defaultTime; if (timerType === 'disappear-after-read-option') { // making explicit DataTestId here as `waitForElement` currently allows a string - // TODO: add explicit typing to waitForElement const dataTestId: DataTestId = 'input-time-option-12-hours'; - defaultTime = await waitForElement(windowA, 'data-testid', dataTestId); + defaultTime = await waitForElement({ + window: windowA, + strategy: 'data-testid', + selector: dataTestId, + maxWaitMs: 1_000, + shouldLog: true, + }); } else { // making explicit DataTestId here as `waitForElement` currently allows a string - // TODO: add explicit typing to waitForElement const dataTestId: DataTestId = 'input-time-option-1-days'; - defaultTime = await waitForElement( - windowA, - 'data-testid', - dataTestId, - 1000, - ); + defaultTime = await waitForElement({ + window: windowA, + strategy: 'data-testid', + selector: dataTestId, + maxWaitMs: 1_000, + shouldLog: true, + }); } const checked = await isChecked(defaultTime); if (checked) { diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 9a89921..c697e0a 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -53,26 +53,34 @@ export async function waitForTestIdWithText( return found; } -export async function waitForElement( - window: Page, - strategy: Strategy, - selector: string, - maxWaitMs?: number, - text?: string, -) { +export async function waitForElement({ + selector, + strategy, + window, + maxWaitMs, + shouldLog, + text, +}: { + window: Page; + strategy: Strategy; + selector: string; + maxWaitMs?: number; + text?: string; + shouldLog: boolean; +}) { const builtSelector = !text ? `css=[${strategy}=${selector}]` : `css=[${strategy}=${selector}]:has-text("${text.replace(/"/g, '\\"')}")`; const start = Date.now(); - if (!selector.includes('path-light-svg')) { + if (shouldLog) { console.log(`waitForElement: ${builtSelector} for maxMs ${maxWaitMs}`); } const el = await window.waitForSelector(builtSelector, { timeout: maxWaitMs, }); - if (!selector.includes('path-light-svg')) { + if (shouldLog) { console.log( `waitForElement: got ${builtSelector} after ${Date.now() - start}ms`, ); @@ -146,7 +154,13 @@ export async function waitForMatchingPlaceholder( do { try { - const elem = await waitForElement(window, 'data-testid', dataTestId); + const elem = await waitForElement({ + window, + strategy: 'data-testid', + selector: dataTestId, + shouldLog: false, + maxWaitMs: 100, + }); const elemPlaceholder = await elem.getAttribute('placeholder'); if (elemPlaceholder === placeholder) { console.info( @@ -179,16 +193,23 @@ export async function waitForLoadingAnimationToFinish( ) { let loadingAnimation: ElementHandle | undefined; - await waitForElement(window, 'data-testid', `${loader}`, maxWait); + await waitForElement({ + window, + strategy: 'data-testid', + selector: `${loader}`, + maxWaitMs: maxWait, + shouldLog: false, + }); do { try { - loadingAnimation = await waitForElement( + loadingAnimation = await waitForElement({ window, - 'data-testid', - `${loader}`, - 100, - ); + strategy: 'data-testid', + selector: `${loader}`, + maxWaitMs: 100, + shouldLog: false, + }); await sleepFor(500); console.info(`${loader} was found, waiting for it to be gone`); } catch (_e) { @@ -234,12 +255,14 @@ export async function checkPathLight(window: Page, maxWait?: number) { let pathFilter: string | null = null; await doWhileWithMax(maxWaitTime, waitPerLoop, 'checkPathLight', async () => { - const pathLight = await waitForElement( + const pathLight = await waitForElement({ window, - 'data-testid', - 'path-light-svg', - maxWait, - ); + strategy: 'data-testid', + selector: 'path-light-svg', + maxWaitMs: maxWait, + shouldLog: false, + }); + pathFilter = await pathLight.getAttribute('style'); if (Date.now() - start >= maxWaitTime / 10) { @@ -444,9 +467,19 @@ export async function hasElementBeenDeleted( const start = Date.now(); let el: ElementHandle | undefined; + console.info( + `waiting for element to be deleted "${strategy}:${selector}:${text}", maxWait: ${maxWait}ms`, + ); do { try { - el = await waitForElement(window, strategy, selector, maxWait, text); + el = await waitForElement({ + window, + strategy, + selector, + maxWaitMs: maxWait, + text, + shouldLog: false, + }); await sleepFor(100); console.info(`Element has been found, waiting for deletion`); } catch (_e) { @@ -455,7 +488,14 @@ export async function hasElementBeenDeleted( } } while (Date.now() - start <= maxWait && el); try { - el = await waitForElement(window, strategy, selector, 1000, text); + el = await waitForElement({ + window, + strategy, + selector, + maxWaitMs: 1_000, + text, + shouldLog: false, + }); } catch (_e) { // if we did throw here it's actually because the element is gone, so it's ok } @@ -465,7 +505,9 @@ export async function hasElementBeenDeleted( `hasElementBeenDeleted: element with selector ${selector} was expected to be gone but is still there`, ); } - console.info(`Element has been deleted yay`); + console.info( + `Element "${strategy}:${selector}:${text}" has been deleted yay`, + ); } export async function hasTextMessageBeenDeleted( @@ -479,13 +521,14 @@ export async function hasTextMessageBeenDeleted( 'waiting for text message to be deleted', async () => { try { - await waitForElement( + await waitForElement({ window, - 'data-testid', - 'message-content', - maxWait, + strategy: 'data-testid', + selector: 'message-content', + maxWaitMs: maxWait, text, - ); + shouldLog: false, + }); return false; } catch (_e) { console.info(`Text message not found, yay!`); From 2fbcc05fa623ec4801376474ffa742cd155b92fc Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Mar 2026 16:34:24 +1100 Subject: [PATCH 03/17] fix: merge the two ban/unban tests together --- tests/automation/community_tests.spec.ts | 81 +++++++++++-------- tests/automation/delete_account.spec.ts | 3 + .../automation/enforce_localized_str.spec.ts | 2 + tests/automation/utilities/utils.ts | 14 +++- 4 files changed, 64 insertions(+), 36 deletions(-) diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index 44851fb..cfa32d7 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -73,8 +73,8 @@ test_Alice_1W_Bob_1W( sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { assertAdminIsKnown(); - const msg1 = `Ban me but unban me later! - ${Date.now()}`; - const msg2 = `I'm banned :( - ${Date.now()}`; + const banMeUnbanLaterMsg = `Ban me but unban me later! - ${Date.now()}`; + const bannedCheckMsg = `I'm banned :( - ${Date.now()}`; const msg3 = `Freedom! - ${Date.now()}`; await Promise.all([ recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { @@ -83,24 +83,38 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { newUser(windowB, 'Bob'), ]); await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); - await sendMessage(windowB, msg1); + await sendMessage(windowB, banMeUnbanLaterMsg); await windowA.bringToFront(); await scrollToBottomIfNecessary(windowA); - await clickOnWithText(windowA, Conversation.messageContent, msg1, { - rightButton: true, - maxWait: 15_000, - }); + await clickOnWithText( + windowA, + Conversation.messageContent, + banMeUnbanLaterMsg, + { + rightButton: true, + maxWait: 15_000, + }, + ); await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { strictMode: false, maxWait: 10_000, }); await clickOn(windowA, Conversation.banUserButton); - await pasteIntoInput(windowB, Conversation.messageInput.selector, msg2); + await pasteIntoInput( + windowB, + Conversation.messageInput.selector, + bannedCheckMsg, + ); await clickOn(windowB, Conversation.sendMessageButton); - await waitForMessageStatus(windowB, msg2, 'failed'); - await clickOnWithText(windowA, Conversation.messageContent, msg1, { - rightButton: true, - }); + await waitForMessageStatus(windowB, bannedCheckMsg, 'failed'); + await clickOnWithText( + windowA, + Conversation.messageContent, + banMeUnbanLaterMsg, + { + rightButton: true, + }, + ); await clickOnWithText(windowA, Global.contextMenuItem, unbanUserString, { strictMode: false, }); @@ -111,26 +125,23 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { Conversation.messageContent.selector, msg3, ); -}); -sessionTestTwoWindows('Ban And delete all', async ([windowA, windowB]) => { - assertAdminIsKnown(); - const msg1 = `Ban and delete! - ${Date.now()}`; - const msg2 = `Did that work? - ${Date.now()}`; - await Promise.all([ - recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { - fallbackName: 'Admin', - }), - newUser(windowB, 'Bob'), - ]); - await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); - await sendMessage(windowB, msg1); + // Now that the user is unban, check that we can ban and delete all. + // Note: a single test is doing all of those steps because having two of them on the same seed turns out to make both unreliable + const banAndDeleteAllMsg = `Ban and delete! - ${Date.now()}`; + const bannedCheckMsg2 = `Did that work? - ${Date.now()}`; + await sendMessage(windowB, banAndDeleteAllMsg); await windowA.bringToFront(); await scrollToBottomIfNecessary(windowA); - await clickOnWithText(windowA, Conversation.messageContent, msg1, { - rightButton: true, - maxWait: 15_000, - }); + await clickOnWithText( + windowA, + Conversation.messageContent, + banAndDeleteAllMsg, + { + rightButton: true, + maxWait: 15_000, + }, + ); await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { strictMode: false, maxWait: 10_000, @@ -141,15 +152,19 @@ sessionTestTwoWindows('Ban And delete all', async ([windowA, windowB]) => { Conversation.messageContent.strategy, Conversation.messageContent.selector, 10_000, - msg1, + banAndDeleteAllMsg, + ); + await pasteIntoInput( + windowB, + Conversation.messageInput.selector, + bannedCheckMsg2, ); - await pasteIntoInput(windowB, Conversation.messageInput.selector, msg2); await clickOn(windowB, Conversation.sendMessageButton); - await waitForMessageStatus(windowB, msg2, 'failed'); + await waitForMessageStatus(windowB, bannedCheckMsg2, 'failed'); await hasElementPoppedUpThatShouldnt( windowA, Conversation.messageContent.strategy, Conversation.messageContent.selector, - msg2, + bannedCheckMsg2, ); }); diff --git a/tests/automation/delete_account.spec.ts b/tests/automation/delete_account.spec.ts index f77d524..953f10f 100644 --- a/tests/automation/delete_account.spec.ts +++ b/tests/automation/delete_account.spec.ts @@ -124,6 +124,8 @@ sessionTestTwoWindows( ]); // Create contact and send new message await createContact(windowA, windowB, userA, userB); + // Allow some time so that Alice gets to push her first config message to the network + await sleepFor(5000, true); // Delete all data from device // Click on settings tab await clickOn(windowA, LeftPane.settingsButton); @@ -134,6 +136,7 @@ sessionTestTwoWindows( tStripped('sessionClearData'), ); // Keep 'Clear Device only' selection + await clickOnMatchingText(windowA, tStripped('clearDeviceOnly')); // Confirm deletion by clicking Clear, twice await clickOnMatchingText(windowA, tStripped('clear')); diff --git a/tests/automation/enforce_localized_str.spec.ts b/tests/automation/enforce_localized_str.spec.ts index 2102b6e..daf7aa7 100644 --- a/tests/automation/enforce_localized_str.spec.ts +++ b/tests/automation/enforce_localized_str.spec.ts @@ -124,6 +124,8 @@ function getExpectedStringFromKey( return 'Clear All'; case 'deleteMessageDeviceOnly': return 'Delete on this device only'; + case 'clearDeviceOnly': + return 'Clear device only'; case 'deleteMessageDevicesAll': return 'Delete on all my devices'; case 'deleteMessageDeletedLocally': diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index c697e0a..8157563 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -201,6 +201,7 @@ export async function waitForLoadingAnimationToFinish( shouldLog: false, }); + let hasLoggedAlready = false; do { try { loadingAnimation = await waitForElement({ @@ -211,7 +212,10 @@ export async function waitForLoadingAnimationToFinish( shouldLog: false, }); await sleepFor(500); - console.info(`${loader} was found, waiting for it to be gone`); + if (!hasLoggedAlready) { + console.info(`${loader} was found, waiting for it to be gone`); + hasLoggedAlready = true; + } } catch (_e) { loadingAnimation = undefined; } @@ -470,18 +474,22 @@ export async function hasElementBeenDeleted( console.info( `waiting for element to be deleted "${strategy}:${selector}:${text}", maxWait: ${maxWait}ms`, ); + let hasLoggedAlready = false; do { try { el = await waitForElement({ window, strategy, selector, - maxWaitMs: maxWait, + maxWaitMs: 100, text, shouldLog: false, }); await sleepFor(100); - console.info(`Element has been found, waiting for deletion`); + if (!hasLoggedAlready) { + console.info(`Element has been found, waiting for deletion`); + hasLoggedAlready = true; + } } catch (_e) { el = undefined; console.info(`Element has been deleted, woohoo!`); From 9fc3010e2d6ead1a0fa1df09a6bad84e562c31fb Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Mar 2026 16:48:53 +1100 Subject: [PATCH 04/17] chore: attempt making ban logic reliable --- tests/automation/community_tests.spec.ts | 56 +++++++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index cfa32d7..3003938 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -1,4 +1,7 @@ +import type { Page } from '@playwright/test'; + import { tStripped } from '../localization/lib'; +import { sleepFor } from '../promise_utils'; import { testCommunityName } from './constants/community'; import { Conversation, Global, HomeScreen } from './locators'; import { newUser } from './setup/new_user'; @@ -71,6 +74,42 @@ test_Alice_1W_Bob_1W( }, ); +async function scrollToBottomLookingForMessage({ + window, + msg, +}: { + window: Page; + msg: string; +}) { + // It seems that for communities, we sometimes need to press multiple times the scroll to bottom + // button for the message to be visible. + const start = Date.now(); + do { + try { + await window.bringToFront(); + + await scrollToBottomIfNecessary(window); + await waitForTestIdWithText( + window, + Conversation.messageContent.selector, + msg, + 1_000, + ); + } catch (_e) { + // nothing to do here + } + await sleepFor(1000, true); + } while (Date.now() - start < 15_000); + + // this just checks if the message is visible or not after exiting the loop. i.e. this will throw if the message is not visible + await waitForTestIdWithText( + window, + Conversation.messageContent.selector, + msg, + 1_000, + ); +} + sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { assertAdminIsKnown(); const banMeUnbanLaterMsg = `Ban me but unban me later! - ${Date.now()}`; @@ -84,15 +123,16 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { ]); await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); await sendMessage(windowB, banMeUnbanLaterMsg); - await windowA.bringToFront(); - await scrollToBottomIfNecessary(windowA); + await scrollToBottomLookingForMessage({ + window: windowA, + msg: banMeUnbanLaterMsg, + }); await clickOnWithText( windowA, Conversation.messageContent, banMeUnbanLaterMsg, { rightButton: true, - maxWait: 15_000, }, ); await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { @@ -131,20 +171,22 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { const banAndDeleteAllMsg = `Ban and delete! - ${Date.now()}`; const bannedCheckMsg2 = `Did that work? - ${Date.now()}`; await sendMessage(windowB, banAndDeleteAllMsg); - await windowA.bringToFront(); - await scrollToBottomIfNecessary(windowA); + await scrollToBottomLookingForMessage({ + window: windowA, + msg: banMeUnbanLaterMsg, + }); await clickOnWithText( windowA, Conversation.messageContent, banAndDeleteAllMsg, { rightButton: true, - maxWait: 15_000, + maxWait: 1_000, }, ); await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { strictMode: false, - maxWait: 10_000, + maxWait: 1_000, }); await clickOn(windowA, Conversation.banAndDeleteAllButton); await hasElementBeenDeleted( From 98db1fa9e263eb35fdf463ad3c047911c6e4b3b7 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 4 Mar 2026 12:02:08 +1100 Subject: [PATCH 05/17] chore: refactored to make waitForElement take the usual type of args --- tests/automation/community_tests.spec.ts | 17 +-- tests/automation/delete_account.spec.ts | 35 ++--- .../disappearing_message_checks.spec.ts | 88 ++++++------ .../automation/disappearing_messages.spec.ts | 19 +-- .../group_disappearing_messages.spec.ts | 27 ++-- tests/automation/landing_page.spec.ts | 29 ++-- tests/automation/linked_device_group.spec.ts | 13 +- tests/automation/linked_device_user.spec.ts | 98 ++++--------- tests/automation/locators/index.ts | 27 ++++ tests/automation/message_checks.spec.ts | 62 ++++---- .../automation/message_checks_groups.spec.ts | 16 +-- tests/automation/types/testing.ts | 3 + tests/automation/user_actions.spec.ts | 22 ++- tests/automation/utilities/join_community.ts | 11 +- tests/automation/utilities/leave_group.ts | 13 +- tests/automation/utilities/message.ts | 10 +- tests/automation/utilities/reply_message.ts | 17 ++- .../utilities/set_disappearing_messages.ts | 40 ++++-- tests/automation/utilities/utils.ts | 135 ++++++++++-------- 19 files changed, 333 insertions(+), 349 deletions(-) diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index 3003938..63aaf42 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -114,7 +114,7 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { assertAdminIsKnown(); const banMeUnbanLaterMsg = `Ban me but unban me later! - ${Date.now()}`; const bannedCheckMsg = `I'm banned :( - ${Date.now()}`; - const msg3 = `Freedom! - ${Date.now()}`; + const freedomNowMsg = `Freedom! - ${Date.now()}`; await Promise.all([ recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { fallbackName: 'Admin', @@ -159,11 +159,11 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { strictMode: false, }); await clickOn(windowA, Conversation.unbanUserButton); - await sendMessage(windowB, msg3); + await sendMessage(windowB, freedomNowMsg); await waitForTestIdWithText( windowA, Conversation.messageContent.selector, - msg3, + freedomNowMsg, ); // Now that the user is unban, check that we can ban and delete all. @@ -189,13 +189,10 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { maxWait: 1_000, }); await clickOn(windowA, Conversation.banAndDeleteAllButton); - await hasElementBeenDeleted( - windowA, - Conversation.messageContent.strategy, - Conversation.messageContent.selector, - 10_000, - banAndDeleteAllMsg, - ); + await hasElementBeenDeleted(windowA, Conversation.messageContent, { + maxWait: 10_000, + text: banAndDeleteAllMsg, + }); await pasteIntoInput( windowB, Conversation.messageInput.selector, diff --git a/tests/automation/delete_account.spec.ts b/tests/automation/delete_account.spec.ts index 953f10f..acc8f12 100644 --- a/tests/automation/delete_account.spec.ts +++ b/tests/automation/delete_account.spec.ts @@ -92,18 +92,19 @@ sessionTestTwoWindows( await hasElementBeenDeleted( restoringWindow, - 'data-testid', - HomeScreen.conversationItemName.selector, - 5_000, + HomeScreen.conversationItemName, + { maxWait: 5_000 }, ); await clickOn(restoringWindow, HomeScreen.plusButton); // Expect contacts list to be empty await hasElementBeenDeleted( restoringWindow, - 'data-testid', - Global.contactItem.selector, - 10000, + + Global.contactItem, + { + maxWait: 10_000, + }, ); } finally { if (restoringWindows) { @@ -150,21 +151,23 @@ sessionTestTwoWindows( await waitForElement({ window: restoringWindow, - strategy: 'data-testid', - selector: HomeScreen.conversationItemName.selector, - maxWaitMs: 10_000, - shouldLog: true, - text: userB.userName, + locator: HomeScreen.conversationItemName, + options: { + maxWaitMs: 10_000, + shouldLog: true, + text: userB.userName, + }, }); // Check if contact is available in contacts section await clickOn(restoringWindow, HomeScreen.plusButton); await waitForElement({ window: restoringWindow, - strategy: 'data-testid', - selector: Global.contactItem.selector, - maxWaitMs: 1000, - shouldLog: true, - text: userB.userName, + locator: Global.contactItem, + options: { + maxWaitMs: 1000, + shouldLog: true, + text: userB.userName, + }, }); console.log('Contacts have been restored'); } finally { diff --git a/tests/automation/disappearing_message_checks.spec.ts b/tests/automation/disappearing_message_checks.spec.ts index 0175ab2..372c84a 100644 --- a/tests/automation/disappearing_message_checks.spec.ts +++ b/tests/automation/disappearing_message_checks.spec.ts @@ -93,12 +93,9 @@ mediaArray.forEach( if (mediaType === 'voice') { await waitForTestIdWithText(bobWindow1, 'audio-player'); await sleepFor(30000); - await hasElementBeenDeleted( - bobWindow1, - 'data-testid', - 'audio-player', - 1_000, - ); + await hasElementBeenDeleted(bobWindow1, Conversation.audioPlayer, { + maxWait: 1_000, + }); } else { await waitForTextMessage(bobWindow1, testMessage); // Wait 30 seconds for image to disappear @@ -184,21 +181,20 @@ test_Alice_1W_Bob_1W( await sendLinkPreview(aliceWindow1, testLink); await waitForElement({ window: bobWindow1, - strategy: 'data-testid', - selector: 'msg-link-preview-title', - maxWaitMs: 3_000, - shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + locator: Conversation.linkPreviewTitle, + + options: { + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }, }); // Wait 30 seconds for link preview to disappear await sleepFor(30_000); - await hasElementBeenDeleted( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - 1_000, // no need to wait too long here, it should have disappeared already - 'Session | Send Messages, Not Metadata. | Private Messenger', - ); + await hasElementBeenDeleted(bobWindow1, Conversation.linkPreviewTitle, { + maxWait: 1_000, // no need to wait too long here, it should have disappeared already + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }); }, ); @@ -259,11 +255,12 @@ test_Alice_1W_Bob_1W( [aliceWindow1, bobWindow1].map((w) => waitForElement({ window: w, - strategy: 'class', - selector: 'group-name', - maxWaitMs: 15_000, - shouldLog: true, - text: testCommunityName, + locator: Conversation.groupName, + options: { + maxWaitMs: 15_000, + shouldLog: true, + text: testCommunityName, + }, }), ), ); @@ -271,13 +268,10 @@ test_Alice_1W_Bob_1W( await sleepFor(30000); await Promise.all( [bobWindow1, aliceWindow1].map((w) => - hasElementBeenDeleted( - w, - 'class', - 'group-name', - 1_000, - testCommunityName, - ), + hasElementBeenDeleted(w, Conversation.groupName, { + maxWait: 1_000, + text: testCommunityName, + }), ), ); }, @@ -316,11 +310,15 @@ test_Alice_1W_Bob_1W( await makeVoiceCall(aliceWindow1, bobWindow1); // In the receivers window, the message is 'Call in progress' await Promise.all([ - waitForTestIdWithText( - bobWindow1, - 'call-notification-answered-a-call', - tStripped('callsInProgress'), - ), + waitForElement({ + window: bobWindow1, + locator: Conversation.callNotificationAnswered, + options: { + text: tStripped('callsInProgress'), + shouldLog: true, + maxWaitMs: 15_000, + }, + }), // In the callers window, the message is 'You called {receiverName}' waitForTestIdWithText( aliceWindow1, @@ -332,19 +330,17 @@ test_Alice_1W_Bob_1W( await sleepFor(30000); await Promise.all([ - hasElementBeenDeleted( - bobWindow1, - 'data-testid', - 'call-notification-answered-a-call', - 1_000, - tStripped('callsInProgress'), - ), + hasElementBeenDeleted(bobWindow1, Conversation.callNotificationAnswered, { + maxWait: 1_000, + text: tStripped('callsInProgress'), + }), hasElementBeenDeleted( aliceWindow1, - 'data-testid', - 'call-notification-started-call', - 1_000, - tStripped('callsYouCalled', { name: bob.userName }), + Conversation.callNotificationStarted, + { + maxWait: 1_000, + text: tStripped('callsYouCalled', { name: bob.userName }), + }, ), ]); }, diff --git a/tests/automation/disappearing_messages.spec.ts b/tests/automation/disappearing_messages.spec.ts index fad880d..08438e8 100644 --- a/tests/automation/disappearing_messages.spec.ts +++ b/tests/automation/disappearing_messages.spec.ts @@ -1,7 +1,7 @@ import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { defaultDisappearingOptions } from './constants/variables'; -import { Conversation, HomeScreen } from './locators'; +import { Conversation, Global, HomeScreen } from './locators'; import { test_Alice_2W, test_Alice_2W_Bob_1W, @@ -299,11 +299,9 @@ test_Alice_2W_Bob_1W( bobWindow1, tStripped('disappearingMessagesFollowSetting'), ); - await clickOnElement({ - window: bobWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); + + await clickOn(bobWindow1, Global.confirmButton); + // Check control message are visible and correct // Each window has two control messages: You turned off and other user turned off (because we're following settings) await Promise.all([ @@ -340,12 +338,9 @@ test_Alice_2W_Bob_1W( ]); await Promise.all( [aliceWindow1, aliceWindow2, bobWindow1].map((w) => - hasElementBeenDeleted( - w, - 'data-testid', - 'disappear-messages-type-and-time', - 1_000, - ), + hasElementBeenDeleted(w, Conversation.DisappearMessagesTypeAndTime, { + maxWait: 1_000, + }), ), ); }, diff --git a/tests/automation/group_disappearing_messages.spec.ts b/tests/automation/group_disappearing_messages.spec.ts index 9a62311..8409bb5 100644 --- a/tests/automation/group_disappearing_messages.spec.ts +++ b/tests/automation/group_disappearing_messages.spec.ts @@ -5,6 +5,7 @@ import { mediaArray, testLink, } from './constants/variables'; +import { Conversation } from './locators'; import { test_group_Alice_1W_Bob_1W_Charlie_1W } from './setup/sessionTest'; import { sendMessage } from './utilities/message'; import { @@ -64,7 +65,9 @@ mediaArray.forEach(({ mediaType, path, shouldCheckMediaPreview }) => { await sleepFor(10000); await Promise.all( [bobWindow1, charlieWindow1].map((w) => - hasElementBeenDeleted(w, 'data-testid', 'audio-player', 1_000), + hasElementBeenDeleted(w, Conversation.audioPlayer, { + maxWait: 1_000, + }), ), ); } else { @@ -121,11 +124,12 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( [bobWindow1, charlieWindow1].map((w) => waitForElement({ window: w, - strategy: 'data-testid', - selector: 'msg-link-preview-title', - maxWaitMs: 3_000, - shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + locator: Conversation.linkPreviewTitle, + options: { + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }, }), ), ); @@ -133,13 +137,10 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await sleepFor(30000); await Promise.all( [bobWindow1, charlieWindow1].map((w) => - hasElementBeenDeleted( - w, - 'data-testid', - 'msg-link-preview-title', - 1_000, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ), + hasElementBeenDeleted(w, Conversation.linkPreviewTitle, { + maxWait: 1_000, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }), ), ); }, diff --git a/tests/automation/landing_page.spec.ts b/tests/automation/landing_page.spec.ts index fca50e4..c848232 100644 --- a/tests/automation/landing_page.spec.ts +++ b/tests/automation/landing_page.spec.ts @@ -1,4 +1,5 @@ import { tStripped } from '../localization/lib'; +import { Conversation } from './locators'; import { test_Alice_2W } from './setup/sessionTest'; import { hasElementPoppedUpThatShouldnt, @@ -12,10 +13,8 @@ test_Alice_2W( [aliceWindow1, aliceWindow2].map((w) => waitForElement({ window: w, - strategy: 'class', - selector: 'session-conversation', - maxWaitMs: 1000, - shouldLog: true, + locator: Conversation.SessionConversation, + options: { maxWaitMs: 1000, shouldLog: true }, }), ), ); @@ -32,11 +31,12 @@ test_Alice_2W( ].map(async (builder) => waitForElement({ window: aliceWindow1, - strategy: 'data-testid', - selector: 'empty-msg-view-account-created', - maxWaitMs: 1_000, - shouldLog: true, - text: builder.toString(), + locator: Conversation.EmptyMessageViewCreated, + options: { + maxWaitMs: 1_000, + shouldLog: true, + text: builder.toString(), + }, }), ), ); @@ -49,11 +49,12 @@ test_Alice_2W( ].map(async (builder) => waitForElement({ window: aliceWindow2, - strategy: 'data-testid', - selector: 'empty-msg-view-welcome', - maxWaitMs: 1_000, - shouldLog: true, - text: builder.toString(), + locator: Conversation.EmptyMessageViewWelcome, + options: { + maxWaitMs: 1_000, + shouldLog: true, + text: builder.toString(), + }, }), ), ); diff --git a/tests/automation/linked_device_group.spec.ts b/tests/automation/linked_device_group.spec.ts index 44aaf96..d4f2601 100644 --- a/tests/automation/linked_device_group.spec.ts +++ b/tests/automation/linked_device_group.spec.ts @@ -211,7 +211,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ]); await clickOn(aliceWindow2, Global.cancelButton); await clickOn(aliceWindow2, Global.modalCloseButton); - // Delete device data on alicewindow2 + // Delete device data on aliceWindow2 await clearDataOnWindow(aliceWindow2); const [restoredWindow] = await openApp(1); await recoverFromSeed(restoredWindow, alice.recoveryPassword); @@ -336,13 +336,10 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( ); await Promise.all( [aliceWindow1, aliceWindow2].map(async (w) => { - await hasElementBeenDeleted( - w, - 'data-testid', - HomeScreen.conversationItemName.selector, - 10_000, - groupCreated.userName, - ); + await hasElementBeenDeleted(w, HomeScreen.conversationItemName, { + maxWait: 10_000, + text: groupCreated.userName, + }); }), ); }, diff --git a/tests/automation/linked_device_user.spec.ts b/tests/automation/linked_device_user.spec.ts index d1d563c..e1ac308 100644 --- a/tests/automation/linked_device_user.spec.ts +++ b/tests/automation/linked_device_user.spec.ts @@ -18,7 +18,7 @@ import { } from './setup/sessionTest'; import { createContact } from './utilities/create_contact'; import { linkedDevice } from './utilities/linked_device'; -import { sendMessage } from './utilities/message'; +import { deleteMessageFor, sendMessage } from './utilities/message'; import { compareElementScreenshot } from './utilities/screenshot'; import { sendNewMessage } from './utilities/send_message'; import { @@ -26,7 +26,6 @@ import { clickOn, clickOnElement, clickOnMatchingText, - clickOnTextMessage, clickOnWithText, doWhileWithMax, hasElementBeenDeleted, @@ -194,25 +193,18 @@ test_Alice_2W_Bob_1W( waitForTextMessage(aliceWindow2, messageToDelete, 15_000), waitForTextMessage(bobWindow1, messageToDelete, 15_000), ]); - await clickOnTextMessage(aliceWindow1, messageToDelete, true, 1_000); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('deleteMessageDeviceOnly'), - ); + await deleteMessageFor(aliceWindow1, messageToDelete, 'device_only'); - await clickOnWithText( - aliceWindow1, - Global.confirmButton, - tStripped('delete'), - ); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); + await sleepFor(15_000, true); // explicit wait to make the delete was a local delete only (and had time to propagate if not) await Promise.all([ - hasTextMessageBeenDeleted(aliceWindow1, messageToDelete, 6_000), // should only be deleted locally + // the content of the original message should be removed on device that removed it + hasTextMessageBeenDeleted(aliceWindow1, messageToDelete, 6_000), + // tombstone should be visible on device that removed it + waitForMatchingText( + aliceWindow1, + tStripped('deleteMessageDeletedLocally'), + 6_000, + ), waitForMatchingText(aliceWindow2, messageToDelete, 15_000), // should still be here on linked device waitForMatchingText(bobWindow1, messageToDelete, 15_000), // should still be here on bob ]); @@ -235,19 +227,8 @@ test_Alice_2W_Bob_1W( waitForTextMessage(aliceWindow2, unsentMessage), waitForTextMessage(bobWindow1, unsentMessage), ]); - await clickOnTextMessage(aliceWindow1, unsentMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText(aliceWindow1, tStripped('deleteMessageEveryone')); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); + await deleteMessageFor(aliceWindow1, unsentMessage, 'for_everyone'); + await hasTextMessageBeenDeleted(aliceWindow1, unsentMessage, 1000); await waitForMatchingText( bobWindow1, @@ -274,22 +255,10 @@ test_Alice_2W( waitForTextMessage(aliceWindow1, unsentMessage), waitForTextMessage(aliceWindow2, unsentMessage), ]); - await clickOnTextMessage(aliceWindow1, unsentMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('deleteMessageDevicesAll'), - ); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); + + await clickOn(aliceWindow1, Global.confirmButton); + await deleteMessageFor(aliceWindow1, unsentMessage, 'for_all_my_devices'); + // in NTS, a message deleted on all our devices is removed entirely (the tombstone is not left) await Promise.all( [aliceWindow1, aliceWindow2].map((w) => @@ -415,13 +384,10 @@ test_Alice_2W_Bob_1W( // Need to wait for deletion to propagate to linked device await Promise.all( [aliceWindow1, aliceWindow2].map((w) => - hasElementBeenDeleted( - w, - 'data-testid', - HomeScreen.conversationItemName.selector, - 10_000, - bob.userName, - ), + hasElementBeenDeleted(w, HomeScreen.conversationItemName, { + maxWait: 10_000, + text: bob.userName, + }), ), ); }, @@ -475,20 +441,14 @@ test_Alice_2W( // Check linked device for hidden note to self await sleepFor(1000); await Promise.all([ - hasElementBeenDeleted( - aliceWindow1, - 'data-testid', - HomeScreen.conversationItemName.selector, - 5000, - tStripped('noteToSelf'), - ), - hasElementBeenDeleted( - aliceWindow2, - 'data-testid', - HomeScreen.conversationItemName.selector, - 15_000, - tStripped('noteToSelf'), - ), + hasElementBeenDeleted(aliceWindow1, HomeScreen.conversationItemName, { + maxWait: 5000, + text: tStripped('noteToSelf'), + }), + hasElementBeenDeleted(aliceWindow2, HomeScreen.conversationItemName, { + maxWait: 15_000, + text: tStripped('noteToSelf'), + }), ]); }, ); diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index 8cde5d8..ca28917 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -100,12 +100,17 @@ export class Conversation extends Locator { static readonly disappearingControlMessage = this.testId( 'disappear-control-message', ); + static readonly DisappearMessagesTypeAndTime = this.testId( + 'disappear-messages-type-and-time', + ); static readonly quoteText = this.testId('quote-text'); static readonly endCallButton = this.testId('end-call'); static readonly endVoiceMessageButton = this.testId('end-voice-message'); static readonly mentionsContainer = this.testId('mentions-container'); // This is also the locator for emojis static readonly mentionsItem = this.testId('mentions-container-row'); // This is also the locator for emojis static readonly messageContent = this.testId('message-content'); + static readonly linkPreviewTitle = this.className('msg-link-preview-title'); + static readonly messageInput = this.testId('message-input-text-area'); static readonly messageRequestAcceptControlMessage = this.testId( 'message-request-response-message', @@ -114,6 +119,28 @@ export class Conversation extends Locator { static readonly scrollToBottomButton = this.testId('scroll-to-bottom-button'); static readonly sendMessageButton = this.testId('send-message-button'); static readonly unbanUserButton = this.testId('unban-user-confirm-button'); + + static readonly groupName = this.className('group-name'); + + static readonly audioPlayer = this.testId('audio-player'); + static readonly callNotificationAnswered = this.testId( + 'call-notification-answered-a-call', + ); + static readonly callNotificationStarted = this.testId( + 'call-notification-started-call', + ); + + static readonly tooltipCharacterCount = this.testId( + 'tooltip-character-count', + ); + + static readonly SessionConversation = this.className('session-conversation'); + static readonly EmptyMessageViewCreated = this.testId( + 'empty-msg-view-account-created', + ); + static readonly EmptyMessageViewWelcome = this.testId( + 'empty-msg-view-welcome', + ); } export class ConversationSettings extends Locator { diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index a01a1bd..c35a029 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -31,8 +31,6 @@ import { checkModalStrings, clickOn, clickOnElement, - clickOnMatchingText, - clickOnTextMessage, clickOnWithText, hasElementPoppedUpThatShouldnt, hasTextMessageBeenDeleted, @@ -72,8 +70,7 @@ mediaArray.forEach( if (mediaType === 'voice') { await replyToMedia({ senderWindow: bobWindow1, - strategy: 'data-testid', - selector: 'audio-player', + locator: Conversation.audioPlayer, replyText: testReply, receiverWindow: aliceWindow1, }); @@ -123,11 +120,12 @@ test_Alice_1W_Bob_1W( await sendLinkPreview(aliceWindow1, testLink); await waitForElement({ window: bobWindow1, - strategy: 'data-testid', - selector: 'msg-link-preview-title', - maxWaitMs: 3_000, - shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + locator: Conversation.linkPreviewTitle, + options: { + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }, }); await replyTo({ senderWindow: bobWindow1, @@ -168,11 +166,13 @@ test_Alice_1W_Bob_1W( [aliceWindow1, bobWindow1].map((w) => waitForElement({ window: w, - strategy: 'class', - selector: 'group-name', - maxWaitMs: 15_000, - shouldLog: true, - text: testCommunityName, + locator: Conversation.groupName, + + options: { + maxWaitMs: 15_000, + shouldLog: true, + text: testCommunityName, + }, }), ), ); @@ -187,19 +187,8 @@ test_Alice_1W_Bob_1W( await sendMessage(aliceWindow1, unsendMessage); await waitForTextMessage(bobWindow1, unsendMessage); - await clickOnTextMessage(aliceWindow1, unsendMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText(aliceWindow1, tStripped('deleteMessageEveryone')); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); + await deleteMessageFor(aliceWindow1, unsendMessage, 'for_everyone'); + await sleepFor(1000); await waitForMatchingText( bobWindow1, @@ -319,20 +308,21 @@ messageLengthTestCases.forEach((testCase) => { // Check countdown behavior if (expectedCount) { - await waitForTestIdWithText( - aliceWindow1, - 'tooltip-character-count', - expectedCount, - ); + await waitForElement({ + window: aliceWindow1, + locator: Conversation.tooltipCharacterCount, + options: { text: expectedCount }, + }); } else { // Verify countdown tooltip is not present try { await waitForElement({ window: aliceWindow1, - strategy: 'data-testid', - selector: 'tooltip-character-count', - maxWaitMs: 1_000, - shouldLog: true, + locator: Conversation.tooltipCharacterCount, + options: { + maxWaitMs: 1_000, + shouldLog: true, + }, }); throw new Error( `Countdown should not be visible for messages under ${countdownThreshold} chars`, diff --git a/tests/automation/message_checks_groups.spec.ts b/tests/automation/message_checks_groups.spec.ts index 5d45404..c683d2c 100644 --- a/tests/automation/message_checks_groups.spec.ts +++ b/tests/automation/message_checks_groups.spec.ts @@ -1,7 +1,7 @@ import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { longText, mediaArray, testLink } from './constants/variables'; -import { HomeScreen } from './locators'; +import { Conversation, HomeScreen } from './locators'; import { test_group_Alice_1W_Bob_1W_Charlie_1W, test_group_Alice_2W_Bob_1W_Charlie_1W, @@ -71,8 +71,7 @@ mediaArray.forEach(({ mediaType, path, shouldCheckMediaPreview }) => { if (mediaType === 'voice') { await replyToMedia({ senderWindow: bobWindow1, - strategy: 'data-testid', - selector: 'audio-player', + locator: Conversation.audioPlayer, replyText: testReply, receiverWindow: aliceWindow1, }); @@ -137,11 +136,12 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( [bobWindow1, charlieWindow1].map((w) => waitForElement({ window: w, - strategy: 'data-testid', - selector: 'msg-link-preview-title', - maxWaitMs: 3_000, - shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + locator: Conversation.linkPreviewTitle, + options: { + maxWaitMs: 3_000, + shouldLog: true, + text: 'Session | Send Messages, Not Metadata. | Private Messenger', + }, }), ), ); diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index a2321e3..27662d8 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -131,6 +131,8 @@ export type DataTestId = | 'edit-profile-icon' | 'empty-conversation-control-message' | 'empty-conversation-notification' + | 'empty-msg-view-account-created' + | 'empty-msg-view-welcome' | 'enable-calls-settings-row' | 'enable-communities-message-requests-settings-row' | 'enable-microphone-settings-row' @@ -188,6 +190,7 @@ export type DataTestId = | 'password-input-reconfirm' | 'password-input' | 'path-light-container' + | 'path-light-svg' | 'privacy-settings-menu-item' | 'profile-name-input' | 'quote-text' diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index 6ddccb9..8c9d366 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -344,13 +344,10 @@ test_Alice_1W_Bob_1W( tStripped('delete'), ); // Check if conversation is deleted - await hasElementBeenDeleted( - aliceWindow1, - 'data-testid', - Global.contactItem.selector, - 1000, - bob.userName, - ); + await hasElementBeenDeleted(aliceWindow1, Global.contactItem, { + maxWait: 1000, + text: bob.userName, + }); }, ); @@ -466,13 +463,10 @@ test_Alice_1W_no_network( Global.confirmButton, tStripped('hide'), ); - await hasElementBeenDeleted( - aliceWindow1, - 'data-testid', - 'module-conversation__user__profile-name', - 5000, - tStripped('noteToSelf'), - ); + await hasElementBeenDeleted(aliceWindow1, HomeScreen.conversationItemName, { + maxWait: 5000, + text: tStripped('noteToSelf'), + }); }, ); diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index 1d25f94..ebc0f35 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -56,13 +56,10 @@ export const leaveCommunity = async (window: Page, communityName: string) => { ); await clickOnWithText(window, Global.contextMenuItem, 'Leave Community'); await clickOn(window, Global.confirmButton); - await hasElementBeenDeleted( - window, - HomeScreen.conversationItemName.strategy, - HomeScreen.conversationItemName.selector, - 5_000, - communityName, - ); + await hasElementBeenDeleted(window, HomeScreen.conversationItemName, { + maxWait: 5_000, + text: communityName, + }); console.log('Left community'); }; diff --git a/tests/automation/utilities/leave_group.ts b/tests/automation/utilities/leave_group.ts index 38d3159..71b8db8 100644 --- a/tests/automation/utilities/leave_group.ts +++ b/tests/automation/utilities/leave_group.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; -import { Conversation, Global } from '../locators'; +import { Conversation, Global, HomeScreen } from '../locators'; import { Group } from '../types/testing'; import { clickOn, @@ -18,11 +18,8 @@ export const leaveGroup = async (window: Page, group: Group) => { // Confirm leave group await clickOnWithText(window, Global.confirmButton, tStripped('leave')); // check config message - await hasElementBeenDeleted( - window, - 'data-testid', - 'module-conversation__user__profile-name', - 5_000, - group.userName, - ); + await hasElementBeenDeleted(window, HomeScreen.conversationItemName, { + maxWait: 5_000, + text: group.userName, + }); }; diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index dfcb8ae..b845fe8 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -1,9 +1,11 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; +import { Global } from '../locators'; import { MessageStatus } from '../types/testing'; import { checkModalStrings, + clickOn, clickOnElement, clickOnMatchingText, clickOnTextMessage, @@ -58,11 +60,9 @@ export async function deleteMessageFor( } await checkModalStrings(window, tStripped('deleteMessage', { count: 1 })); - await clickOnElement({ - window, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); + + await clickOn(window, Global.confirmButton); + await waitForTestIdWithText( window, 'session-toast', diff --git a/tests/automation/utilities/reply_message.ts b/tests/automation/utilities/reply_message.ts index c72c529..a585ea1 100644 --- a/tests/automation/utilities/reply_message.ts +++ b/tests/automation/utilities/reply_message.ts @@ -3,7 +3,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; import { sleepFor } from '../../promise_utils'; import { Conversation } from '../locators'; -import { Strategy } from '../types/testing'; +import { type StrategyExtractionObj } from '../types/testing'; import { sendMessage } from './message'; import { verifyMediaPreviewLoaded } from './send_media'; import { @@ -81,23 +81,22 @@ export const replyTo = async ({ export const replyToMedia = async ({ replyText, - strategy, - selector, + locator, receiverWindow, senderWindow, }: { replyText: string; - strategy: Strategy; - selector: string; + locator: StrategyExtractionObj; receiverWindow: Page; senderWindow: Page; }) => { const selc = await waitForElement({ window: senderWindow, - strategy, - selector, - shouldLog: true, - maxWaitMs: 20_000, + locator, + options: { + shouldLog: true, + maxWaitMs: 20_000, + }, }); // the right click context menu, for some reasons, often doesn't show up on the first try. Let's loop a few times diff --git a/tests/automation/utilities/set_disappearing_messages.ts b/tests/automation/utilities/set_disappearing_messages.ts index 7593ae4..e0afe5e 100644 --- a/tests/automation/utilities/set_disappearing_messages.ts +++ b/tests/automation/utilities/set_disappearing_messages.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; -import { Conversation, ConversationSettings } from '../locators'; +import { Conversation, ConversationSettings, Global } from '../locators'; import { ConversationType, DataTestId, @@ -15,7 +15,6 @@ import { clickOnMatchingText, formatTimeOption, waitForElement, - waitForTestIdWithText, } from './utils'; export const setDisappearingMessages = async ( @@ -52,10 +51,14 @@ export const setDisappearingMessages = async ( const dataTestId: DataTestId = 'input-time-option-12-hours'; defaultTime = await waitForElement({ window: windowA, - strategy: 'data-testid', - selector: dataTestId, - maxWaitMs: 1_000, - shouldLog: true, + locator: { + strategy: 'data-testid', + selector: dataTestId, + }, + options: { + maxWaitMs: 1_000, + shouldLog: true, + }, }); } else { // making explicit DataTestId here as `waitForElement` currently allows a string @@ -63,10 +66,14 @@ export const setDisappearingMessages = async ( defaultTime = await waitForElement({ window: windowA, - strategy: 'data-testid', - selector: dataTestId, - maxWaitMs: 1_000, - shouldLog: true, + locator: { + strategy: 'data-testid', + selector: dataTestId, + }, + options: { + maxWaitMs: 1_000, + shouldLog: true, + }, }); } const checked = await isChecked(defaultTime); @@ -93,7 +100,10 @@ export const setDisappearingMessages = async ( strategy: 'data-testid', selector: 'modal-close-button', }); - await waitForTestIdWithText(windowA, 'disappear-messages-type-and-time'); + await waitForElement({ + window: windowA, + locator: Conversation.DisappearMessagesTypeAndTime, + }); if (windowB) { await clickOnMatchingText( windowB, @@ -119,11 +129,11 @@ export const setDisappearingMessages = async ( tStripped('disappearingMessagesFollowSetting'), modalDescription, ); - await clickOnElement({ + + await clickOn(windowB, Global.confirmButton); + await waitForElement({ window: windowB, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', + locator: Conversation.DisappearMessagesTypeAndTime, }); - await waitForTestIdWithText(windowB, 'disappear-messages-type-and-time'); } }; diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 8157563..2ca02bb 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -54,33 +54,29 @@ export async function waitForTestIdWithText( } export async function waitForElement({ - selector, - strategy, window, - maxWaitMs, - shouldLog, - text, + locator, + options, }: { window: Page; - strategy: Strategy; - selector: string; - maxWaitMs?: number; - text?: string; - shouldLog: boolean; + locator: StrategyExtractionObj; + options?: { maxWaitMs?: number; text?: string; shouldLog?: boolean }; }) { - const builtSelector = !text - ? `css=[${strategy}=${selector}]` - : `css=[${strategy}=${selector}]:has-text("${text.replace(/"/g, '\\"')}")`; + const builtSelector = !options?.text + ? `css=[${locator.strategy}=${locator.selector}]` + : `css=[${locator.strategy}=${locator.selector}]:has-text("${options.text.replace(/"/g, '\\"')}")`; const start = Date.now(); - if (shouldLog) { - console.log(`waitForElement: ${builtSelector} for maxMs ${maxWaitMs}`); + if (options?.shouldLog) { + console.log( + `waitForElement: ${builtSelector} for maxMs ${options?.maxWaitMs}`, + ); } const el = await window.waitForSelector(builtSelector, { - timeout: maxWaitMs, + timeout: options?.maxWaitMs, }); - if (shouldLog) { + if (options?.shouldLog) { console.log( `waitForElement: got ${builtSelector} after ${Date.now() - start}ms`, ); @@ -142,24 +138,28 @@ export async function waitForMatchingText( export async function waitForMatchingPlaceholder( window: Page, - dataTestId: string, + dataTestId: DataTestId, placeholder: string, maxWait: number = 30000, ) { let found = false; const start = Date.now(); console.info( - `waitForMatchingPlaceholder: ${placeholder} with datatestId: ${dataTestId}`, + `waitForMatchingPlaceholder: ${placeholder} with dataTestId: ${dataTestId}`, ); do { try { const elem = await waitForElement({ window, - strategy: 'data-testid', - selector: dataTestId, - shouldLog: false, - maxWaitMs: 100, + locator: { + strategy: 'data-testid', + selector: dataTestId, + }, + options: { + shouldLog: false, + maxWaitMs: 100, + }, }); const elemPlaceholder = await elem.getAttribute('placeholder'); if (elemPlaceholder === placeholder) { @@ -182,7 +182,7 @@ export async function waitForMatchingPlaceholder( if (!found) { throw new Error( - `Failed to find datatestid:"${dataTestId}" with placeholder: "${placeholder}"`, + `Failed to find dataTestId:"${dataTestId}" with placeholder: "${placeholder}"`, ); } } @@ -195,10 +195,15 @@ export async function waitForLoadingAnimationToFinish( await waitForElement({ window, - strategy: 'data-testid', - selector: `${loader}`, - maxWaitMs: maxWait, - shouldLog: false, + + locator: { + strategy: 'data-testid', + selector: `${loader}`, + }, + options: { + maxWaitMs: maxWait, + shouldLog: false, + }, }); let hasLoggedAlready = false; @@ -206,10 +211,14 @@ export async function waitForLoadingAnimationToFinish( try { loadingAnimation = await waitForElement({ window, - strategy: 'data-testid', - selector: `${loader}`, - maxWaitMs: 100, - shouldLog: false, + locator: { + strategy: 'data-testid', + selector: `${loader}`, + }, + options: { + maxWaitMs: 100, + shouldLog: false, + }, }); await sleepFor(500); if (!hasLoggedAlready) { @@ -261,10 +270,14 @@ export async function checkPathLight(window: Page, maxWait?: number) { await doWhileWithMax(maxWaitTime, waitPerLoop, 'checkPathLight', async () => { const pathLight = await waitForElement({ window, - strategy: 'data-testid', - selector: 'path-light-svg', - maxWaitMs: maxWait, - shouldLog: false, + locator: { + strategy: 'data-testid', + selector: 'path-light-svg', + }, + options: { + maxWaitMs: maxWait, + shouldLog: false, + }, }); pathFilter = await pathLight.getAttribute('style'); @@ -463,27 +476,29 @@ export async function grabTextFromElement( export async function hasElementBeenDeleted( window: Page, - strategy: Strategy, - selector: string, - maxWait: number, - text?: string, + locator: StrategyExtractionObj, + options: { + maxWait: number; + text?: string; + }, ) { const start = Date.now(); let el: ElementHandle | undefined; console.info( - `waiting for element to be deleted "${strategy}:${selector}:${text}", maxWait: ${maxWait}ms`, + `waiting for element to be deleted "${locator.strategy}:${locator.selector}:${options.text}", maxWait: ${options.maxWait}ms`, ); let hasLoggedAlready = false; do { try { el = await waitForElement({ window, - strategy, - selector, - maxWaitMs: 100, - text, - shouldLog: false, + locator, + options: { + maxWaitMs: 100, // the outer loop is the one using the options.maxWait, not this one. + text: options.text, + shouldLog: false, + }, }); await sleepFor(100); if (!hasLoggedAlready) { @@ -494,15 +509,16 @@ export async function hasElementBeenDeleted( el = undefined; console.info(`Element has been deleted, woohoo!`); } - } while (Date.now() - start <= maxWait && el); + } while (Date.now() - start <= options.maxWait && el); try { el = await waitForElement({ window, - strategy, - selector, - maxWaitMs: 1_000, - text, - shouldLog: false, + locator, + options: { + maxWaitMs: 100, // the element should be there once the loop exits. if it's not right away it's an error. + text: options.text, + shouldLog: false, + }, }); } catch (_e) { // if we did throw here it's actually because the element is gone, so it's ok @@ -510,11 +526,11 @@ export async function hasElementBeenDeleted( if (el) { throw new Error( - `hasElementBeenDeleted: element with selector ${selector} was expected to be gone but is still there`, + `hasElementBeenDeleted: element with selector ${locator.selector} was expected to be gone but is still there`, ); } console.info( - `Element "${strategy}:${selector}:${text}" has been deleted yay`, + `Element "${locator.strategy}:${locator.selector}:${options.text}" has been deleted yay`, ); } @@ -531,11 +547,12 @@ export async function hasTextMessageBeenDeleted( try { await waitForElement({ window, - strategy: 'data-testid', - selector: 'message-content', - maxWaitMs: maxWait, - text, - shouldLog: false, + locator: Conversation.messageContent, + options: { + maxWaitMs: maxWait, + text, + shouldLog: false, + }, }); return false; } catch (_e) { From f2fbfe06cfa99225782a132373900e58b7098775 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 4 Mar 2026 16:21:34 +1100 Subject: [PATCH 06/17] fix: wrapped up delete nts, 1o1 & groups changes --- tests/automation/community_tests.spec.ts | 10 +- tests/automation/constants/variables.ts | 2 + .../disappearing_message_checks.spec.ts | 27 +-- .../automation/disappearing_messages.spec.ts | 34 +-- .../group_disappearing_messages.spec.ts | 7 +- tests/automation/group_testing.spec.ts | 29 +-- tests/automation/group_upkeep.spec.ts | 63 ----- tests/automation/linked_device_group.spec.ts | 39 +-- .../automation/linked_device_requests.spec.ts | 23 +- tests/automation/linked_device_user.spec.ts | 97 +------- tests/automation/locators/index.ts | 7 +- tests/automation/message_checks.spec.ts | 163 +++++++------ .../automation/message_checks_groups.spec.ts | 223 +++++++----------- tests/automation/message_requests.spec.ts | 26 +- tests/automation/setup/create_group.ts | 6 +- tests/automation/types/testing.ts | 2 + tests/automation/user_actions.spec.ts | 14 +- tests/automation/utilities/conversation.ts | 11 + tests/automation/utilities/create_contact.ts | 1 - tests/automation/utilities/join_community.ts | 8 +- tests/automation/utilities/message.ts | 71 +++++- tests/automation/utilities/send_media.ts | 9 +- tests/automation/utilities/send_message.ts | 4 +- tests/automation/utilities/utils.ts | 40 +++- tests/promise_utils.ts | 19 +- 25 files changed, 374 insertions(+), 561 deletions(-) delete mode 100644 tests/automation/group_upkeep.spec.ts create mode 100644 tests/automation/utilities/conversation.ts diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index 63aaf42..6679bb6 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'; import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { testCommunityName } from './constants/community'; -import { Conversation, Global, HomeScreen } from './locators'; +import { Conversation, Global } from './locators'; import { newUser } from './setup/new_user'; import { recoverFromSeed } from './setup/recovery_using_seed'; import { @@ -11,6 +11,7 @@ import { test_Alice_1W_Bob_1W, test_Alice_2W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { assertAdminIsKnown, joinCommunity, @@ -39,11 +40,8 @@ test_Alice_2W( await scrollToBottomIfNecessary(aliceWindow1); await sendMessage(aliceWindow1, 'Hello, community!'); // Check linked device for community - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - testCommunityName, - ); + + await openConversationWith(aliceWindow2, testCommunityName); }, ); diff --git a/tests/automation/constants/variables.ts b/tests/automation/constants/variables.ts index 2151a27..3282c4f 100644 --- a/tests/automation/constants/variables.ts +++ b/tests/automation/constants/variables.ts @@ -10,6 +10,8 @@ export const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum quis lacinia mi. Praesent fermentum vehicula rhoncus. Aliquam ac purus lobortis, convallis nisi quis, pulvinar elit. Nam commodo eros in molestie lobortis. Donec at mattis est. In tempor ex nec velit mattis, vitae feugiat augue maximus. Nullam risus libero, bibendum et enim et, viverra viverra est. Suspendisse potenti. Sed ut nibh in sem rhoncus suscipit. Etiam tristique leo sit amet ullamcorper dictum. Suspendisse sollicitudin, lectus et suscipit eleifend, libero dui ultricies neque, non elementum nulla orci bibendum lorem. Suspendisse potenti. Aenean a tellus imperdiet, iaculis metus quis, pretium diam. Nunc varius vitae enim vestibulum interdum. In hac habitasse platea dictumst. Donec auctor sem quis eleifend fermentum. Vestibulum neque nulla, maximus non arcu gravida, condimentum euismod turpis. Cras ac mattis orci. Quisque ac enim pharetra felis sodales eleifend. Aliquam erat volutpat. Donec sit amet mollis nibh, eget feugiat ipsum. Integer vestibulum purus ac suscipit egestas. Duis vitae aliquet ligula.'; export const screenshotFolder = 'screenshots'; export const testLink = 'https://getsession.org/'; +export const testLinkTitle = + 'Session | Send Messages, Not Metadata. | Private Messenger'; export const mediaArray = [ { diff --git a/tests/automation/disappearing_message_checks.spec.ts b/tests/automation/disappearing_message_checks.spec.ts index 372c84a..865017e 100644 --- a/tests/automation/disappearing_message_checks.spec.ts +++ b/tests/automation/disappearing_message_checks.spec.ts @@ -6,14 +6,11 @@ import { longText, mediaArray, testLink, + testLinkTitle, } from './constants/variables'; -import { - Conversation, - ConversationSettings, - Global, - HomeScreen, -} from './locators'; +import { Conversation, ConversationSettings, Global } from './locators'; import { test_Alice_1W_Bob_1W } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { joinCommunity } from './utilities/join_community'; import { waitForMessageStatus } from './utilities/message'; @@ -182,18 +179,17 @@ test_Alice_1W_Bob_1W( await waitForElement({ window: bobWindow1, locator: Conversation.linkPreviewTitle, - options: { - maxWaitMs: 3_000, + maxWaitMs: 10_000, shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + text: testLinkTitle, }, }); // Wait 30 seconds for link preview to disappear await sleepFor(30_000); await hasElementBeenDeleted(bobWindow1, Conversation.linkPreviewTitle, { maxWait: 1_000, // no need to wait too long here, it should have disappeared already - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + text: testLinkTitle, }); }, ); @@ -246,16 +242,13 @@ test_Alice_1W_Bob_1W( .getByTestId('modal-close-button') .click(); await clickOn(aliceWindow1, Global.modalCloseButton); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + + await openConversationWith(aliceWindow1, bob.userName); await Promise.all( [aliceWindow1, bobWindow1].map((w) => waitForElement({ window: w, - locator: Conversation.groupName, + locator: Conversation.communityInvitationDetails, options: { maxWaitMs: 15_000, shouldLog: true, @@ -268,7 +261,7 @@ test_Alice_1W_Bob_1W( await sleepFor(30000); await Promise.all( [bobWindow1, aliceWindow1].map((w) => - hasElementBeenDeleted(w, Conversation.groupName, { + hasElementBeenDeleted(w, Conversation.communityInvitationDetails, { maxWait: 1_000, text: testCommunityName, }), diff --git a/tests/automation/disappearing_messages.spec.ts b/tests/automation/disappearing_messages.spec.ts index 08438e8..e1121ae 100644 --- a/tests/automation/disappearing_messages.spec.ts +++ b/tests/automation/disappearing_messages.spec.ts @@ -1,12 +1,13 @@ import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { defaultDisappearingOptions } from './constants/variables'; -import { Conversation, Global, HomeScreen } from './locators'; +import { Conversation, Global } from './locators'; import { test_Alice_2W, test_Alice_2W_Bob_1W, test_group_Alice_2W_Bob_1W_Charlie_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { sendMessage } from './utilities/message'; import { sendNewMessage } from './utilities/send_message'; @@ -15,7 +16,6 @@ import { clickOn, clickOnElement, clickOnMatchingText, - clickOnWithText, doesTextIncludeString, formatTimeOption, hasElementBeenDeleted, @@ -40,11 +40,7 @@ test_Alice_2W_Bob_1W( // Create Contact await createContact(aliceWindow1, bobWindow1, alice, bob); // Click on conversation in linked device - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow2, bob.userName); await setDisappearingMessages( aliceWindow1, @@ -96,11 +92,7 @@ test_Alice_2W_Bob_1W( await createContact(aliceWindow1, bobWindow1, alice, bob); // Click on conversation in linked device - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow2, bob.userName); await setDisappearingMessages( aliceWindow1, ['1:1', disappearingMessagesType, timeOption, disappearAction], @@ -146,11 +138,7 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( }); const testMessage = 'Testing disappearing messages in groups'; - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); await setDisappearingMessages(aliceWindow1, [ 'group', disappearingMessagesType, @@ -195,11 +183,7 @@ test_Alice_2W( // Open Note to self conversation await sendNewMessage(aliceWindow1, alice.accountid, testMessage); // Check messages are syncing across linked devices - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - tStripped('noteToSelf'), - ); + await openConversationWith(aliceWindow2, tStripped('noteToSelf')); await waitForTextMessage(aliceWindow2, testMessage); // Enable disappearing messages await setDisappearingMessages(aliceWindow1, [ @@ -232,11 +216,7 @@ test_Alice_2W_Bob_1W( const formattedTime = formatTimeOption(timeOption); await createContact(aliceWindow1, bobWindow1, alice, bob); // Click on conversation on linked device - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow2, bob.userName); // Set disappearing messages to on await setDisappearingMessages( aliceWindow1, diff --git a/tests/automation/group_disappearing_messages.spec.ts b/tests/automation/group_disappearing_messages.spec.ts index 8409bb5..0a58929 100644 --- a/tests/automation/group_disappearing_messages.spec.ts +++ b/tests/automation/group_disappearing_messages.spec.ts @@ -4,6 +4,7 @@ import { longText, mediaArray, testLink, + testLinkTitle, } from './constants/variables'; import { Conversation } from './locators'; import { test_group_Alice_1W_Bob_1W_Charlie_1W } from './setup/sessionTest'; @@ -111,7 +112,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ); test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Send disappearing link to groups', + 'Send disappearing link preview to groups', async ({ aliceWindow1, bobWindow1, charlieWindow1 }) => { await setDisappearingMessages(aliceWindow1, [ 'group', @@ -128,7 +129,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( options: { maxWaitMs: 3_000, shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + text: testLinkTitle, }, }), ), @@ -139,7 +140,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( [bobWindow1, charlieWindow1].map((w) => hasElementBeenDeleted(w, Conversation.linkPreviewTitle, { maxWait: 1_000, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + text: testLinkTitle, }), ), ); diff --git a/tests/automation/group_testing.spec.ts b/tests/automation/group_testing.spec.ts index fcefac6..762411b 100644 --- a/tests/automation/group_testing.spec.ts +++ b/tests/automation/group_testing.spec.ts @@ -1,11 +1,6 @@ import { tStripped } from '../localization/lib'; import { doForAll, sleepFor } from '../promise_utils'; -import { - Conversation, - ConversationSettings, - Global, - HomeScreen, -} from './locators'; +import { Conversation, ConversationSettings, Global } from './locators'; import { createGroup } from './setup/create_group'; import { newUser } from './setup/new_user'; import { @@ -13,6 +8,7 @@ import { test_group_Alice_1W_Bob_1W_Charlie_1W, test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { leaveGroup } from './utilities/leave_group'; import { renameGroup } from './utilities/rename_group'; @@ -63,11 +59,8 @@ test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( groupCreated, }) => { await createContact(aliceWindow1, draculaWindow1, alice, dracula); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + + await openConversationWith(aliceWindow1, groupCreated.userName); await clickOnElement({ window: aliceWindow1, strategy: 'data-testid', @@ -94,11 +87,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( }, [aliceWindow1, bobWindow1, charlieWindow1], ); - await clickOnWithText( - draculaWindow1, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(draculaWindow1, groupCreated.userName); }, ); @@ -165,13 +154,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( // All users open group conversation await Promise.all( - members.map((m) => - clickOnWithText( - m.window, - HomeScreen.conversationItemName, - groupCreated.userName, - ), - ), + members.map((m) => openConversationWith(m.window, groupCreated.userName)), ); // All users type @ to open mentions diff --git a/tests/automation/group_upkeep.spec.ts b/tests/automation/group_upkeep.spec.ts deleted file mode 100644 index 2819042..0000000 --- a/tests/automation/group_upkeep.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -// FIXME enable this test again once we fixed it -// sessionTestFiveWindows( -// 'Group upkeep - should be skipped', -// async ([windowA, windowB, windowC, windowD, windowE]) => { -// await Promise.all([ -// logIn(windowA, userA.recoveryPhrase), -// logIn(windowB, userB.recoveryPhrase), -// logIn(windowC, userC.recoveryPhrase), -// logIn(windowD, userD.recoveryPhrase), -// logIn(windowE, userE.recoveryPhrase), -// ]); -// // Send message from test users to all of it's contacts to maintain contact status - -// // Send message from user A to Whale(TC1) -// await sendNewMessage( -// windowA, -// userB.sessionid, -// `${userA.userName} -> ${userB.userName}: ${Date.now()}` -// ); -// // Send message from Whale to user A -// await sendNewMessage( -// windowB, -// userA.sessionid, -// `${userB.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// // Send message from user A to Dragon(TC2) -// await sendNewMessage( -// windowA, -// userC.sessionid, -// `${userA.userName} -> ${userC.userName}: ${Date.now()}` -// ); -// // Send message from Dragon to user A -// await sendNewMessage( -// windowC, -// userA.sessionid, -// `${userC.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// // Send message from user A to Fish(TC3) -// await sendNewMessage( -// windowA, -// userD.sessionid, -// `${userA.userName} -> ${userD.userName}: ${Date.now()}` -// ); -// // Send message from Fish to user A -// await sendNewMessage( -// windowD, -// userA.sessionid, -// `${userD.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// // Send message from user A to Gopher(TC4) -// await sendNewMessage( -// windowA, -// userE.sessionid, -// `${userA.userName} -> ${userD.userName}: ${Date.now()}` -// ); -// // Send message from Gopher to user A -// await sendNewMessage( -// windowE, -// userA.sessionid, -// `${userD.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// } -// ); diff --git a/tests/automation/linked_device_group.spec.ts b/tests/automation/linked_device_group.spec.ts index d4f2601..2b22b61 100644 --- a/tests/automation/linked_device_group.spec.ts +++ b/tests/automation/linked_device_group.spec.ts @@ -15,6 +15,7 @@ import { test_group_Alice_1W_Bob_1W_Charlie_1W, test_group_Alice_2W_Bob_1W_Charlie_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { leaveGroup } from './utilities/leave_group'; import { checkModalStrings, @@ -46,11 +47,8 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( // Check for user A for control message that userC left group // await sleepFor(1000); // Click on group - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow1, groupCreated.userName); + await waitForTestIdWithText( aliceWindow1, 'group-update-message', @@ -59,11 +57,7 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( }), ); // Check for linked device (userA) - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); await waitForTestIdWithText( aliceWindow2, 'group-update-message', @@ -96,11 +90,8 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); + // Check header name await waitForTestIdWithText( aliceWindow2, @@ -177,11 +168,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); // Check header name await waitForTestIdWithText( aliceWindow2, @@ -222,11 +209,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - restoredWindow, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(restoredWindow, groupCreated.userName); // Check header name await waitForTestIdWithText( restoredWindow, @@ -268,11 +251,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - restoredWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(restoredWindow2, groupCreated.userName); // Check header name await waitForTestIdWithText( restoredWindow2, diff --git a/tests/automation/linked_device_requests.spec.ts b/tests/automation/linked_device_requests.spec.ts index 0743020..4d5a570 100644 --- a/tests/automation/linked_device_requests.spec.ts +++ b/tests/automation/linked_device_requests.spec.ts @@ -8,6 +8,7 @@ import { Settings, } from './locators'; import { test_Alice_2W_Bob_1W } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { sendMessage } from './utilities/message'; import { sendNewMessage } from './utilities/send_message'; import { @@ -28,11 +29,8 @@ test_Alice_2W_Bob_1W( // Accept request in aliceWindow1 await clickOn(aliceWindow1, HomeScreen.messageRequestBanner); await clickOn(aliceWindow2, HomeScreen.messageRequestBanner); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); + await clickOn(aliceWindow1, Conversation.acceptMessageRequestButton); await waitForTestIdWithText( aliceWindow1, @@ -70,11 +68,8 @@ test_Alice_2W_Bob_1W( await sendNewMessage(bobWindow1, alice.accountid, testMessage); // Decline request in aliceWindow1 await clickOn(aliceWindow1, HomeScreen.messageRequestBanner); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); + await clickOn(aliceWindow2, HomeScreen.messageRequestBanner); await waitForTestIdWithText( aliceWindow2, @@ -110,11 +105,7 @@ test_Alice_2W_Bob_1W( // Check the message request banner appears and click on it await clickOn(aliceWindow1, HomeScreen.messageRequestBanner); // Select message request from Bob - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); // Block Bob await clickOn(aliceWindow1, Conversation.blockMessageRequestButton); // Check modal strings @@ -136,7 +127,7 @@ test_Alice_2W_Bob_1W( Global.contactItem.selector, bob.userName, ); - // Check that the blocked contacts is on alicewindow2 + // Check that the blocked contacts is on aliceWindow2 // Check blocked status in blocked contacts list await clickOn(aliceWindow2, LeftPane.settingsButton); await clickOn(aliceWindow2, Settings.conversationsMenuItem); diff --git a/tests/automation/linked_device_user.spec.ts b/tests/automation/linked_device_user.spec.ts index e1ac308..9644915 100644 --- a/tests/automation/linked_device_user.spec.ts +++ b/tests/automation/linked_device_user.spec.ts @@ -18,9 +18,8 @@ import { } from './setup/sessionTest'; import { createContact } from './utilities/create_contact'; import { linkedDevice } from './utilities/linked_device'; -import { deleteMessageFor, sendMessage } from './utilities/message'; +import { sendMessage } from './utilities/message'; import { compareElementScreenshot } from './utilities/screenshot'; -import { sendNewMessage } from './utilities/send_message'; import { checkModalStrings, clickOn, @@ -29,13 +28,10 @@ import { clickOnWithText, doWhileWithMax, hasElementBeenDeleted, - hasTextMessageBeenDeleted, pasteIntoInput, waitForLoadingAnimationToFinish, waitForMatchingPlaceholder, - waitForMatchingText, waitForTestIdWithText, - waitForTextMessage, } from './utilities/utils'; sessionTestOneWindow('Link a device', async ([aliceWindow1]) => { @@ -177,97 +173,6 @@ test_Alice_2W_Bob_1W( }, ); -test_Alice_2W_Bob_1W( - 'Delete message locally 1:1', - async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { - const messageToDelete = 'Testing deletion functionality for linked device'; - await createContact(aliceWindow1, bobWindow1, alice, bob); - await sendMessage(aliceWindow1, messageToDelete); - // Navigate to conversation on linked device and for message from user A to user B - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); - await Promise.all([ - waitForTextMessage(aliceWindow2, messageToDelete, 15_000), - waitForTextMessage(bobWindow1, messageToDelete, 15_000), - ]); - await deleteMessageFor(aliceWindow1, messageToDelete, 'device_only'); - - await sleepFor(15_000, true); // explicit wait to make the delete was a local delete only (and had time to propagate if not) - await Promise.all([ - // the content of the original message should be removed on device that removed it - hasTextMessageBeenDeleted(aliceWindow1, messageToDelete, 6_000), - // tombstone should be visible on device that removed it - waitForMatchingText( - aliceWindow1, - tStripped('deleteMessageDeletedLocally'), - 6_000, - ), - waitForMatchingText(aliceWindow2, messageToDelete, 15_000), // should still be here on linked device - waitForMatchingText(bobWindow1, messageToDelete, 15_000), // should still be here on bob - ]); - }, -); - -test_Alice_2W_Bob_1W( - 'Delete message for everyone 1:1', - async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { - const unsentMessage = 'Testing unsending functionality for linked device'; - await createContact(aliceWindow1, bobWindow1, alice, bob); - await sendMessage(aliceWindow1, unsentMessage); - // Navigate to conversation on linked device and for message from user A to user B - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); - await Promise.all([ - waitForTextMessage(aliceWindow2, unsentMessage), - waitForTextMessage(bobWindow1, unsentMessage), - ]); - await deleteMessageFor(aliceWindow1, unsentMessage, 'for_everyone'); - - await hasTextMessageBeenDeleted(aliceWindow1, unsentMessage, 1000); - await waitForMatchingText( - bobWindow1, - tStripped('deleteMessageDeletedGlobally'), - 15_000, - ); - // linked device for deleted message - await hasTextMessageBeenDeleted(aliceWindow2, unsentMessage, 5_000); - }, -); - -test_Alice_2W( - 'Delete message for all my devices NTS', - async ({ alice, aliceWindow1, aliceWindow2 }) => { - const unsentMessage = `Testing unsending functionality for NTS ${new Date().toISOString()}`; - await sendNewMessage(aliceWindow1, alice.accountid, unsentMessage); - // Navigate to conversation on linked device and for message from user A to user B - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - tStripped('noteToSelf'), - ); - await Promise.all([ - waitForTextMessage(aliceWindow1, unsentMessage), - waitForTextMessage(aliceWindow2, unsentMessage), - ]); - - await clickOn(aliceWindow1, Global.confirmButton); - await deleteMessageFor(aliceWindow1, unsentMessage, 'for_all_my_devices'); - - // in NTS, a message deleted on all our devices is removed entirely (the tombstone is not left) - await Promise.all( - [aliceWindow1, aliceWindow2].map((w) => - hasTextMessageBeenDeleted(w, unsentMessage, 15_000), - ), - ); - }, -); - test_Alice_2W_Bob_1W( 'Blocked user syncs', async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index ca28917..e91f18c 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -109,7 +109,7 @@ export class Conversation extends Locator { static readonly mentionsContainer = this.testId('mentions-container'); // This is also the locator for emojis static readonly mentionsItem = this.testId('mentions-container-row'); // This is also the locator for emojis static readonly messageContent = this.testId('message-content'); - static readonly linkPreviewTitle = this.className('msg-link-preview-title'); + static readonly linkPreviewTitle = this.testId('msg-link-preview-title'); static readonly messageInput = this.testId('message-input-text-area'); static readonly messageRequestAcceptControlMessage = this.testId( @@ -120,7 +120,10 @@ export class Conversation extends Locator { static readonly sendMessageButton = this.testId('send-message-button'); static readonly unbanUserButton = this.testId('unban-user-confirm-button'); - static readonly groupName = this.className('group-name'); + static readonly groupName = this.testId('group-name'); + static readonly communityInvitationDetails = this.testId( + 'community-invitation-details', + ); static readonly audioPlayer = this.testId('audio-player'); static readonly callNotificationAnswered = this.testId( diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index c35a029..ac0ec1c 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -1,7 +1,12 @@ import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { testCommunityName } from './constants/community'; -import { longText, mediaArray, testLink } from './constants/variables'; +import { + longText, + mediaArray, + testLink, + testLinkTitle, +} from './constants/variables'; import { Conversation, ConversationSettings, @@ -14,11 +19,17 @@ import { sessionTestTwoWindows, test_Alice_1W, test_Alice_1W_Bob_1W, + test_Alice_2W, test_Alice_2W_Bob_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { joinCommunity } from './utilities/join_community'; -import { deleteMessageFor, sendMessage } from './utilities/message'; +import { + confirmMessageDeletedFor, + deleteMessageFor, + sendMessage, +} from './utilities/message'; import { replyTo, replyToMedia } from './utilities/reply_message'; import { sendLinkPreview, @@ -33,12 +44,10 @@ import { clickOnElement, clickOnWithText, hasElementPoppedUpThatShouldnt, - hasTextMessageBeenDeleted, measureSendingTime, pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, - waitForMatchingText, waitForTestIdWithText, waitForTextMessage, } from './utilities/utils'; @@ -112,7 +121,7 @@ test_Alice_1W_Bob_1W( ); test_Alice_1W_Bob_1W( - 'Send link 1:1', + 'Send link preview 1:1', async ({ alice, aliceWindow1, bob, bobWindow1 }) => { const testReply = `${bob.userName} replying to link from ${alice.userName}`; @@ -124,7 +133,7 @@ test_Alice_1W_Bob_1W( options: { maxWaitMs: 3_000, shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + text: testLinkTitle, }, }); await replyTo({ @@ -157,17 +166,12 @@ test_Alice_1W_Bob_1W( .click(); // Close UCS modal await clickOn(aliceWindow1, Global.modalCloseButton); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); await Promise.all( [aliceWindow1, bobWindow1].map((w) => waitForElement({ window: w, - locator: Conversation.groupName, - + locator: Conversation.communityInvitationDetails, options: { maxWaitMs: 15_000, shouldLog: true, @@ -179,72 +183,85 @@ test_Alice_1W_Bob_1W( }, ); -test_Alice_1W_Bob_1W( - 'Unsend message 1:1', - async ({ alice, aliceWindow1, bob, bobWindow1 }) => { - const unsendMessage = 'Testing unsend functionality'; - await createContact(aliceWindow1, bobWindow1, alice, bob); +const delete1o1TypeArray = ['device_only', 'for_everyone'] as const; - await sendMessage(aliceWindow1, unsendMessage); - await waitForTextMessage(bobWindow1, unsendMessage); - await deleteMessageFor(aliceWindow1, unsendMessage, 'for_everyone'); +delete1o1TypeArray.forEach((deleteType) => { + test_Alice_2W_Bob_1W( + `Delete message 1:1 ${deleteType}`, + async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { + const messageToDelete = `Testing deletion functionality for ${deleteType}`; + await createContact(aliceWindow1, bobWindow1, alice, bob); + await sendMessage(aliceWindow1, messageToDelete); + // Navigate to conversation on linked device and for message from user A to user B + await openConversationWith(aliceWindow2, bob.userName); - await sleepFor(1000); - await waitForMatchingText( - bobWindow1, - tStripped('deleteMessageDeletedGlobally'), - 15_000, - ); - }, -); + await Promise.all([ + waitForTextMessage(aliceWindow2, messageToDelete, 15_000), + waitForTextMessage(bobWindow1, messageToDelete, 15_000), + ]); + await openConversationWith(aliceWindow2, bob.userName); -test_Alice_2W_Bob_1W( - 'Delete message locally in 1:1', - async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { - // send a message from alice to Bob, and then try to delete it locally from Alice's side - const deletedMessage1 = `Testing deletion functionality from ${alice.userName} to ${bob.userName} in 1:1 at ${new Date().toISOString()}`; - await createContact(aliceWindow1, bobWindow1, alice, bob); - await sendMessage(aliceWindow1, deletedMessage1); - // focus the conversation on aliceWindow2 (not done as restored from seed) - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); + await deleteMessageFor(aliceWindow1, messageToDelete, deleteType); - await Promise.all( - [aliceWindow2, bobWindow1].map((w) => - waitForTextMessage(w, deletedMessage1), - ), - ); - await deleteMessageFor(aliceWindow1, deletedMessage1, 'device_only'); - await hasTextMessageBeenDeleted(aliceWindow1, deletedMessage1, 1_000); - // Still should exist in Bob and aliceWindow2 - await Promise.all( - [aliceWindow2, bobWindow1].map((w) => - waitForMatchingText(w, deletedMessage1, 15_000), - ), - ); + await confirmMessageDeletedFor({ + deleteType, + messageToDelete, + otherWindows: [aliceWindow2, bobWindow1], + windowInitiatingDelete: aliceWindow1, + }); - // same, but we know want validate that Bob can also delete locally Alice's message - const deletedMessage2 = `Testing deletion functionality from ${alice.userName} to ${bob.userName} in 1:1 at ${new Date().toISOString()}`; - await sendMessage(aliceWindow1, deletedMessage2); // alice sends it again - await Promise.all( - [aliceWindow2, bobWindow1].map((w) => - waitForTextMessage(w, deletedMessage2), - ), - ); - await deleteMessageFor(bobWindow1, deletedMessage2, 'device_only'); // Bob deletes Alice's message locally + if (deleteType === 'device_only') { + // when testing the device_only deletion, we also want to check that + // an incoming message can be deleted locally. + const messageToDelete2 = `Testing deletion functionality for ${deleteType} #2`; - await hasTextMessageBeenDeleted(bobWindow1, deletedMessage2, 1_000); - // Still should exist in Bob and aliceWindow2 - await Promise.all( - [aliceWindow1, aliceWindow2].map((w) => - waitForMatchingText(w, deletedMessage2, 15_000), - ), - ); - }, -); + await sendMessage(aliceWindow1, messageToDelete2); + await waitForTextMessage( + [aliceWindow2, bobWindow1], + messageToDelete2, + 15_000, + ); + + // bob now deletes Alice's message locally + await deleteMessageFor(bobWindow1, messageToDelete2, deleteType); + + await confirmMessageDeletedFor({ + deleteType, + messageToDelete: messageToDelete2, + otherWindows: [aliceWindow1, aliceWindow2], + windowInitiatingDelete: bobWindow1, + }); + } + }, + ); +}); + +const deleteNtsTypeArray = ['device_only', 'for_all_my_devices'] as const; + +deleteNtsTypeArray.forEach((deleteType) => { + test_Alice_2W( + `Delete message NTS ${deleteType}`, + async ({ aliceWindow1, aliceWindow2 }) => { + const messageToDelete = `Testing deletion functionality for NTS ${deleteType}`; + await sendMessage(aliceWindow1, messageToDelete); + // Navigate to conversation on linked device + await openConversationWith(aliceWindow2, tStripped('noteToSelf')); + await Promise.all([ + waitForTextMessage(aliceWindow1, messageToDelete, 15_000), + waitForTextMessage(aliceWindow2, messageToDelete, 15_000), + ]); + + await deleteMessageFor(aliceWindow1, messageToDelete, deleteType); + + await confirmMessageDeletedFor({ + deleteType, + messageToDelete, + otherWindows: [aliceWindow2], + windowInitiatingDelete: aliceWindow1, + }); + }, + ); +}); sessionTestTwoWindows( 'Check performance', diff --git a/tests/automation/message_checks_groups.spec.ts b/tests/automation/message_checks_groups.spec.ts index c683d2c..63102c9 100644 --- a/tests/automation/message_checks_groups.spec.ts +++ b/tests/automation/message_checks_groups.spec.ts @@ -1,12 +1,21 @@ -import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; -import { longText, mediaArray, testLink } from './constants/variables'; -import { Conversation, HomeScreen } from './locators'; +import { + longText, + mediaArray, + testLink, + testLinkTitle, +} from './constants/variables'; +import { Conversation } from './locators'; import { test_group_Alice_1W_Bob_1W_Charlie_1W, test_group_Alice_2W_Bob_1W_Charlie_1W, } from './setup/sessionTest'; -import { deleteMessageFor, sendMessage } from './utilities/message'; +import { openConversationWith } from './utilities/conversation'; +import { + confirmMessageDeletedFor, + deleteMessageFor, + sendMessage, +} from './utilities/message'; import { replyTo, replyToMedia } from './utilities/reply_message'; import { sendLinkPreview, @@ -15,12 +24,9 @@ import { } from './utilities/send_media'; import { clickOnElement, - clickOnWithText, - hasTextMessageBeenDeleted, pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, - waitForMatchingText, waitForTestIdWithText, waitForTextMessage, } from './utilities/utils'; @@ -121,7 +127,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ); test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Send link to group', + 'Send link preview to group', async ({ alice, bob, @@ -140,7 +146,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( options: { maxWaitMs: 3_000, shouldLog: true, - text: 'Session | Send Messages, Not Metadata. | Private Messenger', + text: testLinkTitle, }, }), ), @@ -155,140 +161,83 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( }, ); -test_group_Alice_2W_Bob_1W_Charlie_1W( - 'Delete message for everyone in group', - async ({ - aliceWindow1, - aliceWindow2, - bobWindow1, - charlieWindow1, - groupCreated, - alice, - bob, - }) => { - // Note: Alice is the admin in this group, Bob is a member without admin rights - const unsendMessageFromBob1 = `Testing unsend functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; - // focus the conversation on aliceWindow2 (not done as restored from seed) - await clickOnWithText( +const deleteGroupTypeArray = [ + 'device_only', + // as normal user, delete one of our own messages + 'for_everyone', + // as an admin, delete someone else message + 'as_admin_for_everyone', +] as const; + +deleteGroupTypeArray.forEach((deleteType) => + test_group_Alice_2W_Bob_1W_Charlie_1W( + `Delete message in group ${deleteType}`, + async ({ + aliceWindow1, aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + bobWindow1, + charlieWindow1, + groupCreated, + bob, + }) => { + // Note: Alice is the admin in this group, Bob is a member without admin rights + const unsendMessageFromBob = `Testing delete ${deleteType} in group from ${bob.userName}`; + // focus the conversation on aliceWindow2 (not done as restored from seed) + await openConversationWith(aliceWindow2, groupCreated.userName); - await sendMessage(bobWindow1, unsendMessageFromBob1); - await Promise.all( - [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => - waitForTextMessage(w, unsendMessageFromBob1, 15_000), - ), - ); + await sendMessage(bobWindow1, unsendMessageFromBob); + await waitForTextMessage( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1], + unsendMessageFromBob, + 15_000, + ); - // Bob sent this message, so should be able to delete it for everyone - await deleteMessageFor(bobWindow1, unsendMessageFromBob1, 'for_everyone'); - // message should be marked as deleted on all devices of all members - await Promise.all( - [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => - waitForMatchingText( - w, - tStripped('deleteMessageDeletedGlobally'), - 15_000, - ), - ), - ); + if (deleteType === 'device_only' || deleteType === 'for_everyone') { + // Bob sent this message, so should be able to delete it locally or for everyone + await deleteMessageFor(bobWindow1, unsendMessageFromBob, deleteType); + await confirmMessageDeletedFor({ + deleteType, + messageToDelete: unsendMessageFromBob, + windowInitiatingDelete: bobWindow1, + otherWindows: [aliceWindow1, aliceWindow2, charlieWindow1], + }); + } else { + // Delete the message as Alice (admin) sent by Bob + await deleteMessageFor( + aliceWindow1, + unsendMessageFromBob, + 'for_everyone', + ); + await confirmMessageDeletedFor({ + deleteType: 'for_everyone', + messageToDelete: unsendMessageFromBob, + windowInitiatingDelete: aliceWindow1, + otherWindows: [aliceWindow2, bobWindow1, charlieWindow1], + }); + } - // Now, try to remove a new message as Alice (admin) sent by Bob - console.log( - `Now, try to remove a new message as ${alice.userName} (admin) sent by ${bob.userName}`, - ); - const unsendMessageFromBob2 = `Testing unsend functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; - await sendMessage(bobWindow1, unsendMessageFromBob2); + if (deleteType === 'device_only') { + // when testing the device_only deletion, we also want to check that + // an incoming message can be deleted locally. + const messageToDelete2 = `Testing delete ${deleteType} in group from ${bob.userName} #2`; - await Promise.all( - [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => - waitForTextMessage(w, unsendMessageFromBob2), - ), - ); - // Bob sent this message, so should be able to delete it for everyone - await deleteMessageFor(aliceWindow1, unsendMessageFromBob2, 'for_everyone'); - // message should be marked as deleted on all devices of all members - await Promise.all( - [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => - waitForMatchingText( - w, - tStripped('deleteMessageDeletedGlobally'), + await sendMessage(bobWindow1, messageToDelete2); + await waitForTextMessage( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1], + messageToDelete2, 15_000, - ), - ), - ); - }, -); - -test_group_Alice_2W_Bob_1W_Charlie_1W( - 'Delete message locally in group', - async ({ - aliceWindow1, - aliceWindow2, - bobWindow1, - charlieWindow1, - groupCreated, - bob, - }) => { - const deletedMessageFromBob1 = `Testing delete message functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; - // focus the conversation on aliceWindow2 (not done as restored from seed) - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + ); - await sendMessage(bobWindow1, deletedMessageFromBob1); - await Promise.all( - [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => - waitForTextMessage(w, deletedMessageFromBob1, 15_000), - ), - ); - // bob can remove locally his own message - await deleteMessageFor(bobWindow1, deletedMessageFromBob1, 'device_only'); - await hasTextMessageBeenDeleted(bobWindow1, deletedMessageFromBob1, 5000); - await waitForMatchingText( - bobWindow1, - tStripped('deleteMessageDeletedLocally'), - 15_000, - ); - // Should still be there for Alice and Charlie - await Promise.all( - [aliceWindow1, aliceWindow2, charlieWindow1].map((w) => - waitForMatchingText(w, deletedMessageFromBob1, 15_000), - ), - ); + // Charlie now deletes Bob's message locally + await deleteMessageFor(charlieWindow1, messageToDelete2, deleteType); - // Charlie (another normal member) can remove locally messages he didn't send - const deletedMessageFromBob2 = `Testing delete message functionality in ${groupCreated.userName} from ${bob.userName} at ${new Date().toISOString()}`; - await sendMessage(bobWindow1, deletedMessageFromBob2); - await Promise.all( - [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1].map((w) => - waitForTextMessage(w, deletedMessageFromBob2, 15_000), - ), - ); - await deleteMessageFor( - charlieWindow1, - deletedMessageFromBob2, - 'device_only', - ); - await hasTextMessageBeenDeleted( - charlieWindow1, - deletedMessageFromBob2, - 5000, - ); - await waitForMatchingText( - charlieWindow1, - tStripped('deleteMessageDeletedLocally'), - 15_000, - ); - // Should still be there for Alice and Bob - await Promise.all( - [aliceWindow1, aliceWindow1, bobWindow1].map((w) => - waitForMatchingText(w, deletedMessageFromBob2, 15_000), - ), - ); - }, + await confirmMessageDeletedFor({ + deleteType, + messageToDelete: messageToDelete2, + otherWindows: [aliceWindow1, aliceWindow2, bobWindow1], + windowInitiatingDelete: charlieWindow1, + }); + } + }, + ), ); diff --git a/tests/automation/message_requests.spec.ts b/tests/automation/message_requests.spec.ts index e346b35..7bf52b9 100644 --- a/tests/automation/message_requests.spec.ts +++ b/tests/automation/message_requests.spec.ts @@ -9,6 +9,7 @@ import { Settings, } from './locators'; import { test_Alice_1W_Bob_1W } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { joinCommunity } from './utilities/join_community'; import { sendMessage } from './utilities/message'; import { sendNewMessage } from './utilities/send_message'; @@ -33,11 +34,7 @@ test_Alice_1W_Bob_1W( // Check the message request banner appears and click on it await clickOn(bobWindow1, HomeScreen.messageRequestBanner); // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); // Check that using the accept button has intended use await clickOn(bobWindow1, Conversation.acceptMessageRequestButton); // Check config message of message request acceptance @@ -66,11 +63,8 @@ test_Alice_1W_Bob_1W( // Check the message request banner appears and click on it await clickOn(bobWindow1, HomeScreen.messageRequestBanner); // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); + await sendMessage(bobWindow1, testReply); // Check config message of message request acceptance @@ -98,11 +92,7 @@ test_Alice_1W_Bob_1W( // Check the message request banner appears and click on it await clickOn(bobWindow1, HomeScreen.messageRequestBanner); // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); await clickOnWithText( bobWindow1, @@ -197,11 +187,7 @@ test_Alice_1W_Bob_1W( await sendMessage(aliceWindow1, messageRequestMsg); await clickOn(bobWindow1, HomeScreen.messageRequestBanner); // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); await sendMessage(bobWindow1, messageRequestResponse); // Check config message of message request acceptance await waitForTestIdWithText( diff --git a/tests/automation/setup/create_group.ts b/tests/automation/setup/create_group.ts index 866079b..99c5626 100644 --- a/tests/automation/setup/create_group.ts +++ b/tests/automation/setup/create_group.ts @@ -4,12 +4,12 @@ import { tStripped } from '../../localization/lib'; import { sortByPubkey } from '../../pubkey'; import { HomeScreen } from '../locators'; import { Group, User } from '../types/testing'; +import { openConversationWith } from '../utilities/conversation'; import { sendMessage } from '../utilities/message'; import { sendNewMessage } from '../utilities/send_message'; import { clickOn, clickOnMatchingText, - clickOnWithText, pasteIntoInput, waitForTestIdWithText, waitForTextMessages, @@ -86,9 +86,7 @@ export const createGroup = async ( ); // Click on test group await Promise.all( - [windowB, windowC].map((w) => - clickOnWithText(w, HomeScreen.conversationItemName, group.userName), - ), + [windowB, windowC].map((w) => openConversationWith(w, group.userName)), ); // Make sure the empty state is in windowB & windowC await Promise.all([ diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index 27662d8..160af8b 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -99,6 +99,7 @@ export type DataTestId = | 'classic-light-themes-settings-menu-item' | 'clear-data-settings-menu-item' | 'clear-group-info-name-button' + | 'community-invitation-details' | 'contact' | 'context-menu-item' | 'continue-button' @@ -178,6 +179,7 @@ export type DataTestId = | 'modal-heading' | 'module-contact-name__profile-name' | 'module-conversation__user__profile-name' + | 'msg-link-preview-title' | 'new-closed-group-name' | 'new-conversation-button' | 'new-session-conversation' diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index 8c9d366..1661559 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -14,6 +14,7 @@ import { test_Alice_1W_no_network, test_Alice_2W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { sendMessage, waitForMessageStatus } from './utilities/message'; import { compareElementScreenshot } from './utilities/screenshot'; @@ -262,11 +263,8 @@ test_Alice_1W_Bob_1W( selector: Settings.enableReadReceipts.selector, }); await clickOn(aliceWindow1, Global.modalCloseButton); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); + await clickOnElement({ window: bobWindow1, strategy: 'data-testid', @@ -281,11 +279,7 @@ test_Alice_1W_Bob_1W( }); await clickOn(bobWindow1, Global.modalCloseButton); await sendMessage(aliceWindow1, 'Testing read receipts'); - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); await waitForMessageStatus(aliceWindow1, 'Testing read receipts', 'read'); }, ); diff --git a/tests/automation/utilities/conversation.ts b/tests/automation/utilities/conversation.ts new file mode 100644 index 0000000..7ca79a9 --- /dev/null +++ b/tests/automation/utilities/conversation.ts @@ -0,0 +1,11 @@ +import type { Page } from '@playwright/test'; + +import { HomeScreen } from '../locators'; +import { clickOnWithText } from './utils'; + +/** + * Open a conversation from the left pane with the provided name + */ +export async function openConversationWith(window: Page, convoName: string) { + await clickOnWithText(window, HomeScreen.conversationItemName, convoName); +} diff --git a/tests/automation/utilities/create_contact.ts b/tests/automation/utilities/create_contact.ts index 777099f..f15d672 100644 --- a/tests/automation/utilities/create_contact.ts +++ b/tests/automation/utilities/create_contact.ts @@ -15,7 +15,6 @@ export const createContact = async ( // User A sends message to User B await Promise.all([ sendNewMessage(windowA, userB.accountid, testMessage), - sendNewMessage(windowB, userA.accountid, testReply), ]); console.warn(`createContact took ${Date.now() - start}ms`); diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index ebc0f35..fc024ce 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -7,6 +7,7 @@ import { testCommunityName, } from '../constants/community'; import { Global, HomeScreen } from '../locators'; +import { openConversationWith } from './conversation'; import { clickOn, clickOnMatchingText, @@ -82,11 +83,8 @@ export const joinOrOpenCommunity = async (window: Page) => { ); await clickOn(window, Global.backButton); await clickOn(window, Global.backButton); - await clickOnWithText( - window, - HomeScreen.conversationItemName, - testCommunityName, - ); + + await openConversationWith(window, testCommunityName); } catch (waitError) { // The error message we expected wasn't there, so this is a real failure throw joinError; // Throw the original join error, not the wait timeout diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index b845fe8..6344bdd 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -1,6 +1,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; +import { sleepFor } from '../../promise_utils'; import { Global } from '../locators'; import { MessageStatus } from '../types/testing'; import { @@ -9,23 +10,29 @@ import { clickOnElement, clickOnMatchingText, clickOnTextMessage, + hasTextMessageBeenDeleted, pasteIntoInput, + waitForMatchingText, waitForTestIdWithText, } from './utils'; +export type MessageDeleteType = + | 'device_only' + | 'for_all_my_devices' + | 'for_everyone'; + export const waitForMessageStatus = async ( window: Page, message: string, status: MessageStatus, ) => { - const selc = `css=[data-testid=message-content]:has-text("${message}"):has([data-testid=msg-status][data-testtype=${status}])`; + const selector = `css=[data-testid=message-container]:has-text("${message}"):has([data-testid=msg-status][data-testtype=${status}])`; const logSig = `${status} status of message '${message}'`; - console.info(`waiting for ${logSig}`); - const messageStatus = await window.waitForSelector(selc, { - timeout: 20_000, + const messageStatus = await window.waitForSelector(selector, { + timeout: 20_000, // a gif on mainnet can take a long time to upload }); - console.info(`${logSig} is ${Boolean(messageStatus)}`); + console.info(`${logSig} is ${!!messageStatus}`); }; export const sendMessage = async (window: Page, message: string) => { @@ -43,7 +50,7 @@ export const sendMessage = async (window: Page, message: string) => { export async function deleteMessageFor( window: Page, message: string, - deletionType: 'device_only' | 'for_all_my_devices' | 'for_everyone', + deletionType: MessageDeleteType, ) { await clickOnTextMessage(window, message, true); await clickOnMatchingText(window, tStripped('delete')); @@ -69,3 +76,55 @@ export async function deleteMessageFor( tStripped('deleteMessageDeleted', { count: 1 }), ); } + +/** + * Wait 15s and then confirms that all of the windows have the message is the expected state, depending on the delete type. + * + * A local deletion + */ +export async function confirmMessageDeletedFor({ + deleteType, + messageToDelete, + otherWindows, + windowInitiatingDelete, +}: { + windowInitiatingDelete: Page; + otherWindows: Array; + messageToDelete: string; + deleteType: MessageDeleteType; +}) { + // explicit wait to make sure a deleted locally that was wrongly deleted globally had time to propagate + await sleepFor(15_000, true); + if (deleteType === 'device_only') { + await Promise.all([ + // the content of the original message should be removed on the device that removed it + hasTextMessageBeenDeleted(windowInitiatingDelete, messageToDelete, 1_000), + // and should have been replaced with a tombstone (local version) + waitForMatchingText( + windowInitiatingDelete, + tStripped('deleteMessageDeletedLocally'), + 1_000, + ), + + // the other devices should have the message still visible + ...otherWindows.map((w) => + waitForMatchingText(w, messageToDelete, 1_000), + ), + ]); + } else { + await Promise.all([ + // all of the devices should have the message content removed + ...[windowInitiatingDelete, ...otherWindows].map((w) => + hasTextMessageBeenDeleted(w, messageToDelete, 1_000), + ), + // all of the devices should have the tombstone shown (global version) + ...[windowInitiatingDelete, ...otherWindows].map((w) => + waitForMatchingText( + w, + tStripped('deleteMessageDeletedGlobally'), + 1_000, + ), + ), + ]); + } +} diff --git a/tests/automation/utilities/send_media.ts b/tests/automation/utilities/send_media.ts index bc3447b..afb277d 100644 --- a/tests/automation/utilities/send_media.ts +++ b/tests/automation/utilities/send_media.ts @@ -2,6 +2,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; import { sleepFor } from '../../promise_utils'; +import { testLinkTitle } from '../constants/variables'; import { Conversation, Global, Settings } from '../locators'; import { isRunningOnDevNet } from '../setup/open'; import { MediaType } from '../types/testing'; @@ -133,7 +134,9 @@ export const sendLinkPreview = async (window: Page, testLink: string) => { // doesn't pop up if manually typing link (needs to be pasted) await window.keyboard.press(`${controlOrMetaFor()}+A`); await window.keyboard.press(`${controlOrMetaFor()}+X`); + await sleepFor(100); await clickOn(window, Conversation.messageInput); + await sleepFor(100); await window.keyboard.press(`${controlOrMetaFor()}+V`); await checkModalStrings( window, @@ -149,11 +152,7 @@ export const sendLinkPreview = async (window: Page, testLink: string) => { ); } await waitForTestIdWithText(window, 'link-preview-image'); - await waitForTestIdWithText( - window, - 'link-preview-title', - 'Session | Send Messages, Not Metadata. | Private Messenger', - ); + await waitForTestIdWithText(window, 'link-preview-title', testLinkTitle); await clickOnElement({ window, strategy: 'data-testid', diff --git a/tests/automation/utilities/send_message.ts b/tests/automation/utilities/send_message.ts index ba1e194..8f72240 100644 --- a/tests/automation/utilities/send_message.ts +++ b/tests/automation/utilities/send_message.ts @@ -6,13 +6,13 @@ import { clickOn, pasteIntoInput } from './utils'; export const sendNewMessage = async ( window: Page, - sessionid: string, + sessionId: string, message: string, ) => { await clickOn(window, HomeScreen.plusButton); await clickOn(window, HomeScreen.newMessageOption); // Enter session ID of USER B - await pasteIntoInput(window, 'new-session-conversation', sessionid); + await pasteIntoInput(window, 'new-session-conversation', sessionId); // click next await clickOn(window, HomeScreen.newMessageNextButton); await sendMessage(window, message); diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 2ca02bb..b2ecb29 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -22,6 +22,12 @@ type ElementOptions = { strictMode?: boolean; }; +export function escapeText(text: string) { + /* prettier-ignore */ + + return text.replace(/"/g, '\\\"'); +} + // TODO Unify element interaction functions to use locator objects the way clickOn and clickOnWithText do // Remaining functions to migrate: waitForElement, pasteIntoInput, grabTextFromElement etc. @@ -35,10 +41,11 @@ export async function waitForTestIdWithText( ) { let builtSelector = `css=[data-testid=${dataTestId}]`; if (text) { - // " => \\\" /* prettier-ignore */ - const escapedText = text.replace(/"/g, '\\\"'); + // " => \\\" + + const escapedText = escapeText(text); builtSelector += `:has-text("${escapedText}")`; // console.info('builtSelector:', builtSelector); @@ -86,7 +93,7 @@ export async function waitForElement({ } export async function waitForTextMessage( - window: Page, + window: Array | Page, text: string, maxWait?: number, ) { @@ -95,18 +102,23 @@ export async function waitForTextMessage( const builtSelector = `css=[data-testid=message-content]:has-text("${escapedText}")`; console.info('waitForTextMessage: builtSelector:', builtSelector); - const el = await window.waitForSelector(builtSelector, { timeout: maxWait }); + const windows = Array.isArray(window) ? window : [window]; + const el = await Promise.all( + windows.map((w) => w.waitForSelector(builtSelector, { timeout: maxWait })), + ); console.info(`Text message found. Text: "${text}"`); - return el; + return el[0]; } export async function waitForTextMessages( - window: Page, + window: Array | Page, texts: Array, maxWait?: number, ) { + const windows = Array.isArray(window) ? window : [window]; + return Promise.all( - texts.map(async (t) => waitForTextMessage(window, t, maxWait)), + texts.map(async (t) => waitForTextMessage(windows, t, maxWait)), ); } @@ -118,7 +130,7 @@ export async function waitForControlMessageWithText( } export async function waitForMatchingText( - window: Page, + window: Array | Page, text: string, maxWait: number, ) { @@ -127,13 +139,17 @@ export async function waitForMatchingText( console.info(`waitForMatchingText: ${text} for maxWait: ${maxTimeout}ms`); const start = Date.now(); - const found = await window.waitForSelector(builtSelector, { - timeout: maxTimeout, - }); + const windows = Array.isArray(window) ? window : [window]; + const found = await Promise.all( + windows.map((w) => + w.waitForSelector(builtSelector, { timeout: maxTimeout }), + ), + ); + console.info( `waitForMatchingText: found "${text}" in ${Date.now() - start}ms`, ); - return found; + return found[0]; } export async function waitForMatchingPlaceholder( diff --git a/tests/promise_utils.ts b/tests/promise_utils.ts index 45b7f01..239839e 100644 --- a/tests/promise_utils.ts +++ b/tests/promise_utils.ts @@ -1,12 +1,25 @@ import { Page } from '@playwright/test'; +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export const sleepFor = async (ms: number, showLog = false) => { if (showLog || ms > 5000) { console.info(`sleeping for ${ms}ms...`); + + if (ms > 5000) { + const chunks = 6; + const msPerChunk = Math.floor(ms / chunks); + for (let index = 0; index < chunks; index++) { + await sleep(msPerChunk); + console.info(`slept for ${msPerChunk * (index + 1)}/${ms}ms...`); + } + + return; + } } - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); + return sleep(ms); }; export async function doForAll( From 692b39f037cf44420eb92f7b8bf6c1ce01caaaa1 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 5 Mar 2026 11:17:16 +1100 Subject: [PATCH 07/17] chore: cleanup tests and split ban&unban tests again --- playwright.config.ts | 17 +- .../automation/community_admin_tests.spec.ts | 128 +++++++++++++ tests/automation/community_tests.spec.ts | 168 +----------------- .../automation/enforce_localized_str.spec.ts | 2 + tests/automation/linked_device_user.spec.ts | 10 +- tests/automation/locators/index.ts | 2 + tests/automation/message_checks.spec.ts | 5 +- .../automation/message_checks_groups.spec.ts | 93 +++++----- tests/automation/setup/create_group.ts | 81 ++++----- tests/automation/user_actions.spec.ts | 13 +- tests/automation/utilities/conversation.ts | 49 ++++- tests/automation/utilities/join_community.ts | 4 +- tests/automation/utilities/message.ts | 83 ++++++--- tests/automation/utilities/reply_message.ts | 6 +- tests/automation/utilities/utils.ts | 78 ++++++-- 15 files changed, 426 insertions(+), 313 deletions(-) create mode 100644 tests/automation/community_admin_tests.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 7f05f1b..e7e444b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,9 +26,22 @@ export default defineConfig({ repeatEach: process.env.PLAYWRIGHT_REPEAT_COUNT ? toNumber(process.env.PLAYWRIGHT_REPEAT_COUNT) : 0, - workers: toNumber(process.env.PLAYWRIGHT_WORKERS_COUNT) || 1, reportSlowTests: null, - fullyParallel: true, // otherwise, tests in the same file are not run in parallel globalSetup: './global.setup', // clean leftovers of previous test runs on start, runs only once snapshotPathTemplate: `${screenshotFolder}/{testName}/{arg}-{platform}{ext}`, + projects: [ + { + name: 'Community admin tests', + // those needs to be run sequentially + testMatch: '**/community_admin_tests.spec.ts', + fullyParallel: false, + workers: 1, + }, + { + name: 'All other tests', + testMatch: '**/!(community_admin_tests).spec.ts', + fullyParallel: true, // otherwise, tests in the same file are not run in parallel + workers: toNumber(process.env.PLAYWRIGHT_WORKERS_COUNT) || 1, + }, + ], }); diff --git a/tests/automation/community_admin_tests.spec.ts b/tests/automation/community_admin_tests.spec.ts new file mode 100644 index 0000000..de74676 --- /dev/null +++ b/tests/automation/community_admin_tests.spec.ts @@ -0,0 +1,128 @@ +import { tStripped } from '../localization/lib'; +import { testCommunityName } from './constants/community'; +import { Conversation, Global, HomeScreen } from './locators'; +import { newUser } from './setup/new_user'; +import { recoverFromSeed } from './setup/recovery_using_seed'; +import { sessionTestTwoWindows } from './setup/sessionTest'; +import { scrollToBottomLookingForMessage } from './utilities/conversation'; +import { + assertAdminIsKnown, + joinCommunity, + joinOrOpenCommunity, +} from './utilities/join_community'; +import { sendMessage, waitForMessageStatus } from './utilities/message'; +import { + clickOn, + clickOnWithText, + hasElementBeenDeleted, + hasElementPoppedUpThatShouldnt, + pasteIntoInput, + rightClickOnWithText, + waitForTestIdWithText, +} from './utilities/utils'; + +const banUserString = tStripped('banUser'); +const unbanUserString = tStripped('banUnbanUser'); + +const actionsToDo = ['ban_unban', 'ban_delete_all'] as const; + +actionsToDo.forEach((action) => { + sessionTestTwoWindows(`Community admin ${action}`, async ([alice1, bob1]) => { + assertAdminIsKnown(); + const firstMsgNotBanned = `${action} me! - ${Date.now()}`; + const secondMsgBanned = `I'm banned :( - ${Date.now()}`; + const thirdMsgUnbanned = `Freedom! - ${Date.now()}`; + + const [_alice, bob] = await Promise.all([ + recoverFromSeed(alice1, process.env.SOGS_ADMIN_SEED!, { + fallbackName: 'Admin', + }), + newUser(bob1, 'Bob'), + ]); + await Promise.all([joinOrOpenCommunity(alice1), joinCommunity(bob1)]); + await sendMessage(bob1, firstMsgNotBanned); + await scrollToBottomLookingForMessage({ + window: alice1, + msg: firstMsgNotBanned, + }); + await rightClickOnWithText( + alice1, + Conversation.messageContent, + firstMsgNotBanned, + ); + await clickOnWithText(alice1, Global.contextMenuItem, banUserString, { + strictMode: false, + maxWait: 1_00000, + }); + if (action === 'ban_unban') { + await clickOn(alice1, Conversation.banUserButton); + await pasteIntoInput( + bob1, + Conversation.messageInput.selector, + secondMsgBanned, + ); + await clickOn(bob1, Conversation.sendMessageButton); + await waitForMessageStatus(bob1, secondMsgBanned, 'failed'); + await rightClickOnWithText( + alice1, + Conversation.messageContent, + firstMsgNotBanned, + ); + await clickOnWithText(alice1, Global.contextMenuItem, unbanUserString, { + strictMode: false, + }); + await clickOn(alice1, Conversation.unbanUserButton); + } else { + await clickOn(alice1, Conversation.banAndDeleteAllButton); + await hasElementBeenDeleted(alice1, Conversation.messageContent, { + maxWait: 10_000, + text: firstMsgNotBanned, + }); + // Bob was banned, so he can't send a message + await pasteIntoInput( + bob1, + Conversation.messageInput.selector, + secondMsgBanned, + ); + await clickOn(bob1, Conversation.sendMessageButton); + await waitForMessageStatus(bob1, secondMsgBanned, 'failed'); + await hasElementPoppedUpThatShouldnt( + alice1, + Conversation.messageContent.strategy, + Conversation.messageContent.selector, + secondMsgBanned, + ); + // Alice unban Bob via the convo right click modal (as all messages from Bob have been removed) + await rightClickOnWithText( + alice1, + HomeScreen.conversationItemName, + testCommunityName, + ); + await clickOnWithText(alice1, Global.contextMenuItem, unbanUserString, { + strictMode: false, + }); + await pasteIntoInput( + alice1, + Conversation.unbanUserInput.selector, + bob.accountid, + ); + await clickOn(alice1, Conversation.unbanUserButton); + await waitForTestIdWithText( + alice1, + Global.toast.selector, + tStripped('banUnbanUserUnbanned'), + ); + } + + // here the user has been either + // - ban & unbanned or + // - banned_delete_all & unbanned + // So he should be able to send a message again + await sendMessage(bob1, thirdMsgUnbanned); + await waitForTestIdWithText( + alice1, + Conversation.messageContent.selector, + thirdMsgUnbanned, + ); + }); +}); diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index 6679bb6..9436a3b 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -1,37 +1,11 @@ -import type { Page } from '@playwright/test'; - -import { tStripped } from '../localization/lib'; -import { sleepFor } from '../promise_utils'; import { testCommunityName } from './constants/community'; -import { Conversation, Global } from './locators'; -import { newUser } from './setup/new_user'; -import { recoverFromSeed } from './setup/recovery_using_seed'; -import { - sessionTestTwoWindows, - test_Alice_1W_Bob_1W, - test_Alice_2W, -} from './setup/sessionTest'; +import { test_Alice_1W_Bob_1W, test_Alice_2W } from './setup/sessionTest'; import { openConversationWith } from './utilities/conversation'; -import { - assertAdminIsKnown, - joinCommunity, - joinOrOpenCommunity, -} from './utilities/join_community'; -import { sendMessage, waitForMessageStatus } from './utilities/message'; +import { joinCommunity } from './utilities/join_community'; +import { sendMessage } from './utilities/message'; import { replyTo } from './utilities/reply_message'; import { sendMedia } from './utilities/send_media'; -import { - clickOn, - clickOnWithText, - hasElementBeenDeleted, - hasElementPoppedUpThatShouldnt, - pasteIntoInput, - scrollToBottomIfNecessary, - waitForTestIdWithText, -} from './utilities/utils'; - -const banUserString = tStripped('banUser'); -const unbanUserString = tStripped('banUnbanUser'); +import { scrollToBottomIfNecessary } from './utilities/utils'; test_Alice_2W( 'Join community and sync', @@ -71,137 +45,3 @@ test_Alice_1W_Bob_1W( }); }, ); - -async function scrollToBottomLookingForMessage({ - window, - msg, -}: { - window: Page; - msg: string; -}) { - // It seems that for communities, we sometimes need to press multiple times the scroll to bottom - // button for the message to be visible. - const start = Date.now(); - do { - try { - await window.bringToFront(); - - await scrollToBottomIfNecessary(window); - await waitForTestIdWithText( - window, - Conversation.messageContent.selector, - msg, - 1_000, - ); - } catch (_e) { - // nothing to do here - } - await sleepFor(1000, true); - } while (Date.now() - start < 15_000); - - // this just checks if the message is visible or not after exiting the loop. i.e. this will throw if the message is not visible - await waitForTestIdWithText( - window, - Conversation.messageContent.selector, - msg, - 1_000, - ); -} - -sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { - assertAdminIsKnown(); - const banMeUnbanLaterMsg = `Ban me but unban me later! - ${Date.now()}`; - const bannedCheckMsg = `I'm banned :( - ${Date.now()}`; - const freedomNowMsg = `Freedom! - ${Date.now()}`; - await Promise.all([ - recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { - fallbackName: 'Admin', - }), - newUser(windowB, 'Bob'), - ]); - await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); - await sendMessage(windowB, banMeUnbanLaterMsg); - await scrollToBottomLookingForMessage({ - window: windowA, - msg: banMeUnbanLaterMsg, - }); - await clickOnWithText( - windowA, - Conversation.messageContent, - banMeUnbanLaterMsg, - { - rightButton: true, - }, - ); - await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { - strictMode: false, - maxWait: 10_000, - }); - await clickOn(windowA, Conversation.banUserButton); - await pasteIntoInput( - windowB, - Conversation.messageInput.selector, - bannedCheckMsg, - ); - await clickOn(windowB, Conversation.sendMessageButton); - await waitForMessageStatus(windowB, bannedCheckMsg, 'failed'); - await clickOnWithText( - windowA, - Conversation.messageContent, - banMeUnbanLaterMsg, - { - rightButton: true, - }, - ); - await clickOnWithText(windowA, Global.contextMenuItem, unbanUserString, { - strictMode: false, - }); - await clickOn(windowA, Conversation.unbanUserButton); - await sendMessage(windowB, freedomNowMsg); - await waitForTestIdWithText( - windowA, - Conversation.messageContent.selector, - freedomNowMsg, - ); - - // Now that the user is unban, check that we can ban and delete all. - // Note: a single test is doing all of those steps because having two of them on the same seed turns out to make both unreliable - const banAndDeleteAllMsg = `Ban and delete! - ${Date.now()}`; - const bannedCheckMsg2 = `Did that work? - ${Date.now()}`; - await sendMessage(windowB, banAndDeleteAllMsg); - await scrollToBottomLookingForMessage({ - window: windowA, - msg: banMeUnbanLaterMsg, - }); - await clickOnWithText( - windowA, - Conversation.messageContent, - banAndDeleteAllMsg, - { - rightButton: true, - maxWait: 1_000, - }, - ); - await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { - strictMode: false, - maxWait: 1_000, - }); - await clickOn(windowA, Conversation.banAndDeleteAllButton); - await hasElementBeenDeleted(windowA, Conversation.messageContent, { - maxWait: 10_000, - text: banAndDeleteAllMsg, - }); - await pasteIntoInput( - windowB, - Conversation.messageInput.selector, - bannedCheckMsg2, - ); - await clickOn(windowB, Conversation.sendMessageButton); - await waitForMessageStatus(windowB, bannedCheckMsg2, 'failed'); - await hasElementPoppedUpThatShouldnt( - windowA, - Conversation.messageContent.strategy, - Conversation.messageContent.selector, - bannedCheckMsg2, - ); -}); diff --git a/tests/automation/enforce_localized_str.spec.ts b/tests/automation/enforce_localized_str.spec.ts index daf7aa7..df1fc41 100644 --- a/tests/automation/enforce_localized_str.spec.ts +++ b/tests/automation/enforce_localized_str.spec.ts @@ -110,6 +110,8 @@ function getExpectedStringFromKey( return 'Unblock this contact to send a message'; case 'attachmentsClickToDownload': return 'Click to download {file_type}'; + case 'banUnbanUserUnbanned': + return 'User unbanned'; case 'media': return 'Media'; case 'file': diff --git a/tests/automation/linked_device_user.spec.ts b/tests/automation/linked_device_user.spec.ts index 9644915..38b13f7 100644 --- a/tests/automation/linked_device_user.spec.ts +++ b/tests/automation/linked_device_user.spec.ts @@ -29,6 +29,7 @@ import { doWhileWithMax, hasElementBeenDeleted, pasteIntoInput, + rightClickOnWithText, waitForLoadingAnimationToFinish, waitForMatchingPlaceholder, waitForTestIdWithText, @@ -181,11 +182,10 @@ test_Alice_2W_Bob_1W( await createContact(aliceWindow1, bobWindow1, alice, bob); await sendMessage(aliceWindow1, testMessage); // Navigate to conversation on linked device and check for message from user A to user B - await clickOnWithText( + await rightClickOnWithText( aliceWindow2, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); // Select block await clickOnWithText( @@ -264,11 +264,10 @@ test_Alice_2W_Bob_1W( ), ); // Delete contact - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); await clickOnWithText( aliceWindow1, @@ -322,11 +321,10 @@ test_Alice_2W( HomeScreen.conversationItemName.selector, tStripped('noteToSelf'), ); - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, tStripped('noteToSelf'), - { rightButton: true }, ); await clickOnWithText( aliceWindow1, diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index e91f18c..c6b7d16 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -85,7 +85,9 @@ export class Conversation extends Locator { 'ban-user-delete-all-confirm-button', ); static readonly banUserButton = this.testId('ban-user-confirm-button'); + static readonly unbanUserButton = this.testId('unban-user-confirm-button'); static readonly banUserInput = this.testId('ban-user-input'); + static readonly unbanUserInput = this.testId('unban-user-input'); static readonly blockMessageRequestButton = this.testId( 'decline-and-block-message-request', ); diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index ac0ec1c..14529d2 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -37,6 +37,7 @@ import { sendVoiceMessage, trustUser, } from './utilities/send_media'; +import { sendNewMessage } from './utilities/send_message'; import { checkCTAStrings, checkModalStrings, @@ -241,9 +242,9 @@ const deleteNtsTypeArray = ['device_only', 'for_all_my_devices'] as const; deleteNtsTypeArray.forEach((deleteType) => { test_Alice_2W( `Delete message NTS ${deleteType}`, - async ({ aliceWindow1, aliceWindow2 }) => { + async ({ aliceWindow1, aliceWindow2, alice }) => { const messageToDelete = `Testing deletion functionality for NTS ${deleteType}`; - await sendMessage(aliceWindow1, messageToDelete); + await sendNewMessage(aliceWindow1, alice.accountid, messageToDelete); // Navigate to conversation on linked device await openConversationWith(aliceWindow2, tStripped('noteToSelf')); await Promise.all([ diff --git a/tests/automation/message_checks_groups.spec.ts b/tests/automation/message_checks_groups.spec.ts index 63102c9..eb7ffcc 100644 --- a/tests/automation/message_checks_groups.spec.ts +++ b/tests/automation/message_checks_groups.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test'; + import { sleepFor } from '../promise_utils'; import { longText, @@ -14,6 +16,7 @@ import { openConversationWith } from './utilities/conversation'; import { confirmMessageDeletedFor, deleteMessageFor, + type MessageDeleteType, sendMessage, } from './utilities/message'; import { replyTo, replyToMedia } from './utilities/reply_message'; @@ -23,6 +26,7 @@ import { sendVoiceMessage, } from './utilities/send_media'; import { + assertUnreachable, clickOnElement, pasteIntoInput, waitForElement, @@ -162,7 +166,8 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ); const deleteGroupTypeArray = [ - 'device_only', + 'device_only_outgoing', + 'device_only_incoming', // as normal user, delete one of our own messages 'for_everyone', // as an admin, delete someone else message @@ -192,52 +197,50 @@ deleteGroupTypeArray.forEach((deleteType) => 15_000, ); - if (deleteType === 'device_only' || deleteType === 'for_everyone') { - // Bob sent this message, so should be able to delete it locally or for everyone - await deleteMessageFor(bobWindow1, unsendMessageFromBob, deleteType); - await confirmMessageDeletedFor({ - deleteType, - messageToDelete: unsendMessageFromBob, - windowInitiatingDelete: bobWindow1, - otherWindows: [aliceWindow1, aliceWindow2, charlieWindow1], - }); - } else { - // Delete the message as Alice (admin) sent by Bob - await deleteMessageFor( - aliceWindow1, - unsendMessageFromBob, - 'for_everyone', - ); - await confirmMessageDeletedFor({ - deleteType: 'for_everyone', - messageToDelete: unsendMessageFromBob, - windowInitiatingDelete: aliceWindow1, - otherWindows: [aliceWindow2, bobWindow1, charlieWindow1], - }); - } - - if (deleteType === 'device_only') { - // when testing the device_only deletion, we also want to check that - // an incoming message can be deleted locally. - const messageToDelete2 = `Testing delete ${deleteType} in group from ${bob.userName} #2`; + let windowInitiatingDelete: Page | undefined; + let fallbackDeleteType: MessageDeleteType | undefined; + switch (deleteType) { + case 'device_only_incoming': + // make Charlie delete Bob's message locally + windowInitiatingDelete = charlieWindow1; + fallbackDeleteType = 'device_only'; - await sendMessage(bobWindow1, messageToDelete2); - await waitForTextMessage( - [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1], - messageToDelete2, - 15_000, - ); - - // Charlie now deletes Bob's message locally - await deleteMessageFor(charlieWindow1, messageToDelete2, deleteType); - - await confirmMessageDeletedFor({ - deleteType, - messageToDelete: messageToDelete2, - otherWindows: [aliceWindow1, aliceWindow2, bobWindow1], - windowInitiatingDelete: charlieWindow1, - }); + break; + case 'device_only_outgoing': + case 'for_everyone': + // Bob sent this message, so should be able to delete it both locally and for everyone + windowInitiatingDelete = bobWindow1; + fallbackDeleteType = + deleteType === 'for_everyone' ? 'for_everyone' : 'device_only'; + break; + case 'as_admin_for_everyone': + // Alice (admin) is deleting Bob's message + windowInitiatingDelete = aliceWindow1; + fallbackDeleteType = 'for_everyone'; + break; + default: + assertUnreachable(deleteType, `assertUnreachable for deleteType`); + break; } + const otherWindows = [ + aliceWindow1, + aliceWindow2, + bobWindow1, + charlieWindow1, + ].filter((m) => m !== windowInitiatingDelete); + + // Bob sent this message, so should be able to delete it locally or for everyone + await deleteMessageFor( + windowInitiatingDelete, + unsendMessageFromBob, + fallbackDeleteType, + ); + await confirmMessageDeletedFor({ + deleteType: fallbackDeleteType, + messageToDelete: unsendMessageFromBob, + windowInitiatingDelete, + otherWindows, + }); }, ), ); diff --git a/tests/automation/setup/create_group.ts b/tests/automation/setup/create_group.ts index 99c5626..0a9b2f8 100644 --- a/tests/automation/setup/create_group.ts +++ b/tests/automation/setup/create_group.ts @@ -25,34 +25,34 @@ export const createGroup = async ( windowC: Page, ): Promise => { const group: Group = { userName, userOne, userTwo, userThree }; - const messageAB = `${userOne.userName} to ${userTwo.userName}`; - const messageBA = `${userTwo.userName} to ${userOne.userName}`; - const messageCA = `${userThree.userName} to ${userOne.userName}`; - const messageAC = `${userOne.userName} to ${userThree.userName}`; - const msgAToGroup = `${userOne.userName} -> ${group.userName}`; - const msgBToGroup = `${userTwo.userName} -> ${group.userName}`; - const msgCToGroup = `${userThree.userName} -> ${group.userName}`; - // Add contacts - await sendNewMessage( - windowA, - userThree.accountid, - `${messageAC} Time: ${Date.now()}`, - ); - await sendNewMessage( - windowA, - userTwo.accountid, - `${messageAB} Time: ${Date.now()}`, - ); - await sendNewMessage( - windowB, - userOne.accountid, - `${messageBA} Time: ${Date.now()}`, + + const actionsToDo = [ + { window: windowA, sender: userOne, receivers: [userTwo, userThree] }, + { window: windowB, sender: userTwo, receivers: [userOne, userThree] }, + { window: windowC, sender: userThree, receivers: [userOne, userTwo] }, + ]; + // make everyone a friend of everyone, by sending a message to each other + // Note: we need to do one send per window to avoid race conditions + await Promise.all( + actionsToDo.map(async (action) => + sendNewMessage( + action.window, + action.receivers[0].accountid, + `${action.sender.userName} to ${action.receivers[0].userName}`, + ), + ), ); - await sendNewMessage( - windowC, - userOne.accountid, - `${messageCA} Time: ${Date.now()}`, + // once the first batch is sent, we can start the second batch + await Promise.all( + actionsToDo.map(async (action) => + sendNewMessage( + action.window, + action.receivers[1].accountid, + `${action.sender.userName} to ${action.receivers[1].userName}`, + ), + ), ); + // Click new closed group tab await clickOn(windowA, HomeScreen.plusButton); await clickOn(windowA, HomeScreen.createGroupOption); @@ -103,29 +103,30 @@ export const createGroup = async ( tStripped('groupInviteYouAndOtherNew', { other_name: userTwo.userName }), ), ]); - // Send message in group chat from user A - await sendMessage(windowA, msgAToGroup); - // Focus screen - await clickOnMatchingText(windowA, msgAToGroup); - - // Send message in group chat from user B - await sendMessage(windowB, msgBToGroup); - await clickOnMatchingText(windowB, msgBToGroup); - // Send message from C to the group - await sendMessage(windowC, msgCToGroup); - await clickOnMatchingText(windowC, msgCToGroup); + const msgsSent = await Promise.all( + [ + [windowA, userOne] as const, + [windowB, userTwo] as const, + [windowC, userThree] as const, + ].map(async ([w, u]) => { + const msgToGroup = `${u.userName} to ${group.userName}`; + await sendMessage(w, msgToGroup); + await clickOnMatchingText(w, msgToGroup); + return msgToGroup; + }), + ); // Verify that each messages was received by the other two accounts // windowA should see the message from B and the message from C - await waitForTextMessages(windowA, [msgBToGroup, msgCToGroup]); + await waitForTextMessages(windowA, [msgsSent[1], msgsSent[2]]); // windowB should see the message from A and the message from C - await waitForTextMessages(windowB, [msgAToGroup, msgCToGroup]); + await waitForTextMessages(windowB, [msgsSent[0], msgsSent[2]]); // windowC must see the message from A and the message from B - await waitForTextMessages(windowC, [msgAToGroup, msgBToGroup]); + await waitForTextMessages(windowC, [msgsSent[0], msgsSent[1]]); return { userName, userOne, userTwo, userThree }; }; diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index 1661559..58d1b31 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -28,6 +28,7 @@ import { doesElementExist, hasElementBeenDeleted, pasteIntoInput, + rightClickOnWithText, waitForLoadingAnimationToFinish, waitForMatchingText, waitForTestIdWithText, @@ -52,11 +53,10 @@ test_Alice_1W_Bob_1W( // he is a contact, close the new conversation button tab as there is no right click allowed on it await clickOn(aliceWindow1, Global.backButton); // then right click on the contact conversation list item to show the menu - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); // Select block await clickOnWithText( @@ -210,11 +210,10 @@ test_Alice_1W_Bob_1W( const nickname = 'new nickname for Bob'; await createContact(aliceWindow1, bobWindow1, alice, bob); - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); await clickOnMatchingText(aliceWindow1, tStripped('nicknameSet')); await sleepFor(1000); @@ -316,11 +315,10 @@ test_Alice_1W_Bob_1W( ); // Delete contact - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); await clickOnWithText( aliceWindow1, @@ -436,11 +434,10 @@ test_Alice_1W_no_network( Conversation.conversationHeader.selector, tStripped('noteToSelf'), ); - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, tStripped('noteToSelf'), - { rightButton: true }, ); await clickOnWithText( aliceWindow1, diff --git a/tests/automation/utilities/conversation.ts b/tests/automation/utilities/conversation.ts index 7ca79a9..7678184 100644 --- a/tests/automation/utilities/conversation.ts +++ b/tests/automation/utilities/conversation.ts @@ -1,7 +1,12 @@ import type { Page } from '@playwright/test'; -import { HomeScreen } from '../locators'; -import { clickOnWithText } from './utils'; +import { sleepFor } from '../../promise_utils'; +import { Conversation, HomeScreen } from '../locators'; +import { + clickOnWithText, + scrollToBottomIfNecessary, + waitForTestIdWithText, +} from './utils'; /** * Open a conversation from the left pane with the provided name @@ -9,3 +14,43 @@ import { clickOnWithText } from './utils'; export async function openConversationWith(window: Page, convoName: string) { await clickOnWithText(window, HomeScreen.conversationItemName, convoName); } + +export async function scrollToBottomLookingForMessage({ + window, + msg, +}: { + window: Page; + msg: string; +}) { + // It seems that for communities, we sometimes need to press multiple times the scroll to bottom + // button for the message to be visible. + const start = Date.now(); + do { + try { + await window.bringToFront(); + + await scrollToBottomIfNecessary(window); + const found = await waitForTestIdWithText( + window, + Conversation.messageContent.selector, + msg, + 100, + ); + if (found) { + console.info(`scrollToBottomLookingForMessage: Found message "${msg}"`); + break; + } + } catch (_e) { + // nothing to do here + } + await sleepFor(1000, true); + } while (Date.now() - start < 15_000); + + // this just checks if the message is visible or not after exiting the loop. i.e. this will throw if the message is not visible + await waitForTestIdWithText( + window, + Conversation.messageContent.selector, + msg, + 100, + ); +} diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index fc024ce..0f578fd 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -14,6 +14,7 @@ import { clickOnWithText, hasElementBeenDeleted, pasteIntoInput, + rightClickOnWithText, waitForLoadingAnimationToFinish, waitForMatchingText, waitForTestIdWithText, @@ -49,11 +50,10 @@ export const joinDefaultCommunity = async ( }; export const leaveCommunity = async (window: Page, communityName: string) => { - await clickOnWithText( + await rightClickOnWithText( window, HomeScreen.conversationItemName, communityName, - { rightButton: true }, ); await clickOnWithText(window, Global.contextMenuItem, 'Leave Community'); await clickOn(window, Global.confirmButton); diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index 6344bdd..be74ba5 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -95,36 +95,63 @@ export async function confirmMessageDeletedFor({ }) { // explicit wait to make sure a deleted locally that was wrongly deleted globally had time to propagate await sleepFor(15_000, true); - if (deleteType === 'device_only') { - await Promise.all([ - // the content of the original message should be removed on the device that removed it - hasTextMessageBeenDeleted(windowInitiatingDelete, messageToDelete, 1_000), - // and should have been replaced with a tombstone (local version) - waitForMatchingText( - windowInitiatingDelete, - tStripped('deleteMessageDeletedLocally'), - 1_000, - ), - - // the other devices should have the message still visible - ...otherWindows.map((w) => - waitForMatchingText(w, messageToDelete, 1_000), - ), - ]); - } else { - await Promise.all([ - // all of the devices should have the message content removed - ...[windowInitiatingDelete, ...otherWindows].map((w) => - hasTextMessageBeenDeleted(w, messageToDelete, 1_000), - ), - // all of the devices should have the tombstone shown (global version) - ...[windowInitiatingDelete, ...otherWindows].map((w) => + switch (deleteType) { + case 'device_only': + await Promise.all([ + // the content of the original message should be removed on the device that removed it + hasTextMessageBeenDeleted( + windowInitiatingDelete, + messageToDelete, + 1_000, + ), + // and should have been replaced with a tombstone (local version) waitForMatchingText( - w, - tStripped('deleteMessageDeletedGlobally'), + windowInitiatingDelete, + tStripped('deleteMessageDeletedLocally'), 1_000, ), - ), - ]); + + // the other devices should have the message still visible + ...otherWindows.map((w) => + waitForMatchingText(w, messageToDelete, 1_000), + ), + ]); + break; + case 'for_everyone': + await Promise.all([ + // all of the devices should have the message content removed + ...[windowInitiatingDelete, ...otherWindows].map((w) => + hasTextMessageBeenDeleted(w, messageToDelete, 1_000), + ), + // all of the devices should have the tombstone shown (global version) + ...[windowInitiatingDelete, ...otherWindows].map((w) => + waitForMatchingText( + w, + tStripped('deleteMessageDeletedGlobally'), + 1_000, + ), + ), + ]); + break; + case 'for_all_my_devices': + // NTS for_all_my_devices does not leave tombstones, it removes the messages completely from all clients + await Promise.all([ + // all of our devices should have the message removed + ...[windowInitiatingDelete, ...otherWindows].map((w) => + hasTextMessageBeenDeleted(w, messageToDelete, 1_000), + ), + // and no tombstones at all + ...[windowInitiatingDelete, ...otherWindows].map((w) => + hasTextMessageBeenDeleted( + w, + tStripped('deleteMessageDeletedGlobally'), + 1_000, + ), + ), + ]); + break; + + default: + break; } } diff --git a/tests/automation/utilities/reply_message.ts b/tests/automation/utilities/reply_message.ts index a585ea1..9a00f30 100644 --- a/tests/automation/utilities/reply_message.ts +++ b/tests/automation/utilities/reply_message.ts @@ -4,6 +4,7 @@ import { tStripped } from '../../localization/lib'; import { sleepFor } from '../../promise_utils'; import { Conversation } from '../locators'; import { type StrategyExtractionObj } from '../types/testing'; +import { scrollToBottomLookingForMessage } from './conversation'; import { sendMessage } from './message'; import { verifyMediaPreviewLoaded } from './send_media'; import { @@ -36,7 +37,10 @@ export const replyTo = async ({ receiverWindow: Page | null; shouldCheckMediaPreview?: boolean; }) => { - await waitForTextMessage(senderWindow, textMessage); + await scrollToBottomLookingForMessage({ + msg: textMessage, + window: senderWindow, + }); // If the original message has media, verify sender sees it before replying if (shouldCheckMediaPreview) { diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index b2ecb29..7aa46c9 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -28,6 +28,17 @@ export function escapeText(text: string) { return text.replace(/"/g, '\\\"'); } +/** + * This function can be used to make sure all the possible values as input of a switch as taken care off, without having a default case. + */ +export function assertUnreachable(_x: never, message: string): never { + const msg = `assertUnreachable: Didn't expect to get here with "${message}"`; + // eslint:disable: no-console + + console.info(msg); + throw new Error(msg); +} + // TODO Unify element interaction functions to use locator objects the way clickOn and clickOnWithText do // Remaining functions to migrate: waitForElement, pasteIntoInput, grabTextFromElement etc. @@ -350,18 +361,9 @@ export async function clickOn( ); } -/** - * Clicks on an element that contains specific text - * @param window - Playwright page instance - * @param locator - Element locator with strategy and selector - * @param text - Text content to match within the element - * @param options - Optional element interaction configuration - */ -export async function clickOnWithText( - window: Page, +function buildSelectorForClickWithText( locator: StrategyExtractionObj, text: string, - options?: ElementOptions, ) { let builtSelector: string; @@ -376,15 +378,65 @@ export async function clickOnWithText( }]:has-text("${text.replace(/"/g, '\\"')}")`; } + return builtSelector; +} + +/** + * Clicks on an element that contains specific text + * @param window - Playwright page instance + * @param locator - Element locator with strategy and selector + * @param text - Text content to match within the element + * @param options - Optional element interaction configuration + */ +export async function clickOnWithText( + window: Page, + locator: StrategyExtractionObj, + text: string, + options?: Omit, +) { + const builtSelector = buildSelectorForClickWithText(locator, text); + const sharedOpts = { timeout: options?.maxWait, strict: options?.strictMode ?? true, }; - await window.click( - builtSelector, - options?.rightButton ? { ...sharedOpts, button: 'right' } : sharedOpts, + await window.click(builtSelector, sharedOpts); +} + +export async function rightClickOnWithText( + window: Page, + locator: StrategyExtractionObj, + text: string, + options?: Omit, +) { + const builtSelector = buildSelectorForClickWithText(locator, text); + + const sharedOpts = { + timeout: options?.maxWait, + strict: options?.strictMode ?? true, + button: 'right' as const, + }; + + for (let attempt = 0; attempt < 2; attempt++) { + await window.click(builtSelector, sharedOpts); + // This is a hack, but sometimes the right click makes the window move slightly, and close the context menu. + // So we wait for the context menu to appear (and to stay visible for 100ms), and if it doesn't, we try again. + await sleepFor(100, false); + const menuVisible = await window + .waitForSelector('[data-testid="context-menu-item"]', { timeout: 100 }) + .then(() => true) + .catch(() => false); + + if (menuVisible) { + return; + } + await sleepFor(500, true); + } + throw new Error( + `rightClickOnWithText: context menu never appeared for "${text}"`, ); } + // Legacy wrapper for backwards compatibility export async function clickOnElement({ window, From 06c6f83b967b5805500db63c4d41e5a470987cfa Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 5 Mar 2026 11:18:09 +1100 Subject: [PATCH 08/17] chore: update tui reporter with elapsed + scrollbar --- terminalTui.ts | 130 ++++++++++++++++++++++++++++++++++++++++++------- tuiReporter.ts | 29 ++++++----- 2 files changed, 129 insertions(+), 30 deletions(-) diff --git a/terminalTui.ts b/terminalTui.ts index 665fb31..cd7524f 100644 --- a/terminalTui.ts +++ b/terminalTui.ts @@ -160,6 +160,9 @@ export class TerminalTui { private lastUserInteractionTime = 0; private selection: { startRow: number; endRow: number } | null = null; private lastOutputLines: string[] = []; // cached from last render for selection copy + private startTime: number | null = null; + private frozenElapsedMs: number | null = null; // set when suite finishes + private elapsedTimer: ReturnType | null = null; start(): void { if (!process.stdout.isTTY) return; @@ -190,6 +193,8 @@ export class TerminalTui { }; process.on('exit', this.exitHandler); + this.startTime = Date.now(); + this.elapsedTimer = setInterval(() => this.scheduleRender(), 1000); this.scheduleRender(); } @@ -197,6 +202,11 @@ export class TerminalTui { if (!this.isActive) return; this.isActive = false; + if (this.elapsedTimer) { + clearInterval(this.elapsedTimer); + this.elapsedTimer = null; + } + if (this.flashTimeout) { clearTimeout(this.flashTimeout); this.flashTimeout = null; @@ -326,6 +336,16 @@ export class TerminalTui { this.selectedIndex = 0; this.outputScrollOffset = 0; this.autoFollow = false; + + // Freeze elapsed time and stop the live ticker + if (this.startTime !== null) { + this.frozenElapsedMs = Date.now() - this.startTime; + } + if (this.elapsedTimer) { + clearInterval(this.elapsedTimer); + this.elapsedTimer = null; + } + this.scheduleRender(); } @@ -366,6 +386,29 @@ export class TerminalTui { }); } + /** Returns a 1-char scrollbar indicator for a given row in the track. */ + private scrollbarChar( + total: number, + visible: number, + offset: number, + trackHeight: number, + row: number, + ): string { + if (total <= visible) { + return chalk.dim('\u2502'); // │ — no scrolling needed + } + const thumbSize = Math.max(1, Math.round((visible / total) * trackHeight)); + const maxOffset = total - visible; + const clampedOffset = Math.min(offset, maxOffset); + const thumbStart = Math.round( + (clampedOffset / maxOffset) * (trackHeight - thumbSize), + ); + if (row >= thumbStart && row < thumbStart + thumbSize) { + return chalk.white('\u2588'); // █ thumb + } + return chalk.dim('\u2591'); // ░ track + } + private render(): void { const cols = process.stdout.columns || 80; const rows = process.stdout.rows || 24; @@ -427,8 +470,8 @@ export class TerminalTui { this.lastListStart = listStart; this.lastLeftWidth = leftWidth; - // Right pane: build wrapped output lines - const outputLines = this.buildOutputLines(selectedTest, rightWidth - 2); + // Right pane: build wrapped output lines (−1 for scrollbar column) + const outputLines = this.buildOutputLines(selectedTest, rightWidth - 3); this.lastOutputLines = outputLines; const maxScroll = Math.max(0, outputLines.length - contentHeight); this.outputScrollOffset = Math.min(this.outputScrollOffset, maxScroll); @@ -460,6 +503,7 @@ export class TerminalTui { : chalk.dim('--'); const maxTitleLen = leftWidth - + 1 - // scrollbar column 4 - 5 - (entry.retry > 0 ? 4 + String(entry.retry).length : 0) - @@ -468,32 +512,49 @@ export class TerminalTui { const line = ` ${label} ${retryStr}${title}`; const lineWithDur = - padRight(line, leftWidth - visibleLength(durStr) - 2) + durStr + ' '; + padRight(line, leftWidth - 1 - visibleLength(durStr) - 2) + + durStr + + ' '; leftCell = isSelected ? this.activePaneFocus === 'list' - ? chalk.inverse(padRight(lineWithDur, leftWidth)) - : chalk.bgGray(padRight(lineWithDur, leftWidth)) - : padRight(lineWithDur, leftWidth); + ? chalk.inverse(padRight(lineWithDur, leftWidth - 1)) + : chalk.bgGray(padRight(lineWithDur, leftWidth - 1)) + : padRight(lineWithDur, leftWidth - 1); } else { - leftCell = ' '.repeat(leftWidth); + leftCell = ' '.repeat(leftWidth - 1); } - buf += chalk.dim('\u2502') + leftCell; + const leftScrollbar = this.scrollbarChar( + listLen, + contentHeight, + listStart, + contentHeight, + row, + ); + buf += chalk.dim('\u2502') + leftCell + leftScrollbar; // Divider buf += chalk.dim('\u2502'); - // Right cell + // Right cell (−1 for scrollbar column) const outIdx = this.outputScrollOffset + row; let rightCell = ''; if (outIdx < outputLines.length) { - rightCell = ' ' + truncate(outputLines[outIdx], rightWidth - 2) + RESET; + rightCell = ' ' + truncate(outputLines[outIdx], rightWidth - 3) + RESET; } const isSelected = outIdx >= selLo && outIdx <= selHi; - buf += isSelected - ? chalk.inverse(padRight(rightCell, rightWidth)) - : padRight(rightCell, rightWidth); + const rightScrollbar = this.scrollbarChar( + outputLines.length, + contentHeight, + this.outputScrollOffset, + contentHeight, + row, + ); + buf += + (isSelected + ? chalk.inverse(padRight(rightCell, rightWidth - 1)) + : padRight(rightCell, rightWidth - 1)) + rightScrollbar; } // --- Bottom divider --- @@ -517,12 +578,20 @@ export class TerminalTui { : chalk.dim('\u2191\u2193 scroll'); const tabHint = chalk.dim('Tab') + ' switch'; const qHint = chalk.dim('q') + ' quit'; - const cHint = chalk.dim('c') + ' copy'; + const cHint = + chalk.dim('c') + ' copy output ' + chalk.dim('C') + ' copy list'; const isFollowing = this.autoFollow && Date.now() - this.lastUserInteractionTime > 30_000; const fHint = isFollowing ? chalk.green('f') + chalk.green(' follow') : chalk.dim('f') + ' follow'; + const elapsedMs = + this.frozenElapsedMs ?? + (this.startTime !== null ? Date.now() - this.startTime : null); + const elapsedStr = + elapsedMs !== null + ? chalk.dim(` ${formatDuration(elapsedMs)} elapsed`) + : ''; const progressStr = chalk.dim( `${this.progress.completed}/${this.progress.total} done`, ); @@ -534,7 +603,7 @@ export class TerminalTui { buf += ` ${listHint} ${tabHint} ${qHint} ${cHint} ${fHint} ${chalk.dim( '|', - )} ${progressStr}${estStr}${flash}`; + )} ${progressStr}${elapsedStr}${estStr}${flash}`; process.stdout.write(buf); } @@ -626,11 +695,15 @@ export class TerminalTui { return; } - // c to copy - if (key === 'c' || key === 'C') { + // c to copy output, C to copy left pane + if (key === 'c') { this.copySelectedOutput(); return; } + if (key === 'C') { + this.copyLeftPane(); + return; + } // f to toggle auto-follow if (key === 'f' || key === 'F') { @@ -804,6 +877,29 @@ export class TerminalTui { this.copyToClipboard(text); } + private copyLeftPane(): void { + if (this.testOrder.length === 0) { + this.showFlash('No tests'); + return; + } + + const lines: string[] = [ + `Tests: ${this.progress.completed}/${this.progress.total}`, + '', + ]; + + for (const id of this.testOrder) { + const entry = this.tests.get(id)!; + const status = entry.status.toUpperCase().padEnd(10); + const retry = entry.retry > 0 ? ` (retry #${entry.retry})` : ''; + const dur = + entry.duration !== null ? ` [${formatDuration(entry.duration)}]` : ''; + lines.push(`${status} ${entry.title}${retry}${dur}`); + } + + this.copyToClipboard(lines.join('\n')); + } + private copyToClipboard(text: string): void { const candidates: Array<{ cmd: string; args: string[] }> = []; if (process.platform === 'darwin') { diff --git a/tuiReporter.ts b/tuiReporter.ts index c5c3a68..0f7feba 100644 --- a/tuiReporter.ts +++ b/tuiReporter.ts @@ -18,6 +18,7 @@ type TestAndResult = { test: TestCase; result: TestResult }; class TuiReporter implements Reporter { private tui = new TerminalTui(); private allResults: Array = []; + private allTests: TestCase[] = []; private allTestsCount = 0; private countWorkers = 1; private startTime = 0; @@ -27,7 +28,8 @@ class TuiReporter implements Reporter { } onBegin(config: FullConfig, suite: Suite) { - this.allTestsCount = suite.allTests().length; + this.allTests = suite.allTests(); + this.allTestsCount = this.allTests.length; this.countWorkers = config.workers; this.startTime = Date.now(); @@ -39,7 +41,7 @@ class TuiReporter implements Reporter { process.exit(1); }); - for (const test of suite.allTests()) { + for (const test of this.allTests) { this.tui.addTest(test.id, test.title); } @@ -170,9 +172,9 @@ class TuiReporter implements Reporter { } // Tests that never finished (still running/pending when stopped) - const finishedTitles = new Set(Object.keys(grouped)); - - const cancelledCount = this.allTestsCount - finishedTitles.size; + const finishedIds = new Set(this.allResults.map((r) => r.test.id)); + const cancelledTests = this.allTests.filter((t) => !finishedIds.has(t.id)); + const cancelledCount = cancelledTests.length; // Summary line const parts: string[] = []; if (passedCount > 0) @@ -200,14 +202,6 @@ class TuiReporter implements Reporter { })`, ), ); - const lastResult = results[results.length - 1]; - const lastError = - lastResult.result.errors[lastResult.result.errors.length - 1]; - if (lastError?.message) { - console.log( - chalk.dim(` Error: ${lastError.message.split('\n')[0]}`), - ); - } } console.log(''); } @@ -227,6 +221,15 @@ class TuiReporter implements Reporter { console.log(''); } + // Cancelled details + if (cancelledTests.length > 0) { + console.log(chalk.dim.bold(' Cancelled:')); + for (const test of sortBy(cancelledTests, (t) => t.title)) { + console.log(chalk.dim(` \u25a0 ${test.title}`)); + } + console.log(''); + } + // Duration const totalMs = Date.now() - this.startTime; const mins = Math.floor(totalMs / 60000); From a0618b6c9f7a8d7231f79a894fb6137c0ec9315c Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 5 Mar 2026 11:21:26 +1100 Subject: [PATCH 09/17] chore: lint --- tests/automation/locators/index.ts | 1 - tests/automation/types/testing.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index c6b7d16..2ae3699 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -85,7 +85,6 @@ export class Conversation extends Locator { 'ban-user-delete-all-confirm-button', ); static readonly banUserButton = this.testId('ban-user-confirm-button'); - static readonly unbanUserButton = this.testId('unban-user-confirm-button'); static readonly banUserInput = this.testId('ban-user-input'); static readonly unbanUserInput = this.testId('unban-user-input'); static readonly blockMessageRequestButton = this.testId( diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index 160af8b..b8041f7 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -223,6 +223,7 @@ export type DataTestId = | 'theme-section' | 'tooltip-character-count' | 'unban-user-confirm-button' + | 'unban-user-input' | 'unblock-button-settings-screen' | 'update-group-info-name-input' | 'update-profile-info-name-input' From 38fe8ff286975de97591e1b3a66f887238ba8c1c Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 5 Mar 2026 11:24:00 +1100 Subject: [PATCH 10/17] chore: merge main --- tests/automation/pin_unpin.spec.ts | 7 +++---- tests/localization/lib | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/automation/pin_unpin.spec.ts b/tests/automation/pin_unpin.spec.ts index e179754..0fcc2ab 100644 --- a/tests/automation/pin_unpin.spec.ts +++ b/tests/automation/pin_unpin.spec.ts @@ -11,25 +11,24 @@ import { clickOnWithText, getConversationOrder, pasteIntoInput, + rightClickOnWithText, waitForTestIdWithText, } from './utilities/utils'; async function pinConversation(window: Page, conversationName: string) { - await clickOnWithText( + await rightClickOnWithText( window, HomeScreen.conversationItemName, conversationName, - { rightButton: true }, ); await clickOnWithText(window, Global.contextMenuItem, tStripped('pin')); } async function unpinConversation(window: Page, conversationName: string) { - await clickOnWithText( + await rightClickOnWithText( window, HomeScreen.conversationItemName, conversationName, - { rightButton: true }, ); await clickOnWithText(window, Global.contextMenuItem, tStripped('pinUnpin')); } diff --git a/tests/localization/lib b/tests/localization/lib index 7e6603e..8ab418c 160000 --- a/tests/localization/lib +++ b/tests/localization/lib @@ -1 +1 @@ -Subproject commit 7e6603e97326cd267eade5667bf43d3a8dcecd22 +Subproject commit 8ab418ca14a512f30ceb84bb69ac48403289c1ed From 85224868520cc654a9148b7c33b6f847f22e8a01 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 5 Mar 2026 13:16:27 +1100 Subject: [PATCH 11/17] fix: moved all of the community test sending messages to 2 files --- playwright.config.ts | 10 +-- tests/automation/community_tests.spec.ts | 83 ++++++++++++++++++++++- tests/automation/message_requests.spec.ts | 77 --------------------- 3 files changed, 88 insertions(+), 82 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index e7e444b..103cd1a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -31,15 +31,17 @@ export default defineConfig({ snapshotPathTemplate: `${screenshotFolder}/{testName}/{arg}-{platform}{ext}`, projects: [ { - name: 'Community admin tests', - // those needs to be run sequentially - testMatch: '**/community_admin_tests.spec.ts', + name: 'Community tests', + // Those needs to be run sequentially as they are making each others unreliable + // (they all are using the same community) + testMatch: '**/*community*tests.spec.ts', fullyParallel: false, workers: 1, + repeatEach: 3, }, { name: 'All other tests', - testMatch: '**/!(community_admin_tests).spec.ts', + testMatch: '**/!(*community*tests).spec.ts', fullyParallel: true, // otherwise, tests in the same file are not run in parallel workers: toNumber(process.env.PLAYWRIGHT_WORKERS_COUNT) || 1, }, diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index 9436a3b..373ad3a 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -1,11 +1,20 @@ +import { expect } from '@playwright/test'; + +import { tStripped } from '../localization/lib'; import { testCommunityName } from './constants/community'; +import { Global, HomeScreen, LeftPane, Settings } from './locators'; import { test_Alice_1W_Bob_1W, test_Alice_2W } from './setup/sessionTest'; import { openConversationWith } from './utilities/conversation'; import { joinCommunity } from './utilities/join_community'; import { sendMessage } from './utilities/message'; import { replyTo } from './utilities/reply_message'; import { sendMedia } from './utilities/send_media'; -import { scrollToBottomIfNecessary } from './utilities/utils'; +import { + clickOn, + grabTextFromElement, + scrollToBottomIfNecessary, + waitForTestIdWithText, +} from './utilities/utils'; test_Alice_2W( 'Join community and sync', @@ -45,3 +54,75 @@ test_Alice_1W_Bob_1W( }); }, ); + +test_Alice_1W_Bob_1W( + 'Community message requests on', + async ({ alice, aliceWindow1, bob, bobWindow1 }) => { + await clickOn(bobWindow1, LeftPane.settingsButton); + await clickOn(bobWindow1, Settings.privacyMenuItem); + await clickOn(bobWindow1, Settings.enableCommunityMessageRequests); + await clickOn(bobWindow1, Global.modalCloseButton); + await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); + const communityMsg = `I accept message requests + ${Date.now()}`; + await sendMessage(bobWindow1, communityMsg); + await scrollToBottomIfNecessary(aliceWindow1); + // Using native methods to locate the author corresponding to the sent message + await aliceWindow1 + .locator('.module-message__container', { hasText: communityMsg }) + .locator('..') // Go up to parent + .locator('svg') + .click(); + const elText = await grabTextFromElement( + aliceWindow1, + 'data-testid', + 'account-id', + ); + expect(elText).toMatch(/^15/); + await clickOn(aliceWindow1, HomeScreen.newMessageAccountIDInput); // yes this is the actual locator for the 'Message' button + await waitForTestIdWithText( + aliceWindow1, + 'header-conversation-name', + bob.userName, + ); + const messageRequestMsg = `${alice.userName} to ${bob.userName}`; + const messageRequestResponse = `${bob.userName} accepts message request`; + await sendMessage(aliceWindow1, messageRequestMsg); + await clickOn(bobWindow1, HomeScreen.messageRequestBanner); + // Select message request from User A + await openConversationWith(bobWindow1, alice.userName); + await sendMessage(bobWindow1, messageRequestResponse); + // Check config message of message request acceptance + await waitForTestIdWithText( + bobWindow1, + 'message-request-response-message', + tStripped('messageRequestYouHaveAccepted', { + name: alice.userName, + }), + ); + }, +); +test_Alice_1W_Bob_1W( + 'Community message requests off', + async ({ aliceWindow1, bobWindow1 }) => { + await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); + const communityMsg = `I do not accept message requests + ${Date.now()}`; + await sendMessage(bobWindow1, communityMsg); + await scrollToBottomIfNecessary(aliceWindow1); + // Using native methods to locate the author corresponding to the sent message + await aliceWindow1 + .locator('.module-message__container', { hasText: communityMsg }) + .locator('..') // Go up to parent + .locator('svg') + .click(); + const elText = await grabTextFromElement( + aliceWindow1, + 'data-testid', + 'account-id', + ); + expect(elText).toMatch(/^15/); + const messageButton = aliceWindow1.getByTestId( + HomeScreen.newMessageAccountIDInput.selector, + ); + await expect(messageButton).toHaveClass(/disabled/); + }, +); diff --git a/tests/automation/message_requests.spec.ts b/tests/automation/message_requests.spec.ts index 7bf52b9..62b0a11 100644 --- a/tests/automation/message_requests.spec.ts +++ b/tests/automation/message_requests.spec.ts @@ -1,5 +1,3 @@ -import { expect } from '@playwright/test'; - import { tStripped } from '../localization/lib'; import { Conversation, @@ -10,7 +8,6 @@ import { } from './locators'; import { test_Alice_1W_Bob_1W } from './setup/sessionTest'; import { openConversationWith } from './utilities/conversation'; -import { joinCommunity } from './utilities/join_community'; import { sendMessage } from './utilities/message'; import { sendNewMessage } from './utilities/send_message'; import { @@ -18,8 +15,6 @@ import { clickOn, clickOnMatchingText, clickOnWithText, - grabTextFromElement, - scrollToBottomIfNecessary, waitForMatchingText, waitForTestIdWithText, } from './utilities/utils'; @@ -152,75 +147,3 @@ test_Alice_1W_Bob_1W( ); }, ); - -test_Alice_1W_Bob_1W( - 'Community message requests on', - async ({ alice, aliceWindow1, bob, bobWindow1 }) => { - await clickOn(bobWindow1, LeftPane.settingsButton); - await clickOn(bobWindow1, Settings.privacyMenuItem); - await clickOn(bobWindow1, Settings.enableCommunityMessageRequests); - await clickOn(bobWindow1, Global.modalCloseButton); - await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); - const communityMsg = `I accept message requests + ${Date.now()}`; - await sendMessage(bobWindow1, communityMsg); - await scrollToBottomIfNecessary(aliceWindow1); - // Using native methods to locate the author corresponding to the sent message - await aliceWindow1 - .locator('.module-message__container', { hasText: communityMsg }) - .locator('..') // Go up to parent - .locator('svg') - .click(); - const elText = await grabTextFromElement( - aliceWindow1, - 'data-testid', - 'account-id', - ); - expect(elText).toMatch(/^15/); - await clickOn(aliceWindow1, HomeScreen.newMessageAccountIDInput); // yes this is the actual locator for the 'Message' button - await waitForTestIdWithText( - aliceWindow1, - 'header-conversation-name', - bob.userName, - ); - const messageRequestMsg = `${alice.userName} to ${bob.userName}`; - const messageRequestResponse = `${bob.userName} accepts message request`; - await sendMessage(aliceWindow1, messageRequestMsg); - await clickOn(bobWindow1, HomeScreen.messageRequestBanner); - // Select message request from User A - await openConversationWith(bobWindow1, alice.userName); - await sendMessage(bobWindow1, messageRequestResponse); - // Check config message of message request acceptance - await waitForTestIdWithText( - bobWindow1, - 'message-request-response-message', - tStripped('messageRequestYouHaveAccepted', { - name: alice.userName, - }), - ); - }, -); -test_Alice_1W_Bob_1W( - 'Community message requests off', - async ({ aliceWindow1, bobWindow1 }) => { - await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); - const communityMsg = `I do not accept message requests + ${Date.now()}`; - await sendMessage(bobWindow1, communityMsg); - await scrollToBottomIfNecessary(aliceWindow1); - // Using native methods to locate the author corresponding to the sent message - await aliceWindow1 - .locator('.module-message__container', { hasText: communityMsg }) - .locator('..') // Go up to parent - .locator('svg') - .click(); - const elText = await grabTextFromElement( - aliceWindow1, - 'data-testid', - 'account-id', - ); - expect(elText).toMatch(/^15/); - const messageButton = aliceWindow1.getByTestId( - HomeScreen.newMessageAccountIDInput.selector, - ); - await expect(messageButton).toHaveClass(/disabled/); - }, -); From 072225ccd16a5f9eecee50caabeaf1b91b0b1fa6 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 5 Mar 2026 15:45:28 +1100 Subject: [PATCH 12/17] chore: add comments to playwright.config.ts --- playwright.config.ts | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 103cd1a..2640751 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,6 +6,24 @@ import { screenshotFolder } from './tests/automation/constants/variables'; dotenv.config({ quiet: true }); +function repeatEach() { + return process.env.PLAYWRIGHT_REPEAT_COUNT + ? toNumber(process.env.PLAYWRIGHT_REPEAT_COUNT) + : 0; +} + +function retryEach() { + return process.env.PLAYWRIGHT_RETRIES_COUNT + ? toNumber(process.env.PLAYWRIGHT_RETRIES_COUNT) + : 0; +} + +function workersCount() { + return process.env.PLAYWRIGHT_WORKERS_COUNT + ? toNumber(process.env.PLAYWRIGHT_WORKERS_COUNT) + : 1; +} + export default defineConfig({ timeout: 350000, globalTimeout: 6000000, @@ -20,30 +38,30 @@ export default defineConfig({ testDir: './tests/automation', testIgnore: '*.js', outputDir: './tests/automation/test-results', - retries: process.env.PLAYWRIGHT_RETRIES_COUNT - ? toNumber(process.env.PLAYWRIGHT_RETRIES_COUNT) - : 0, - repeatEach: process.env.PLAYWRIGHT_REPEAT_COUNT - ? toNumber(process.env.PLAYWRIGHT_REPEAT_COUNT) - : 0, + retries: retryEach(), + repeatEach: repeatEach(), reportSlowTests: null, globalSetup: './global.setup', // clean leftovers of previous test runs on start, runs only once snapshotPathTemplate: `${screenshotFolder}/{testName}/{arg}-{platform}{ext}`, projects: [ + /** + * The community tests relying on sending/receiving messages are unreliable when run in parallel. + * I think it comes down to the jump that happens when a new message is received, and also + * because receiving a new message closes an open context menu. + */ { name: 'Community tests', // Those needs to be run sequentially as they are making each others unreliable // (they all are using the same community) testMatch: '**/*community*tests.spec.ts', fullyParallel: false, - workers: 1, - repeatEach: 3, + workers: 1, // those community tests need to be run sequentially }, { name: 'All other tests', testMatch: '**/!(*community*tests).spec.ts', - fullyParallel: true, // otherwise, tests in the same file are not run in parallel - workers: toNumber(process.env.PLAYWRIGHT_WORKERS_COUNT) || 1, + fullyParallel: true, // set this to true so that tests in the same file are not run in parallel + workers: workersCount(), }, ], }); From c6ba1ae6e89627fd4b75129ffe3a5467375685d0 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 6 Mar 2026 11:06:02 +1100 Subject: [PATCH 13/17] chore: reapply `sort-classes` rule --- eslint.config.mjs | 6 ++++ tests/automation/locators/index.ts | 52 +++++++++++++++--------------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7fb26fa..609bbd2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -81,6 +81,12 @@ export default tseslint.config( ], }, ], + 'perfectionist/sort-classes': [ + 'error', + { + partitionByComment: true, + }, + ], }, }, ); diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index efc3048..10cf87a 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -70,10 +70,10 @@ export class HomeScreen extends Locator { static readonly conversationItemName = this.testId( 'module-conversation__user__profile-name', ); + static readonly messageRequestBanner = this.testId('message-request-banner'); static readonly pinnedConversationIcon = this.testId( 'conversation-item-pinned', ); - static readonly messageRequestBanner = this.testId('message-request-banner'); static readonly plusButton = this.testId('new-conversation-button'); static readonly revealRecoveryPhraseButton = this.testId( 'reveal-recovery-phrase', @@ -87,16 +87,25 @@ export class Conversation extends Locator { static readonly acceptMessageRequestButton = this.testId( 'accept-message-request', ); + static readonly audioPlayer = this.testId('audio-player'); static readonly banAndDeleteAllButton = this.testId( 'ban-user-delete-all-confirm-button', ); static readonly banUserButton = this.testId('ban-user-confirm-button'); static readonly banUserInput = this.testId('ban-user-input'); - static readonly unbanUserInput = this.testId('unban-user-input'); static readonly blockMessageRequestButton = this.testId( 'decline-and-block-message-request', ); static readonly callButton = this.testId('call-button'); + static readonly callNotificationAnswered = this.testId( + 'call-notification-answered-a-call', + ); + static readonly callNotificationStarted = this.testId( + 'call-notification-started-call', + ); + static readonly communityInvitationDetails = this.testId( + 'community-invitation-details', + ); static readonly conversationHeader = this.testId('header-conversation-name'); static readonly conversationSettingsIcon = this.testId( 'conversation-options-avatar', @@ -110,47 +119,38 @@ export class Conversation extends Locator { static readonly DisappearMessagesTypeAndTime = this.testId( 'disappear-messages-type-and-time', ); - static readonly quoteText = this.testId('quote-text'); + static readonly EmptyMessageViewCreated = this.testId( + 'empty-msg-view-account-created', + ); + static readonly EmptyMessageViewWelcome = this.testId( + 'empty-msg-view-welcome', + ); static readonly endCallButton = this.testId('end-call'); static readonly endVoiceMessageButton = this.testId('end-voice-message'); + + static readonly groupName = this.testId('group-name'); + static readonly linkPreviewTitle = this.testId('msg-link-preview-title'); static readonly mentionsContainer = this.testId('mentions-container'); // This is also the locator for emojis static readonly mentionsItem = this.testId('mentions-container-row'); // This is also the locator for emojis static readonly messageContent = this.testId('message-content'); - static readonly linkPreviewTitle = this.testId('msg-link-preview-title'); - static readonly messageInput = this.testId('message-input-text-area'); + static readonly messageRequestAcceptControlMessage = this.testId( 'message-request-response-message', ); static readonly microphoneButton = this.testId('microphone-button'); + + static readonly quoteText = this.testId('quote-text'); static readonly scrollToBottomButton = this.testId('scroll-to-bottom-button'); static readonly sendMessageButton = this.testId('send-message-button'); - static readonly unbanUserButton = this.testId('unban-user-confirm-button'); - static readonly groupName = this.testId('group-name'); - static readonly communityInvitationDetails = this.testId( - 'community-invitation-details', - ); - - static readonly audioPlayer = this.testId('audio-player'); - static readonly callNotificationAnswered = this.testId( - 'call-notification-answered-a-call', - ); - static readonly callNotificationStarted = this.testId( - 'call-notification-started-call', - ); + static readonly SessionConversation = this.className('session-conversation'); static readonly tooltipCharacterCount = this.testId( 'tooltip-character-count', ); - - static readonly SessionConversation = this.className('session-conversation'); - static readonly EmptyMessageViewCreated = this.testId( - 'empty-msg-view-account-created', - ); - static readonly EmptyMessageViewWelcome = this.testId( - 'empty-msg-view-welcome', - ); + static readonly unbanUserButton = this.testId('unban-user-confirm-button'); + static readonly unbanUserInput = this.testId('unban-user-input'); } export class ConversationSettings extends Locator { From dfdac8289c23a0c5a569e40a1be5f622f1bd9916 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 6 Mar 2026 11:10:28 +1100 Subject: [PATCH 14/17] chore: linting --- eslint.config.mjs | 2 +- terminalTui.ts | 904 +++++++++++++++++++++++----------------------- tuiReporter.ts | 98 ++--- 3 files changed, 502 insertions(+), 502 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 609bbd2..0d74201 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -82,7 +82,7 @@ export default tseslint.config( }, ], 'perfectionist/sort-classes': [ - 'error', + 'error', { partitionByComment: true, }, diff --git a/terminalTui.ts b/terminalTui.ts index cd7524f..17632cc 100644 --- a/terminalTui.ts +++ b/terminalTui.ts @@ -135,34 +135,104 @@ function wrapLine(line: string, width: number): string[] { // --- Main class --- export class TerminalTui { - private tests: Map = new Map(); - private testOrder: string[] = []; - private selectedIndex = 0; - private outputScrollOffset = 0; private activePaneFocus: 'list' | 'output' = 'list'; + private autoFollow = true; + private elapsedTimer: ReturnType | null = null; + private exitHandler: (() => void) | null = null; + private flashMessage: string | null = null; + private flashTimeout: ReturnType | null = null; + private frozenElapsedMs: number | null = null; // set when suite finishes + private isActive = false; + private keyHandler: ((data: Buffer) => void) | null = null; + private lastLeftWidth = 30; // saved from render() for mouse click mapping + private lastListStart = 0; // saved from render() for mouse click mapping + private lastOutputLines: string[] = []; // cached from last render for selection copy + private lastUserInteractionTime = 0; + private onStopCallback: StopCallback | null = null; + private originalStdinRawMode: boolean | undefined; + private outputScrollOffset = 0; private progress: TuiProgress = { completed: 0, estimatedMinsLeft: 0, total: 0, }; - private isActive = false; private renderScheduled = false; - private originalStdinRawMode: boolean | undefined; - private keyHandler: ((data: Buffer) => void) | null = null; private resizeHandler: (() => void) | null = null; - private exitHandler: (() => void) | null = null; - private flashMessage: string | null = null; - private flashTimeout: ReturnType | null = null; - private onStopCallback: StopCallback | null = null; - private lastListStart = 0; // saved from render() for mouse click mapping - private lastLeftWidth = 30; // saved from render() for mouse click mapping - private autoFollow = true; - private lastUserInteractionTime = 0; + private selectedIndex = 0; private selection: { startRow: number; endRow: number } | null = null; - private lastOutputLines: string[] = []; // cached from last render for selection copy private startTime: number | null = null; - private frozenElapsedMs: number | null = null; // set when suite finishes - private elapsedTimer: ReturnType | null = null; + private testOrder: string[] = []; + private tests: Map = new Map(); + + addTest(id: string, title: string): void { + this.tests.set(id, { + duration: null, + errors: [], + id, + output: [], + retry: 0, + status: 'pending', + title, + }); + this.testOrder.push(id); + this.scheduleRender(); + } + + appendOutput(id: string, text: string): void { + const entry = this.tests.get(id); + if (!entry) return; + + const lines = text.split(/\r?\n/); + if (lines.length > 0 && entry.output.length > 0 && !text.startsWith('\n')) { + entry.output[entry.output.length - 1] += lines.shift()!; + } + entry.output.push(...lines); + + // Cap output buffer + if (entry.output.length > MAX_OUTPUT_LINES) { + entry.output = entry.output.slice(-MAX_OUTPUT_LINES); + } + + if (this.testOrder[this.selectedIndex] === id) { + // Auto-scroll output to bottom for the selected test + this.outputScrollOffset = Number.MAX_SAFE_INTEGER; // clamped in render + this.scheduleRender(); + } + } + + clearOutput(id: string): void { + const entry = this.tests.get(id); + if (!entry) return; + entry.output = []; + entry.errors = []; + if (this.testOrder[this.selectedIndex] === id) { + this.outputScrollOffset = 0; + this.scheduleRender(); + } + } + + onStop(cb: StopCallback): void { + this.onStopCallback = cb; + } + + setError( + id: string, + errors: Array<{ message?: string; snippet?: string; stack?: string }>, + ): void { + const entry = this.tests.get(id); + if (!entry) return; + entry.errors = errors; + this.scheduleRender(); + } + + setProgress( + completed: number, + total: number, + estimatedMinsLeft: number, + ): void { + this.progress = { completed, estimatedMinsLeft, total }; + this.scheduleRender(); + } start(): void { if (!process.stdout.isTTY) return; @@ -216,24 +286,6 @@ export class TerminalTui { this.restoreTerminal(); } - onStop(cb: StopCallback): void { - this.onStopCallback = cb; - } - - addTest(id: string, title: string): void { - this.tests.set(id, { - duration: null, - errors: [], - id, - output: [], - retry: 0, - status: 'pending', - title, - }); - this.testOrder.push(id); - this.scheduleRender(); - } - updateTest( id: string, status: TestStatus, @@ -263,58 +315,6 @@ export class TerminalTui { this.scheduleRender(); } - appendOutput(id: string, text: string): void { - const entry = this.tests.get(id); - if (!entry) return; - - const lines = text.split(/\r?\n/); - if (lines.length > 0 && entry.output.length > 0 && !text.startsWith('\n')) { - entry.output[entry.output.length - 1] += lines.shift()!; - } - entry.output.push(...lines); - - // Cap output buffer - if (entry.output.length > MAX_OUTPUT_LINES) { - entry.output = entry.output.slice(-MAX_OUTPUT_LINES); - } - - if (this.testOrder[this.selectedIndex] === id) { - // Auto-scroll output to bottom for the selected test - this.outputScrollOffset = Number.MAX_SAFE_INTEGER; // clamped in render - this.scheduleRender(); - } - } - - clearOutput(id: string): void { - const entry = this.tests.get(id); - if (!entry) return; - entry.output = []; - entry.errors = []; - if (this.testOrder[this.selectedIndex] === id) { - this.outputScrollOffset = 0; - this.scheduleRender(); - } - } - - setError( - id: string, - errors: Array<{ message?: string; snippet?: string; stack?: string }>, - ): void { - const entry = this.tests.get(id); - if (!entry) return; - entry.errors = errors; - this.scheduleRender(); - } - - setProgress( - completed: number, - total: number, - estimatedMinsLeft: number, - ): void { - this.progress = { completed, estimatedMinsLeft, total }; - this.scheduleRender(); - } - /** Re-sort the test list for summary view: passed → flaky → failed, each sorted by title */ reorderForSummary(): void { const statusPriority = (entry: TuiTestEntry): number => { @@ -372,11 +372,6 @@ export class TerminalTui { } /** Adjust output scroll offset (clamped in render) */ - private scrollOutput(offset: number): void { - this.outputScrollOffset = Math.max(0, offset); - this.scheduleRender(); - } - private scheduleRender(): void { if (!this.isActive || this.renderScheduled) return; this.renderScheduled = true; @@ -386,251 +381,35 @@ export class TerminalTui { }); } - /** Returns a 1-char scrollbar indicator for a given row in the track. */ - private scrollbarChar( - total: number, - visible: number, - offset: number, - trackHeight: number, - row: number, - ): string { - if (total <= visible) { - return chalk.dim('\u2502'); // │ — no scrolling needed - } - const thumbSize = Math.max(1, Math.round((visible / total) * trackHeight)); - const maxOffset = total - visible; - const clampedOffset = Math.min(offset, maxOffset); - const thumbStart = Math.round( - (clampedOffset / maxOffset) * (trackHeight - thumbSize), - ); - if (row >= thumbStart && row < thumbStart + thumbSize) { - return chalk.white('\u2588'); // █ thumb - } - return chalk.dim('\u2591'); // ░ track + private scrollOutput(offset: number): void { + this.outputScrollOffset = Math.max(0, offset); + this.scheduleRender(); } - private render(): void { - const cols = process.stdout.columns || 80; - const rows = process.stdout.rows || 24; - - if (cols < 60 || rows < 10) { - const msg = 'Terminal too small (min 60x10)'; - const r = Math.floor(rows / 2); - const c = Math.max(1, Math.floor((cols - msg.length) / 2)); - process.stdout.write( - MOVE_TO(1, 1) + ESC + '[2J' + MOVE_TO(r, c) + chalk.yellow(msg), - ); - return; - } - - const leftWidth = Math.min(Math.max(30, Math.floor(cols * 0.4)), cols - 22); - const rightWidth = cols - leftWidth - 3; // 3 = left border + divider + right border - const contentHeight = rows - 3; // header + bottom divider + status bar + /** Returns a 1-char scrollbar indicator for a given row in the track. */ + private buildOutputLines( + entry: TuiTestEntry | undefined, + width: number, + ): string[] { + if (!entry) return [chalk.dim(' No test selected')]; - let buf = MOVE_TO(1, 1); + const lines: string[] = []; - // --- Header --- - const leftHeader = ` Tests (${this.progress.completed}/${this.progress.total}) `; - const selectedTest = this.tests.get( - this.testOrder[this.selectedIndex] ?? '', - ); - const rightHeaderLabel = selectedTest - ? ` Output: ${truncate(selectedTest.title, rightWidth - 12)} ` - : ' Output '; + if (entry.output.length === 0 && entry.errors.length === 0) { + if (entry.status === 'pending') { + lines.push(chalk.dim('Waiting to start...')); + } else if (entry.status === 'running' || entry.status === 'retrying') { + lines.push(chalk.dim('Running... (no output yet)')); + } else { + lines.push(chalk.dim('No output')); + } + return lines; + } - const leftFill = Math.max(0, leftWidth - leftHeader.length - 1); - const rightFill = Math.max(0, rightWidth - rightHeaderLabel.length - 1); - - buf += CLEAR_LINE; - buf += - chalk.dim('\u250c') + - chalk.dim('\u2500') + - chalk.bold(leftHeader) + - chalk.dim('\u2500'.repeat(leftFill)); - buf += - chalk.dim('\u252c') + - chalk.dim('\u2500') + - chalk.bold(rightHeaderLabel) + - chalk.dim('\u2500'.repeat(rightFill)); - buf += chalk.dim('\u2510'); - - // --- Content rows --- - // Left pane: scrolling window around selectedIndex - const listLen = this.testOrder.length; - let listStart = 0; - if (listLen > contentHeight) { - listStart = Math.max( - 0, - Math.min( - this.selectedIndex - Math.floor(contentHeight / 2), - listLen - contentHeight, - ), - ); - } - this.lastListStart = listStart; - this.lastLeftWidth = leftWidth; - - // Right pane: build wrapped output lines (−1 for scrollbar column) - const outputLines = this.buildOutputLines(selectedTest, rightWidth - 3); - this.lastOutputLines = outputLines; - const maxScroll = Math.max(0, outputLines.length - contentHeight); - this.outputScrollOffset = Math.min(this.outputScrollOffset, maxScroll); - - // Selection range (normalized) - const selLo = this.selection - ? Math.min(this.selection.startRow, this.selection.endRow) - : -1; - const selHi = this.selection - ? Math.max(this.selection.startRow, this.selection.endRow) - : -1; - - for (let row = 0; row < contentHeight; row++) { - const screenRow = row + 2; - buf += MOVE_TO(screenRow, 1) + CLEAR_LINE; - - // Left cell - const testIdx = listStart + row; - let leftCell; - if (testIdx < listLen) { - const entry = this.tests.get(this.testOrder[testIdx])!; - const isSelected = testIdx === this.selectedIndex; - const label = statusLabel(entry.status); - const retryStr = - entry.retry > 0 ? chalk.dim(`r:${entry.retry}`) + ' ' : ''; - const durStr = - entry.duration !== null - ? chalk.dim(formatDuration(entry.duration)) - : chalk.dim('--'); - const maxTitleLen = - leftWidth - - 1 - // scrollbar column - 4 - - 5 - - (entry.retry > 0 ? 4 + String(entry.retry).length : 0) - - 5; - const title = truncate(entry.title, Math.max(5, maxTitleLen)); - - const line = ` ${label} ${retryStr}${title}`; - const lineWithDur = - padRight(line, leftWidth - 1 - visibleLength(durStr) - 2) + - durStr + - ' '; - - leftCell = isSelected - ? this.activePaneFocus === 'list' - ? chalk.inverse(padRight(lineWithDur, leftWidth - 1)) - : chalk.bgGray(padRight(lineWithDur, leftWidth - 1)) - : padRight(lineWithDur, leftWidth - 1); - } else { - leftCell = ' '.repeat(leftWidth - 1); - } - - const leftScrollbar = this.scrollbarChar( - listLen, - contentHeight, - listStart, - contentHeight, - row, - ); - buf += chalk.dim('\u2502') + leftCell + leftScrollbar; - - // Divider - buf += chalk.dim('\u2502'); - - // Right cell (−1 for scrollbar column) - const outIdx = this.outputScrollOffset + row; - let rightCell = ''; - if (outIdx < outputLines.length) { - rightCell = ' ' + truncate(outputLines[outIdx], rightWidth - 3) + RESET; - } - const isSelected = outIdx >= selLo && outIdx <= selHi; - const rightScrollbar = this.scrollbarChar( - outputLines.length, - contentHeight, - this.outputScrollOffset, - contentHeight, - row, - ); - buf += - (isSelected - ? chalk.inverse(padRight(rightCell, rightWidth - 1)) - : padRight(rightCell, rightWidth - 1)) + rightScrollbar; - } - - // --- Bottom divider --- - const bottomRow = contentHeight + 2; - buf += MOVE_TO(bottomRow, 1) + CLEAR_LINE; - buf += chalk.dim( - '\u2514' + - '\u2500'.repeat(leftWidth) + - '\u2534' + - '\u2500'.repeat(rightWidth) + - '\u2518', - ); - - // --- Status bar --- - const statusRow = bottomRow + 1; - buf += MOVE_TO(statusRow, 1) + CLEAR_LINE; - - const listHint = - this.activePaneFocus === 'list' - ? chalk.bold('\u2191\u2193 navigate') - : chalk.dim('\u2191\u2193 scroll'); - const tabHint = chalk.dim('Tab') + ' switch'; - const qHint = chalk.dim('q') + ' quit'; - const cHint = - chalk.dim('c') + ' copy output ' + chalk.dim('C') + ' copy list'; - const isFollowing = - this.autoFollow && Date.now() - this.lastUserInteractionTime > 30_000; - const fHint = isFollowing - ? chalk.green('f') + chalk.green(' follow') - : chalk.dim('f') + ' follow'; - const elapsedMs = - this.frozenElapsedMs ?? - (this.startTime !== null ? Date.now() - this.startTime : null); - const elapsedStr = - elapsedMs !== null - ? chalk.dim(` ${formatDuration(elapsedMs)} elapsed`) - : ''; - const progressStr = chalk.dim( - `${this.progress.completed}/${this.progress.total} done`, - ); - const estStr = - this.progress.estimatedMinsLeft > 0 - ? chalk.dim(`, ~${this.progress.estimatedMinsLeft}min left`) - : ''; - const flash = this.flashMessage ? chalk.green(` ${this.flashMessage}`) : ''; - - buf += ` ${listHint} ${tabHint} ${qHint} ${cHint} ${fHint} ${chalk.dim( - '|', - )} ${progressStr}${elapsedStr}${estStr}${flash}`; - - process.stdout.write(buf); - } - - private buildOutputLines( - entry: TuiTestEntry | undefined, - width: number, - ): string[] { - if (!entry) return [chalk.dim(' No test selected')]; - - const lines: string[] = []; - - if (entry.output.length === 0 && entry.errors.length === 0) { - if (entry.status === 'pending') { - lines.push(chalk.dim('Waiting to start...')); - } else if (entry.status === 'running' || entry.status === 'retrying') { - lines.push(chalk.dim('Running... (no output yet)')); - } else { - lines.push(chalk.dim('No output')); - } - return lines; - } - - // stdout/stderr output - for (const line of entry.output) { - lines.push(...wrapLine(line, width)); - } + // stdout/stderr output + for (const line of entry.output) { + lines.push(...wrapLine(line, width)); + } // errors if (entry.errors.length > 0) { @@ -661,6 +440,82 @@ export class TerminalTui { return lines; } + private copyLeftPane(): void { + if (this.testOrder.length === 0) { + this.showFlash('No tests'); + return; + } + + const lines: string[] = [ + `Tests: ${this.progress.completed}/${this.progress.total}`, + '', + ]; + + for (const id of this.testOrder) { + const entry = this.tests.get(id)!; + const status = entry.status.toUpperCase().padEnd(10); + const retry = entry.retry > 0 ? ` (retry #${entry.retry})` : ''; + const dur = + entry.duration !== null ? ` [${formatDuration(entry.duration)}]` : ''; + lines.push(`${status} ${entry.title}${retry}${dur}`); + } + + this.copyToClipboard(lines.join('\n')); + } + + private copySelectedOutput(): void { + const entry = this.tests.get(this.testOrder[this.selectedIndex] ?? ''); + if (!entry) return; + + let text = `Test: ${entry.title}\nStatus: ${entry.status}`; + if (entry.duration !== null) { + text += ` (${formatDuration(entry.duration)})`; + } + if (entry.retry > 0) { + text += ` retry #${entry.retry}`; + } + text += '\n\n'; + + if (entry.output.length > 0) { + text += entry.output.map(stripAnsi).join('\n') + '\n'; + } + + for (const err of entry.errors) { + text += '\n--- Error ---\n'; + if (err.message) text += err.message + '\n'; + if (err.snippet) text += err.snippet + '\n'; + if (err.stack) text += err.stack + '\n'; + } + + this.copyToClipboard(text); + } + + private copyToClipboard(text: string): void { + const candidates: Array<{ cmd: string; args: string[] }> = []; + if (process.platform === 'darwin') { + candidates.push({ cmd: 'pbcopy', args: [] }); + } else if (process.platform === 'win32') { + candidates.push({ cmd: 'clip', args: [] }); + } else { + if (process.env.WAYLAND_DISPLAY) { + candidates.push({ cmd: 'wl-copy', args: [] }); + } + candidates.push({ cmd: 'xclip', args: ['-selection', 'clipboard'] }); + candidates.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }); + } + this.tryCopyWithCandidates(text, candidates, 0); + } + + private copyViaOsc52(text: string): void { + try { + const encoded = Buffer.from(text).toString('base64'); + process.stdout.write(`\x1b]52;c;${encoded}\x07`); + this.showFlash('Copied (OSC 52)'); + } catch { + this.showFlash('Copy failed'); + } + } + private handleKey(data: Buffer): void { const key = data.toString('utf-8'); @@ -828,92 +683,286 @@ export class TerminalTui { this.selection.endRow = outputLineIdx; this.scheduleRender(); } - } + } + + if (!isPress && !isMotion && this.selection) { + // Release — copy selected lines + const { startRow, endRow } = this.selection; + const lo = Math.max(0, Math.min(startRow, endRow)); + const hi = Math.min( + this.lastOutputLines.length - 1, + Math.max(startRow, endRow), + ); + if (lo <= hi) { + const selectedText = this.lastOutputLines + .slice(lo, hi + 1) + .map(stripAnsi) + .join('\n'); + this.copyToClipboard(selectedText); + } + this.selection = null; + this.scheduleRender(); + } + } + + private removeListeners(): void { + if (this.keyHandler) { + process.stdin.removeListener('data', this.keyHandler); + this.keyHandler = null; + } + if (this.resizeHandler) { + process.stdout.removeListener('resize', this.resizeHandler); + this.resizeHandler = null; + } + if (this.exitHandler) { + process.removeListener('exit', this.exitHandler); + this.exitHandler = null; + } + } + + private render(): void { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + + if (cols < 60 || rows < 10) { + const msg = 'Terminal too small (min 60x10)'; + const r = Math.floor(rows / 2); + const c = Math.max(1, Math.floor((cols - msg.length) / 2)); + process.stdout.write( + MOVE_TO(1, 1) + ESC + '[2J' + MOVE_TO(r, c) + chalk.yellow(msg), + ); + return; + } + + const leftWidth = Math.min(Math.max(30, Math.floor(cols * 0.4)), cols - 22); + const rightWidth = cols - leftWidth - 3; // 3 = left border + divider + right border + const contentHeight = rows - 3; // header + bottom divider + status bar + + let buf = MOVE_TO(1, 1); + + // --- Header --- + const leftHeader = ` Tests (${this.progress.completed}/${this.progress.total}) `; + const selectedTest = this.tests.get( + this.testOrder[this.selectedIndex] ?? '', + ); + const rightHeaderLabel = selectedTest + ? ` Output: ${truncate(selectedTest.title, rightWidth - 12)} ` + : ' Output '; + + const leftFill = Math.max(0, leftWidth - leftHeader.length - 1); + const rightFill = Math.max(0, rightWidth - rightHeaderLabel.length - 1); + + buf += CLEAR_LINE; + buf += + chalk.dim('\u250c') + + chalk.dim('\u2500') + + chalk.bold(leftHeader) + + chalk.dim('\u2500'.repeat(leftFill)); + buf += + chalk.dim('\u252c') + + chalk.dim('\u2500') + + chalk.bold(rightHeaderLabel) + + chalk.dim('\u2500'.repeat(rightFill)); + buf += chalk.dim('\u2510'); + + // --- Content rows --- + // Left pane: scrolling window around selectedIndex + const listLen = this.testOrder.length; + let listStart = 0; + if (listLen > contentHeight) { + listStart = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(contentHeight / 2), + listLen - contentHeight, + ), + ); + } + this.lastListStart = listStart; + this.lastLeftWidth = leftWidth; + + // Right pane: build wrapped output lines (−1 for scrollbar column) + const outputLines = this.buildOutputLines(selectedTest, rightWidth - 3); + this.lastOutputLines = outputLines; + const maxScroll = Math.max(0, outputLines.length - contentHeight); + this.outputScrollOffset = Math.min(this.outputScrollOffset, maxScroll); + + // Selection range (normalized) + const selLo = this.selection + ? Math.min(this.selection.startRow, this.selection.endRow) + : -1; + const selHi = this.selection + ? Math.max(this.selection.startRow, this.selection.endRow) + : -1; + + for (let row = 0; row < contentHeight; row++) { + const screenRow = row + 2; + buf += MOVE_TO(screenRow, 1) + CLEAR_LINE; + + // Left cell + const testIdx = listStart + row; + let leftCell; + if (testIdx < listLen) { + const entry = this.tests.get(this.testOrder[testIdx])!; + const isSelected = testIdx === this.selectedIndex; + const label = statusLabel(entry.status); + const retryStr = + entry.retry > 0 ? chalk.dim(`r:${entry.retry}`) + ' ' : ''; + const durStr = + entry.duration !== null + ? chalk.dim(formatDuration(entry.duration)) + : chalk.dim('--'); + const maxTitleLen = + leftWidth - + 1 - // scrollbar column + 4 - + 5 - + (entry.retry > 0 ? 4 + String(entry.retry).length : 0) - + 5; + const title = truncate(entry.title, Math.max(5, maxTitleLen)); + + const line = ` ${label} ${retryStr}${title}`; + const lineWithDur = + padRight(line, leftWidth - 1 - visibleLength(durStr) - 2) + + durStr + + ' '; + + leftCell = isSelected + ? this.activePaneFocus === 'list' + ? chalk.inverse(padRight(lineWithDur, leftWidth - 1)) + : chalk.bgGray(padRight(lineWithDur, leftWidth - 1)) + : padRight(lineWithDur, leftWidth - 1); + } else { + leftCell = ' '.repeat(leftWidth - 1); + } - if (!isPress && !isMotion && this.selection) { - // Release — copy selected lines - const { startRow, endRow } = this.selection; - const lo = Math.max(0, Math.min(startRow, endRow)); - const hi = Math.min( - this.lastOutputLines.length - 1, - Math.max(startRow, endRow), + const leftScrollbar = this.scrollbarChar( + listLen, + contentHeight, + listStart, + contentHeight, + row, ); - if (lo <= hi) { - const selectedText = this.lastOutputLines - .slice(lo, hi + 1) - .map(stripAnsi) - .join('\n'); - this.copyToClipboard(selectedText); + buf += chalk.dim('\u2502') + leftCell + leftScrollbar; + + // Divider + buf += chalk.dim('\u2502'); + + // Right cell (−1 for scrollbar column) + const outIdx = this.outputScrollOffset + row; + let rightCell = ''; + if (outIdx < outputLines.length) { + rightCell = ' ' + truncate(outputLines[outIdx], rightWidth - 3) + RESET; } - this.selection = null; - this.scheduleRender(); + const isSelected = outIdx >= selLo && outIdx <= selHi; + const rightScrollbar = this.scrollbarChar( + outputLines.length, + contentHeight, + this.outputScrollOffset, + contentHeight, + row, + ); + buf += + (isSelected + ? chalk.inverse(padRight(rightCell, rightWidth - 1)) + : padRight(rightCell, rightWidth - 1)) + rightScrollbar; } - } - private copySelectedOutput(): void { - const entry = this.tests.get(this.testOrder[this.selectedIndex] ?? ''); - if (!entry) return; + // --- Bottom divider --- + const bottomRow = contentHeight + 2; + buf += MOVE_TO(bottomRow, 1) + CLEAR_LINE; + buf += chalk.dim( + '\u2514' + + '\u2500'.repeat(leftWidth) + + '\u2534' + + '\u2500'.repeat(rightWidth) + + '\u2518', + ); - let text = `Test: ${entry.title}\nStatus: ${entry.status}`; - if (entry.duration !== null) { - text += ` (${formatDuration(entry.duration)})`; - } - if (entry.retry > 0) { - text += ` retry #${entry.retry}`; - } - text += '\n\n'; + // --- Status bar --- + const statusRow = bottomRow + 1; + buf += MOVE_TO(statusRow, 1) + CLEAR_LINE; - if (entry.output.length > 0) { - text += entry.output.map(stripAnsi).join('\n') + '\n'; - } + const listHint = + this.activePaneFocus === 'list' + ? chalk.bold('\u2191\u2193 navigate') + : chalk.dim('\u2191\u2193 scroll'); + const tabHint = chalk.dim('Tab') + ' switch'; + const qHint = chalk.dim('q') + ' quit'; + const cHint = + chalk.dim('c') + ' copy output ' + chalk.dim('C') + ' copy list'; + const isFollowing = + this.autoFollow && Date.now() - this.lastUserInteractionTime > 30_000; + const fHint = isFollowing + ? chalk.green('f') + chalk.green(' follow') + : chalk.dim('f') + ' follow'; + const elapsedMs = + this.frozenElapsedMs ?? + (this.startTime !== null ? Date.now() - this.startTime : null); + const elapsedStr = + elapsedMs !== null + ? chalk.dim(` ${formatDuration(elapsedMs)} elapsed`) + : ''; + const progressStr = chalk.dim( + `${this.progress.completed}/${this.progress.total} done`, + ); + const estStr = + this.progress.estimatedMinsLeft > 0 + ? chalk.dim(`, ~${this.progress.estimatedMinsLeft}min left`) + : ''; + const flash = this.flashMessage ? chalk.green(` ${this.flashMessage}`) : ''; - for (const err of entry.errors) { - text += '\n--- Error ---\n'; - if (err.message) text += err.message + '\n'; - if (err.snippet) text += err.snippet + '\n'; - if (err.stack) text += err.stack + '\n'; - } + buf += ` ${listHint} ${tabHint} ${qHint} ${cHint} ${fHint} ${chalk.dim( + '|', + )} ${progressStr}${elapsedStr}${estStr}${flash}`; - this.copyToClipboard(text); + process.stdout.write(buf); } - private copyLeftPane(): void { - if (this.testOrder.length === 0) { - this.showFlash('No tests'); - return; + private restoreTerminal(): void { + try { + if (process.stdin.isTTY) { + process.stdin.setRawMode(this.originalStdinRawMode ?? false); + } + process.stdin.pause(); + process.stdin.unref(); + process.stdout.write(MOUSE_OFF + ALT_SCREEN_OFF + CURSOR_SHOW); + } catch { + // Terminal may already be gone } + } - const lines: string[] = [ - `Tests: ${this.progress.completed}/${this.progress.total}`, - '', - ]; - - for (const id of this.testOrder) { - const entry = this.tests.get(id)!; - const status = entry.status.toUpperCase().padEnd(10); - const retry = entry.retry > 0 ? ` (retry #${entry.retry})` : ''; - const dur = - entry.duration !== null ? ` [${formatDuration(entry.duration)}]` : ''; - lines.push(`${status} ${entry.title}${retry}${dur}`); + private scrollbarChar( + total: number, + visible: number, + offset: number, + trackHeight: number, + row: number, + ): string { + if (total <= visible) { + return chalk.dim('\u2502'); // │ — no scrolling needed } - - this.copyToClipboard(lines.join('\n')); + const thumbSize = Math.max(1, Math.round((visible / total) * trackHeight)); + const maxOffset = total - visible; + const clampedOffset = Math.min(offset, maxOffset); + const thumbStart = Math.round( + (clampedOffset / maxOffset) * (trackHeight - thumbSize), + ); + if (row >= thumbStart && row < thumbStart + thumbSize) { + return chalk.white('\u2588'); // █ thumb + } + return chalk.dim('\u2591'); // ░ track } - private copyToClipboard(text: string): void { - const candidates: Array<{ cmd: string; args: string[] }> = []; - if (process.platform === 'darwin') { - candidates.push({ cmd: 'pbcopy', args: [] }); - } else if (process.platform === 'win32') { - candidates.push({ cmd: 'clip', args: [] }); - } else { - if (process.env.WAYLAND_DISPLAY) { - candidates.push({ cmd: 'wl-copy', args: [] }); - } - candidates.push({ cmd: 'xclip', args: ['-selection', 'clipboard'] }); - candidates.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }); - } - this.tryCopyWithCandidates(text, candidates, 0); + private showFlash(msg: string): void { + this.flashMessage = msg; + this.scheduleRender(); + if (this.flashTimeout) clearTimeout(this.flashTimeout); + this.flashTimeout = setTimeout(() => { + this.flashMessage = null; + this.flashTimeout = null; + this.scheduleRender(); + }, 1500); } private tryCopyWithCandidates( @@ -946,53 +995,4 @@ export class TerminalTui { this.tryCopyWithCandidates(text, candidates, index + 1); } } - - private copyViaOsc52(text: string): void { - try { - const encoded = Buffer.from(text).toString('base64'); - process.stdout.write(`\x1b]52;c;${encoded}\x07`); - this.showFlash('Copied (OSC 52)'); - } catch { - this.showFlash('Copy failed'); - } - } - - private showFlash(msg: string): void { - this.flashMessage = msg; - this.scheduleRender(); - if (this.flashTimeout) clearTimeout(this.flashTimeout); - this.flashTimeout = setTimeout(() => { - this.flashMessage = null; - this.flashTimeout = null; - this.scheduleRender(); - }, 1500); - } - - private removeListeners(): void { - if (this.keyHandler) { - process.stdin.removeListener('data', this.keyHandler); - this.keyHandler = null; - } - if (this.resizeHandler) { - process.stdout.removeListener('resize', this.resizeHandler); - this.resizeHandler = null; - } - if (this.exitHandler) { - process.removeListener('exit', this.exitHandler); - this.exitHandler = null; - } - } - - private restoreTerminal(): void { - try { - if (process.stdin.isTTY) { - process.stdin.setRawMode(this.originalStdinRawMode ?? false); - } - process.stdin.pause(); - process.stdin.unref(); - process.stdout.write(MOUSE_OFF + ALT_SCREEN_OFF + CURSOR_SHOW); - } catch { - // Terminal may already be gone - } - } } diff --git a/tuiReporter.ts b/tuiReporter.ts index 0f7feba..e4580ad 100644 --- a/tuiReporter.ts +++ b/tuiReporter.ts @@ -16,16 +16,12 @@ import { TerminalTui } from './terminalTui'; type TestAndResult = { test: TestCase; result: TestResult }; class TuiReporter implements Reporter { - private tui = new TerminalTui(); private allResults: Array = []; private allTests: TestCase[] = []; private allTestsCount = 0; private countWorkers = 1; private startTime = 0; - - printsToStdio(): boolean { - return true; - } + private tui = new TerminalTui(); onBegin(config: FullConfig, suite: Suite) { this.allTests = suite.allTests(); @@ -48,6 +44,52 @@ class TuiReporter implements Reporter { this.tui.setProgress(0, this.allTestsCount, 0); } + async onEnd(_result: FullResult) { + this.tui.reorderForSummary(); + // Workers are already cleaned up by the time onEnd is called. + // Block here to keep the TUI open for browsing results. + await this.tui.waitForClose(); + this.tui.stop(); + this.printSummary(); + } + + onError(error: TestError) { + // Global errors: show in a pseudo-test entry + const globalId = '__global_errors__'; + const existing = this.allResults.find((r) => r.test.id === globalId); + if (!existing) { + this.tui.addTest(globalId, '[Global Errors]'); + } + const msg = error.message || 'Unknown error'; + this.tui.appendOutput(globalId, `${chalk.red('Error:')} ${msg}\n`); + if (error.stack) { + this.tui.appendOutput(globalId, chalk.dim(error.stack) + '\n'); + } + this.tui.updateTest(globalId, 'failed'); + } + + onStdErr( + chunk: Buffer | string, + test: TestCase | void, + _result: TestResult | void, + ) { + if (test) { + const text = isString(chunk) ? chunk : chunk.toString('utf-8'); + this.tui.appendOutput(test.id, text); + } + } + + onStdOut( + chunk: Buffer | string, + test: TestCase | void, + _result: TestResult | void, + ) { + if (test) { + const text = isString(chunk) ? chunk : chunk.toString('utf-8'); + this.tui.appendOutput(test.id, text); + } + } + onTestBegin(test: TestCase, result: TestResult) { const status = result.retry > 0 ? 'retrying' : 'running'; if (result.retry > 0) { @@ -83,50 +125,8 @@ class TuiReporter implements Reporter { this.tui.setProgress(completedCount, this.allTestsCount, estimatedMinsLeft); } - onStdOut( - chunk: Buffer | string, - test: TestCase | void, - _result: TestResult | void, - ) { - if (test) { - const text = isString(chunk) ? chunk : chunk.toString('utf-8'); - this.tui.appendOutput(test.id, text); - } - } - - onStdErr( - chunk: Buffer | string, - test: TestCase | void, - _result: TestResult | void, - ) { - if (test) { - const text = isString(chunk) ? chunk : chunk.toString('utf-8'); - this.tui.appendOutput(test.id, text); - } - } - - onError(error: TestError) { - // Global errors: show in a pseudo-test entry - const globalId = '__global_errors__'; - const existing = this.allResults.find((r) => r.test.id === globalId); - if (!existing) { - this.tui.addTest(globalId, '[Global Errors]'); - } - const msg = error.message || 'Unknown error'; - this.tui.appendOutput(globalId, `${chalk.red('Error:')} ${msg}\n`); - if (error.stack) { - this.tui.appendOutput(globalId, chalk.dim(error.stack) + '\n'); - } - this.tui.updateTest(globalId, 'failed'); - } - - async onEnd(_result: FullResult) { - this.tui.reorderForSummary(); - // Workers are already cleaned up by the time onEnd is called. - // Block here to keep the TUI open for browsing results. - await this.tui.waitForClose(); - this.tui.stop(); - this.printSummary(); + printsToStdio(): boolean { + return true; } private printSummary() { From de9844316f1754aaed120b60da9ecf7a872228fc Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 6 Mar 2026 14:01:06 +1100 Subject: [PATCH 15/17] chore: address PR review --- .../automation/community_admin_tests.spec.ts | 3 +- tests/automation/delete_account.spec.ts | 6 +- tests/automation/landing_page.spec.ts | 4 +- tests/automation/linked_device_group.spec.ts | 10 +- tests/automation/message_checks.spec.ts | 66 +++++----- tests/automation/password.spec.ts | 3 +- .../automation/recovery_phrase_banner.spec.ts | 3 +- tests/automation/setup/open.ts | 33 +++-- tests/automation/setup/recovery_using_seed.ts | 3 +- tests/automation/setup/sessionTest.ts | 57 +++++---- tests/automation/types/testing.ts | 1 + tests/automation/user_actions.spec.ts | 6 +- tests/automation/utilities/linked_device.ts | 4 +- tests/automation/utilities/message.ts | 15 ++- tests/automation/utilities/utils.ts | 115 ++++++------------ 15 files changed, 153 insertions(+), 176 deletions(-) diff --git a/tests/automation/community_admin_tests.spec.ts b/tests/automation/community_admin_tests.spec.ts index de74676..9626811 100644 --- a/tests/automation/community_admin_tests.spec.ts +++ b/tests/automation/community_admin_tests.spec.ts @@ -88,8 +88,7 @@ actionsToDo.forEach((action) => { await waitForMessageStatus(bob1, secondMsgBanned, 'failed'); await hasElementPoppedUpThatShouldnt( alice1, - Conversation.messageContent.strategy, - Conversation.messageContent.selector, + Conversation.messageContent, secondMsgBanned, ); // Alice unban Bob via the convo right click modal (as all messages from Bob have been removed) diff --git a/tests/automation/delete_account.spec.ts b/tests/automation/delete_account.spec.ts index acc8f12..0e8a661 100644 --- a/tests/automation/delete_account.spec.ts +++ b/tests/automation/delete_account.spec.ts @@ -5,7 +5,7 @@ import { sleepFor } from '../promise_utils'; import { Global, HomeScreen, LeftPane, Onboarding, Settings } from './locators'; import { forceCloseAllWindows } from './setup/closeWindows'; import { newUser } from './setup/new_user'; -import { openApp } from './setup/open'; +import { openAppsAndWaitWindows } from './setup/open'; import { recoverFromSeed } from './setup/recovery_using_seed'; import { sessionTestTwoWindows } from './setup/sessionTest'; import { createContact } from './utilities/create_contact'; @@ -62,7 +62,7 @@ sessionTestTwoWindows( // Wait for window to close and reopen // await windowA.close(); - restoringWindows = await openApp(1); // not using sessionTest here as we need to close and reopen one of the window + restoringWindows = await openAppsAndWaitWindows(1); // not using sessionTest here as we need to close and reopen one of the window const [restoringWindow] = restoringWindows; // Sign in with deleted account and check that nothing restores await clickOn(restoringWindow, Onboarding.iHaveAnAccountButton); @@ -142,7 +142,7 @@ sessionTestTwoWindows( // Confirm deletion by clicking Clear, twice await clickOnMatchingText(windowA, tStripped('clear')); await clickOnMatchingText(windowA, tStripped('clear')); - restoringWindows = await openApp(1); + restoringWindows = await openAppsAndWaitWindows(1); const [restoringWindow] = restoringWindows; // Sign in with deleted account and check that nothing restores await recoverFromSeed(restoringWindow, userA.recoveryPassword); diff --git a/tests/automation/landing_page.spec.ts b/tests/automation/landing_page.spec.ts index c848232..7defbe8 100644 --- a/tests/automation/landing_page.spec.ts +++ b/tests/automation/landing_page.spec.ts @@ -69,9 +69,7 @@ test_Alice_2W( ].map(async (builder) => hasElementPoppedUpThatShouldnt( aliceWindow2, - 'data-testid', - 'empty-msg-view-account-created', - + Conversation.EmptyMessageViewCreated, builder.toString(), ), ), diff --git a/tests/automation/linked_device_group.spec.ts b/tests/automation/linked_device_group.spec.ts index 2b22b61..dfe6772 100644 --- a/tests/automation/linked_device_group.spec.ts +++ b/tests/automation/linked_device_group.spec.ts @@ -9,7 +9,7 @@ import { LeftPane, Settings, } from './locators'; -import { openApp } from './setup/open'; +import { openAppsAndWaitWindows } from './setup/open'; import { recoverFromSeed } from './setup/recovery_using_seed'; import { test_group_Alice_1W_Bob_1W_Charlie_1W, @@ -79,7 +79,7 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( test_group_Alice_1W_Bob_1W_Charlie_1W( 'Restore group', async ({ alice, bob, charlie, groupCreated }) => { - const [aliceWindow2] = await openApp(1); + const [aliceWindow2] = await openAppsAndWaitWindows(1); // Check group conversation is in conversation list on linked device // Restore account on a linked device await recoverFromSeed(aliceWindow2, alice.recoveryPassword); @@ -157,7 +157,7 @@ async function clearDataOnWindow(window: Page) { test_group_Alice_1W_Bob_1W_Charlie_1W( 'Delete and restore group', async ({ alice, bob, charlie, groupCreated }) => { - const [aliceWindow2] = await openApp(1); + const [aliceWindow2] = await openAppsAndWaitWindows(1); // Check group conversation is in conversation list on linked device // Restore account on a linked device await recoverFromSeed(aliceWindow2, alice.recoveryPassword); @@ -200,7 +200,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await clickOn(aliceWindow2, Global.modalCloseButton); // Delete device data on aliceWindow2 await clearDataOnWindow(aliceWindow2); - const [restoredWindow] = await openApp(1); + const [restoredWindow] = await openAppsAndWaitWindows(1); await recoverFromSeed(restoredWindow, alice.recoveryPassword); // Does group appear? await waitForTestIdWithText( @@ -242,7 +242,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await clickOn(restoredWindow, Global.modalCloseButton); // Delete device data on restoredWindow await clearDataOnWindow(restoredWindow); - const [restoredWindow2] = await openApp(1); + const [restoredWindow2] = await openAppsAndWaitWindows(1); await recoverFromSeed(restoredWindow2, alice.recoveryPassword); // Does group appear? await waitForTestIdWithText( diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index 14529d2..362d98a 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -184,7 +184,11 @@ test_Alice_1W_Bob_1W( }, ); -const delete1o1TypeArray = ['device_only', 'for_everyone'] as const; +const delete1o1TypeArray = [ + 'device_only_outgoing', + 'device_only_incoming', + 'for_everyone', +] as const; delete1o1TypeArray.forEach((deleteType) => { test_Alice_2W_Bob_1W( @@ -200,39 +204,34 @@ delete1o1TypeArray.forEach((deleteType) => { waitForTextMessage(aliceWindow2, messageToDelete, 15_000), waitForTextMessage(bobWindow1, messageToDelete, 15_000), ]); - await openConversationWith(aliceWindow2, bob.userName); - await deleteMessageFor(aliceWindow1, messageToDelete, deleteType); + // Alice sent the message, device_only_incoming means getting Bob to delete Alice's message locally. + // Otherwise, it's an action that Alice does on her own message. - await confirmMessageDeletedFor({ - deleteType, - messageToDelete, - otherWindows: [aliceWindow2, bobWindow1], - windowInitiatingDelete: aliceWindow1, - }); + const windowInitiatingDelete = + deleteType === 'device_only_incoming' ? bobWindow1 : aliceWindow1; + const otherWindows = [aliceWindow1, aliceWindow2, bobWindow1].filter( + (w) => w !== windowInitiatingDelete, + ); - if (deleteType === 'device_only') { - // when testing the device_only deletion, we also want to check that - // an incoming message can be deleted locally. - const messageToDelete2 = `Testing deletion functionality for ${deleteType} #2`; - - await sendMessage(aliceWindow1, messageToDelete2); - await waitForTextMessage( - [aliceWindow2, bobWindow1], - messageToDelete2, - 15_000, - ); + const simplifiedDeleteType = + deleteType === 'device_only_incoming' || + deleteType === 'device_only_outgoing' + ? 'device_only' + : 'for_everyone'; - // bob now deletes Alice's message locally - await deleteMessageFor(bobWindow1, messageToDelete2, deleteType); + await deleteMessageFor( + windowInitiatingDelete, + messageToDelete, + simplifiedDeleteType, + ); - await confirmMessageDeletedFor({ - deleteType, - messageToDelete: messageToDelete2, - otherWindows: [aliceWindow1, aliceWindow2], - windowInitiatingDelete: bobWindow1, - }); - } + await confirmMessageDeletedFor({ + deleteType: simplifiedDeleteType, + messageToDelete, + otherWindows, + windowInitiatingDelete, + }); }, ); }); @@ -434,8 +433,7 @@ test_Alice_1W( ); await hasElementPoppedUpThatShouldnt( aliceWindow1, - Conversation.mentionsContainer.strategy, - Conversation.mentionsContainer.selector, + Conversation.mentionsContainer, ); await pasteIntoInput( aliceWindow1, @@ -444,8 +442,7 @@ test_Alice_1W( ); await hasElementPoppedUpThatShouldnt( aliceWindow1, - Conversation.mentionsContainer.strategy, - Conversation.mentionsContainer.selector, + Conversation.mentionsContainer, ); }, ); @@ -478,8 +475,7 @@ test_Alice_1W( await clickOn(aliceWindow1, Conversation.messageInput); await hasElementPoppedUpThatShouldnt( aliceWindow1, - Conversation.mentionsContainer.strategy, - Conversation.mentionsContainer.selector, + Conversation.mentionsContainer, ); }, ); diff --git a/tests/automation/password.spec.ts b/tests/automation/password.spec.ts index 9849d71..47636bd 100644 --- a/tests/automation/password.spec.ts +++ b/tests/automation/password.spec.ts @@ -159,8 +159,7 @@ test_Alice_1W_no_network( await hasElementPoppedUpThatShouldnt( aliceWindow1, - 'data-testid', - Settings.recoveryPasswordContainer.selector, + Settings.recoveryPasswordContainer, recoveryPassword, ); diff --git a/tests/automation/recovery_phrase_banner.spec.ts b/tests/automation/recovery_phrase_banner.spec.ts index 4f9e267..62437eb 100644 --- a/tests/automation/recovery_phrase_banner.spec.ts +++ b/tests/automation/recovery_phrase_banner.spec.ts @@ -18,8 +18,7 @@ async function bannerShouldNotAppear(window: Page) { await waitForTestIdWithText(window, HomeScreen.plusButton.selector); await hasElementPoppedUpThatShouldnt( window, - HomeScreen.revealRecoveryPhraseButton.strategy, - HomeScreen.revealRecoveryPhraseButton.selector, + HomeScreen.revealRecoveryPhraseButton, ); console.log('On home screen, banner did not appear'); } diff --git a/tests/automation/setup/open.ts b/tests/automation/setup/open.ts index 4df9544..b16c2bd 100644 --- a/tests/automation/setup/open.ts +++ b/tests/automation/setup/open.ts @@ -1,4 +1,7 @@ -import { _electron as electron } from '@playwright/test'; +import { + _electron as electron, + type ElectronApplication, +} from '@playwright/test'; import chalk from 'chalk'; import { isEmpty } from 'lodash'; import { join } from 'path'; @@ -129,8 +132,7 @@ const openElectronAppOnly = async (multi: string, context?: TestContext) => { const logBrowserConsole = process.env.LOG_BROWSER_CONSOLE === '1'; -const openAppAndWait = async (multi: string, context?: TestContext) => { - const electronApp = await openElectronAppOnly(multi, context); +export async function waitFirstWindow(electronApp: ElectronApplication) { // Get the first window that the app opens, wait if necessary. const start = Date.now(); const window = await electronApp.firstWindow(); @@ -146,9 +148,9 @@ const openAppAndWait = async (multi: string, context?: TestContext) => { } }); return window; -}; +} -export async function openApp(windowsToCreate: number, context?: TestContext) { +export async function openApps(windowsToCreate: number, context?: TestContext) { if (windowsToCreate >= multisAvailable.length) { throw new Error(`Do you really need ${multisAvailable.length} windows?!`); } @@ -156,18 +158,29 @@ export async function openApp(windowsToCreate: number, context?: TestContext) { const multisToUse = multisAvailable.slice(0, windowsToCreate); const array = [...multisToUse]; - const toRet = []; + const apps = []; // not too sure why, but launching those windows with Promise.all triggers a sqlite error... for (let index = 0; index < array.length; index++) { - const element = array[index]; + const multi = array[index]; + + const electronApp = await openElectronAppOnly(multi, context); - const openedWindow = await openAppAndWait(`${element}`, context); - toRet.push(openedWindow); + apps.push(electronApp); } console.log( chalk.bgRedBright(`Pathway to app: `, process.env.SESSION_DESKTOP_ROOT), ); - return toRet; + return apps; +} + +export async function openAppsAndWaitWindows( + windowsToCreate: number, + context?: TestContext, +) { + const apps = await openApps(windowsToCreate, context); + + const windows = await Promise.all(apps.map((app) => waitFirstWindow(app))); + return windows; } export function getTrackedElectronPids(): Array { diff --git a/tests/automation/setup/recovery_using_seed.ts b/tests/automation/setup/recovery_using_seed.ts index 3187e24..7dfa79e 100644 --- a/tests/automation/setup/recovery_using_seed.ts +++ b/tests/automation/setup/recovery_using_seed.ts @@ -19,8 +19,7 @@ export async function recoverFromSeed( await waitForLoadingAnimationToFinish(window, 'loading-animation'); const displayNameInput = await doesElementExist( window, - 'data-testid', - 'display-name-input', + Onboarding.displayNameInput, ); if (displayNameInput) { if (!options?.fallbackName) { diff --git a/tests/automation/setup/sessionTest.ts b/tests/automation/setup/sessionTest.ts index bcb92b0..b3129dd 100644 --- a/tests/automation/setup/sessionTest.ts +++ b/tests/automation/setup/sessionTest.ts @@ -8,7 +8,12 @@ import { linkedDevice } from '../utilities/linked_device'; import { forceCloseAllWindows } from './closeWindows'; import { createGroup } from './create_group'; import { newUser } from './new_user'; -import { openApp, resetTrackedElectronPids, TestContext } from './open'; +import { + openApps, + resetTrackedElectronPids, + TestContext, + waitFirstWindow, +} 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. @@ -44,16 +49,17 @@ function sessionTest>( count: T, context?: TestContext, ) { - return test(testName, async ({}, testinfo) => { + return test(testName, async ({}, testInfo) => { resetTrackedElectronPids(); - const windows = await openApp(count, context); + const apps = await openApps(count, context); + const windows = await Promise.all(apps.map((app) => waitFirstWindow(app))); try { if (windows.length !== count) { throw new Error( - `openApp should have opened ${count} windows but did not.`, + `openApps should have opened ${count} windows but did not.`, ); } - await testCallback(windows as N, testinfo); + await testCallback(windows as N, testInfo); } finally { try { await forceCloseAllWindows(windows); @@ -139,8 +145,11 @@ function sessionTestGeneric< ) { const userNames: Tuple = ['Alice', 'Bob', 'Charlie', 'Dracula']; - return test(testName, async ({}, testinfo) => { - const mainWindows = await openApp(userCount, context); + return test(testName, async ({}, testInfo) => { + const mainApps = await openApps(userCount, context); + const mainWindows = await Promise.all( + mainApps.map((app) => waitFirstWindow(app)), + ); const linkedWindows: Array = []; try { @@ -189,7 +198,7 @@ function sessionTestGeneric< ? Group : undefined, }, - testinfo, + testInfo, ); } finally { try { @@ -206,7 +215,7 @@ function sessionTestGeneric< * Used for tests which don't need network (i.e. setting/checking passwords etc) */ export function test_Alice_1W_no_network( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1, testInfo: TestInfo, @@ -214,7 +223,7 @@ export function test_Alice_1W_no_network( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 1, { waitForNetwork: false, context }, ({ mainWindows, users }, testInfo) => { @@ -230,7 +239,7 @@ export function test_Alice_1W_no_network( } export function test_Alice_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1, testInfo: TestInfo, @@ -238,7 +247,7 @@ export function test_Alice_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 1, { waitForNetwork: true, context }, ({ mainWindows, users }, testInfo) => { @@ -258,7 +267,7 @@ export function test_Alice_1W( * - Alice with 2 windows. */ export function test_Alice_2W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & WithAliceWindow2, testInfo: TestInfo, @@ -266,7 +275,7 @@ export function test_Alice_2W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 1, { links: [1], context }, ({ mainWindows, users, linkedWindows }, testInfo) => { @@ -288,7 +297,7 @@ export function test_Alice_2W( * - Bob with 1 window. */ export function test_Alice_1W_Bob_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & WithBob & WithBobWindow1, testInfo: TestInfo, @@ -296,7 +305,7 @@ export function test_Alice_1W_Bob_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 2, { context }, ({ mainWindows, users }, testInfo) => { @@ -319,7 +328,7 @@ export function test_Alice_1W_Bob_1W( * - Bob with 1 window. */ export function test_Alice_2W_Bob_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -331,7 +340,7 @@ export function test_Alice_2W_Bob_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 2, { links: [1], context }, ({ mainWindows, users, linkedWindows }, testInfo) => { @@ -356,7 +365,7 @@ export function test_Alice_2W_Bob_1W( * - Charlie with 1 window. */ export function test_group_Alice_1W_Bob_1W_Charlie_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -370,7 +379,7 @@ export function test_group_Alice_1W_Bob_1W_Charlie_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 3, { grouped: [1, 2, 3], context }, ({ mainWindows, users, groupCreated }, testInfo) => { @@ -397,7 +406,7 @@ export function test_group_Alice_1W_Bob_1W_Charlie_1W( * - Charlie with 1 window. */ export function test_group_Alice_2W_Bob_1W_Charlie_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -412,7 +421,7 @@ export function test_group_Alice_2W_Bob_1W_Charlie_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 3, { grouped: [1, 2, 3], links: [1], context }, ({ mainWindows, users, groupCreated, linkedWindows }, testInfo) => { @@ -441,7 +450,7 @@ export function test_group_Alice_2W_Bob_1W_Charlie_1W( * - Dracula with 1 window, */ export function test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -458,7 +467,7 @@ export function test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 4, { grouped: [1, 2, 3], context }, ({ mainWindows, users, groupCreated }, testInfo) => { diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index e54a61f..d30dbe0 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -167,6 +167,7 @@ export type DataTestId = | 'market-cap-amount' | 'mentions-container-row' | 'mentions-container' + | 'message-container' | 'message-content' | 'message-input-text-area' | 'message-request-banner' diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index a630345..5e3e30a 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -369,11 +369,7 @@ test_Alice_2W( ); // Click yes await clickOnWithText(aliceWindow1, Global.confirmButton, tStripped('yes')); - await doesElementExist( - aliceWindow1, - 'data-testid', - Settings.recoveryPasswordMenuItem.selector, - ); + await doesElementExist(aliceWindow1, Settings.recoveryPasswordMenuItem); // Check linked device if Recovery Password is still visible (it should be) await clickOn(aliceWindow2, LeftPane.settingsButton); await waitForTestIdWithText( diff --git a/tests/automation/utilities/linked_device.ts b/tests/automation/utilities/linked_device.ts index 6472182..fa48141 100644 --- a/tests/automation/utilities/linked_device.ts +++ b/tests/automation/utilities/linked_device.ts @@ -1,9 +1,9 @@ -import { openApp } from '../setup/open'; +import { openAppsAndWaitWindows } from '../setup/open'; import { recoverFromSeed } from '../setup/recovery_using_seed'; import { checkPathLight } from './utils'; export async function linkedDevice(recoveryPhrase: string) { - const [window] = await openApp(1); // not using sessionTest here as we need to close and reopen one of the window + const [window] = await openAppsAndWaitWindows(1); // not using sessionTest here as we need to close and reopen one of the window await recoverFromSeed(window, recoveryPhrase); await checkPathLight(window); diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index be74ba5..dbfe0d0 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -5,6 +5,7 @@ import { sleepFor } from '../../promise_utils'; import { Global } from '../locators'; import { MessageStatus } from '../types/testing'; import { + buildSelectorEscapeText, checkModalStrings, clickOn, clickOnElement, @@ -26,7 +27,14 @@ export const waitForMessageStatus = async ( message: string, status: MessageStatus, ) => { - const selector = `css=[data-testid=message-container]:has-text("${message}"):has([data-testid=msg-status][data-testtype=${status}])`; + const selector = + buildSelectorEscapeText( + { + strategy: 'data-testid', + selector: 'message-container', + } as const, + message, + ) + `:has([data-testid=msg-status][data-testtype=${status}])`; const logSig = `${status} status of message '${message}'`; const messageStatus = await window.waitForSelector(selector, { @@ -78,9 +86,8 @@ export async function deleteMessageFor( } /** - * Wait 15s and then confirms that all of the windows have the message is the expected state, depending on the delete type. - * - * A local deletion + * Wait 15s and then confirms that all of the windows have the message + * in the expected state, depending on the delete type. */ export async function confirmMessageDeletedFor({ deleteType, diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 60f3670..2455d38 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -29,7 +29,7 @@ export function escapeText(text: string) { } /** - * This function can be used to make sure all the possible values as input of a switch as taken care off, without having a default case. + * This function can be used to make sure all the possible values as input of a switch is taken care off, without having a default case. */ export function assertUnreachable(_x: never, message: string): never { const msg = `assertUnreachable: Didn't expect to get here with "${message}"`; @@ -50,23 +50,13 @@ export async function waitForTestIdWithText( text?: string, maxWait?: number, ) { - let builtSelector = `css=[data-testid="${dataTestId}"]`; - if (text) { - /* prettier-ignore */ - - // " => \\\" - - const escapedText = escapeText(text); - - builtSelector += `:has-text("${escapedText}")`; - // console.info('builtSelector:', builtSelector); - // console.info('Text is tiny bubble: ', escapedText); - } - // console.info('looking for selector', builtSelector); + const builtSelector = buildSelectorEscapeText( + { strategy: 'data-testid', selector: dataTestId }, + text, + ); const found = await window.waitForSelector(builtSelector, { timeout: maxWait, }); - // console.info('found selector', builtSelector); return found; } @@ -80,9 +70,7 @@ export async function waitForElement({ locator: StrategyExtractionObj; options?: { maxWaitMs?: number; text?: string; shouldLog?: boolean }; }) { - const builtSelector = !options?.text - ? `css=[${locator.strategy}=${locator.selector}]` - : `css=[${locator.strategy}=${locator.selector}]:has-text("${options.text.replace(/"/g, '\\"')}")`; + const builtSelector = buildSelectorEscapeText(locator, options?.text); const start = Date.now(); if (options?.shouldLog) { @@ -108,9 +96,10 @@ export async function waitForTextMessage( text: string, maxWait?: number, ) { - const escapedText = text.replace(/"/g, '\\"'); - - const builtSelector = `css=[data-testid=message-content]:has-text("${escapedText}")`; + const builtSelector = buildSelectorEscapeText( + { selector: 'message-content', strategy: 'data-testid' }, + text, + ); console.info('waitForTextMessage: builtSelector:', builtSelector); const windows = Array.isArray(window) ? window : [window]; @@ -146,15 +135,12 @@ export async function waitForMatchingText( maxWait: number, ) { const builtSelector = `css=:has-text("${text}")`; - const maxTimeout = maxWait ?? 55000; - console.info(`waitForMatchingText: ${text} for maxWait: ${maxTimeout}ms`); + console.info(`waitForMatchingText: ${text} for maxWait: ${maxWait}ms`); const start = Date.now(); const windows = Array.isArray(window) ? window : [window]; const found = await Promise.all( - windows.map((w) => - w.waitForSelector(builtSelector, { timeout: maxTimeout }), - ), + windows.map((w) => w.waitForSelector(builtSelector, { timeout: maxWait })), ); console.info( @@ -361,22 +347,17 @@ export async function clickOn( ); } -function buildSelectorForClickWithText( +export function buildSelectorEscapeText( locator: StrategyExtractionObj, - text: string, + text?: string, ) { - let builtSelector: string; + const strategyWithSelector = + locator.strategy === 'class' + ? `.${locator.selector}` + : `[${locator.strategy}=${locator.selector}]`; + const textSelector = text ? `:has-text("${text.replace(/"/g, '\\"')}")` : ''; - if (locator.strategy === 'class') { - builtSelector = `css=.${locator.selector}:has-text("${text.replace( - /"/g, - '\\"', - )}")`; - } else { - builtSelector = `css=[${locator.strategy}=${ - locator.selector - }]:has-text("${text.replace(/"/g, '\\"')}")`; - } + const builtSelector = `css=${strategyWithSelector}${textSelector}`; return builtSelector; } @@ -394,7 +375,7 @@ export async function clickOnWithText( text: string, options?: Omit, ) { - const builtSelector = buildSelectorForClickWithText(locator, text); + const builtSelector = buildSelectorEscapeText(locator, text); const sharedOpts = { timeout: options?.maxWait, @@ -409,7 +390,7 @@ export async function rightClickOnWithText( text: string, options?: Omit, ) { - const builtSelector = buildSelectorForClickWithText(locator, text); + const builtSelector = buildSelectorEscapeText(locator, text); const sharedOpts = { timeout: options?.maxWait, @@ -487,7 +468,10 @@ export async function clickOnTextMessage( rightButton?: boolean, maxWait?: number, ) { - const builtSelector = `css=[data-testid=message-content]:has-text("${text}")`; + const builtSelector = buildSelectorEscapeText( + { selector: 'message-content', strategy: 'data-testid' }, + text, + ); const sharedOpts = { timeout: maxWait }; await window.click( @@ -633,15 +617,12 @@ export async function hasTextMessageBeenDeleted( export async function hasElementPoppedUpThatShouldnt( window: Page, - strategy: Strategy, - selector: string, + locator: StrategyExtractionObj, text?: string, ) { - const builtSelector = !text - ? `css=[${strategy}=${selector}]` - : `css=[${strategy}=${selector}]:has-text("${text.replace(/"/g, '\\"')}")`; + const builtSelector = buildSelectorEscapeText(locator, text); - const fakeError = `Found ${selector}, oops..`; + const fakeError = `Found ${locator.selector}, oops..`; const elVisible = await window.isVisible(builtSelector); if (elVisible === true) { throw new Error(fakeError); @@ -651,21 +632,18 @@ export async function hasElementPoppedUpThatShouldnt( export async function doesElementExist( window: Page, - strategy: Strategy, - selector: string, + locator: StrategyExtractionObj, text?: string, ) { - const builtSelector = !text - ? `css=[${strategy}=${selector}]` - : `css=[${strategy}=${selector}]:has-text("${text.replace(/"/g, '\\"')}")`; + const builtSelector = buildSelectorEscapeText(locator, text); - const fakeError = `Element ${selector} does not exist`; + const fakeError = `Element ${locator.selector} does not exist`; const elVisible = await window.isVisible(builtSelector); if (!elVisible) { console.log(fakeError); return undefined; } - console.log(`Element ${selector} exists`); + console.log(`Element ${locator.selector} exists`); return builtSelector; } @@ -749,26 +727,10 @@ export async function checkModalStrings( export async function verifyNoCTAShows(window: Page) { await sleepFor(1_000); // Let the UI settle await Promise.all([ - hasElementPoppedUpThatShouldnt( - window, - CTA.heading.strategy, - CTA.heading.selector, - ), - hasElementPoppedUpThatShouldnt( - window, - CTA.description.strategy, - CTA.description.selector, - ), - hasElementPoppedUpThatShouldnt( - window, - CTA.confirmButton.strategy, - CTA.confirmButton.selector, - ), - hasElementPoppedUpThatShouldnt( - window, - CTA.cancelButton.strategy, - CTA.cancelButton.selector, - ), + hasElementPoppedUpThatShouldnt(window, CTA.heading), + hasElementPoppedUpThatShouldnt(window, CTA.description), + hasElementPoppedUpThatShouldnt(window, CTA.confirmButton), + hasElementPoppedUpThatShouldnt(window, CTA.cancelButton), ]); } @@ -870,8 +832,7 @@ export async function assertUrlIsReachable(url: string): Promise { export async function scrollToBottomIfNecessary(window: Page): Promise { const canScroll = await doesElementExist( window, - Conversation.scrollToBottomButton.strategy, - Conversation.scrollToBottomButton.selector, + Conversation.scrollToBottomButton, ); if (canScroll) { await clickOn(window, Conversation.scrollToBottomButton); From 34a0c5c36aa4509ab5763bd2875198f708f91521 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 6 Mar 2026 14:19:39 +1100 Subject: [PATCH 16/17] chore: add TODO comment --- tests/automation/utilities/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 2455d38..bbc6ed4 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -40,7 +40,7 @@ export function assertUnreachable(_x: never, message: string): never { } // TODO Unify element interaction functions to use locator objects the way clickOn and clickOnWithText do -// Remaining functions to migrate: waitForElement, pasteIntoInput, grabTextFromElement etc. +// Remaining functions to migrate: pasteIntoInput, grabTextFromElement etc. // WAIT FOR FUNCTIONS @@ -318,6 +318,8 @@ export async function reloadWindow( // ACTIONS +// TODO: convert the clickOn* methods to take destructured args +// like waitForElement does /** * Clicks on an element using a locator object * @param window - Playwright page instance From e8d214e3c98d63c2ee359ff0e2fc9709ce467485 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 6 Mar 2026 14:20:50 +1100 Subject: [PATCH 17/17] chore: lint --- tests/automation/utilities/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index bbc6ed4..c76bc05 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -318,7 +318,7 @@ export async function reloadWindow( // ACTIONS -// TODO: convert the clickOn* methods to take destructured args +// TODO: convert the clickOn* methods to take destructured args // like waitForElement does /** * Clicks on an element using a locator object