From f5c218e6b7c9103be9a4a4fc7f36e1a34be329ac Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 28 Apr 2026 12:51:33 -0500 Subject: [PATCH 1/2] Add reaction e2e tests --- .../helpers/friendStatusHelpers.js | 75 ++++++ .../tests/friendStatus.e2e.test.js | 45 +--- playwright-tests/tests/reactions.e2e.test.js | 228 ++++++++++++++++++ playwright-tests/tests/toll.e2e.test.js | 23 +- playwright-tests/tests/videoCall.e2e.test.js | 21 +- 5 files changed, 306 insertions(+), 86 deletions(-) create mode 100644 playwright-tests/helpers/friendStatusHelpers.js create mode 100644 playwright-tests/tests/reactions.e2e.test.js diff --git a/playwright-tests/helpers/friendStatusHelpers.js b/playwright-tests/helpers/friendStatusHelpers.js new file mode 100644 index 0000000..f99759f --- /dev/null +++ b/playwright-tests/helpers/friendStatusHelpers.js @@ -0,0 +1,75 @@ +const { expect } = require('@playwright/test'); + +const FriendStatus = Object.freeze({ + BLOCKED: 0, + OTHER: 1, + CONNECTION: 2, +}); + +async function waitForFriendStatusTransaction(page) { + await page.waitForEvent('console', { + timeout: 60_000, + predicate: (msg) => /update_toll_required transaction successfully processed/i.test(msg.text()), + }); +} + +async function setFriendStatus(page, username, status) { + // Use the contacts screen path when the caller only knows the username and + // does not already have that chat open. + await page.locator('#switchToContacts').click(); + await expect(page.locator('#contactsScreen.active')).toBeVisible(); + await page.locator('#contactsList .chat-name', { hasText: username }).click(); + await expect(page.locator('#contactInfoModal.active')).toBeVisible(); + await page.locator('#addFriendButtonContactInfo').click(); + await expect(page.locator('#friendModal.active')).toBeVisible(); + await page.locator(`#friendForm input[type=radio][value="${status}"]`).check(); + + await Promise.all([ + waitForFriendStatusTransaction(page), + page.locator('#friendForm button[type="submit"]').click(), + ]); + + await page.locator('#closeContactInfoModal').click(); + await expect(page.locator('#contactInfoModal')).not.toHaveClass(/active/); +} + +async function setFriendStatusInChat(page, status) { + // Use the chat header button when the caller already has the relevant chat + // modal open and wants to keep working in that conversation. + await page.locator('#addFriendButtonChat').click(); + await expect(page.locator('#friendModal.active')).toBeVisible(); + await page.locator(`#friendForm input[type=radio][value="${status}"]`).check(); + + await Promise.all([ + waitForFriendStatusTransaction(page), + page.locator('#friendForm button[type="submit"]').click(), + ]); + + await expect(page.locator('#friendModal')).not.toHaveClass(/active/); +} + +async function getCurrentFriendStatus(page, username) { + // Open the friend modal just long enough to read the selected radio value, + // then return the page to its previous modal-free state. + await page.locator('#switchToContacts').click(); + await expect(page.locator('#contactsScreen.active')).toBeVisible(); + await page.locator('#contactsList .chat-name', { hasText: username }).click(); + await expect(page.locator('#contactInfoModal.active')).toBeVisible(); + await page.locator('#addFriendButtonContactInfo').click(); + await expect(page.locator('#friendModal.active')).toBeVisible(); + + const checked = await page.locator('#friendForm input[type=radio]:checked').getAttribute('value'); + + await page.locator('#closeFriendModal').click(); + await page.locator('#closeContactInfoModal').click(); + await expect(page.locator('#contactInfoModal')).not.toHaveClass(/active/); + + return Number(checked); +} + +module.exports = { + FriendStatus, + getCurrentFriendStatus, + setFriendStatus, + setFriendStatusInChat, +}; diff --git a/playwright-tests/tests/friendStatus.e2e.test.js b/playwright-tests/tests/friendStatus.e2e.test.js index 2681d50..54b2c34 100644 --- a/playwright-tests/tests/friendStatus.e2e.test.js +++ b/playwright-tests/tests/friendStatus.e2e.test.js @@ -1,6 +1,7 @@ const { test: base, expect } = require('../fixtures/base'); const { createAndSignInUser, generateUsername } = require('../helpers/userHelpers'); const { getLiberdusBalance } = require('../helpers/walletHelpers'); +const { FriendStatus, getCurrentFriendStatus, setFriendStatus } = require('../helpers/friendStatusHelpers'); const { sendMessageTo } = require('../helpers/messageHelpers'); const networkParams = require('../helpers/networkParams'); const { newContext } = require('../helpers/toastHelpers'); @@ -15,30 +16,6 @@ const TOLL = (tollStr.slice(0, -18) || '0') + '.' + tollStr.slice(-18); const TOLL_NUM = Number(tollWei) / 1e18; const DEFAULT_TOLL = networkParams.defaultTollLib; -// enum for friend status -const FriendStatus = { - BLOCKED: 0, - OTHER: 1, - CONNECTION: 2 -}; - -async function setFriendStatus(page, username, status) { - await page.click('#switchToContacts'); - await expect(page.locator('#contactsScreen.active')).toBeVisible(); - await page.locator('#contactsList .chat-name', { hasText: username }).click(); - await expect(page.locator('#contactInfoModal.active')).toBeVisible(); - await page.click('#addFriendButtonContactInfo'); - await expect(page.locator('#friendModal.active')).toBeVisible(); - await page.check(`#friendForm input[type=radio][value="${status.toString()}"]`); - await page.click('#friendForm button[type="submit"]'); - await page.waitForEvent('console', { - timeout: 60_000, - predicate: msg => - /update_toll_required transaction successfully processed/i.test(msg.text()) - }); - await page.click('#closeContactInfoModal'); -} - async function setToll(page, amount) { await page.click('#toggleSettings'); await expect(page.locator('#settingsModal')).toBeVisible(); @@ -56,26 +33,6 @@ async function setToll(page, amount) { await page.click('#closeSettings'); } -/** - * Opens the friend modal for the given username and returns the current friend status value. - * Closes the modals after retrieving the value. - * @param {import('@playwright/test').Page} page - * @param {string} username - * @returns {Promise} The current friend status value - */ -async function getCurrentFriendStatus(page, username) { - await page.click('#switchToContacts'); - await expect(page.locator('#contactsScreen.active')).toBeVisible(); - await page.locator('#contactsList .chat-name', { hasText: username }).click(); - await expect(page.locator('#contactInfoModal.active')).toBeVisible(); - await page.click('#addFriendButtonContactInfo'); - await expect(page.locator('#friendModal.active')).toBeVisible(); - const checked = await page.locator('#friendForm input[type=radio]:checked').getAttribute('value'); - await page.click('#closeFriendModal'); - await page.click('#closeContactInfoModal'); - return Number(checked); -} - const test = base.extend({ users: async ({ browser, browserName }, use, testInfo) => { const ctxA = await newContext(browser); diff --git a/playwright-tests/tests/reactions.e2e.test.js b/playwright-tests/tests/reactions.e2e.test.js new file mode 100644 index 0000000..c40d329 --- /dev/null +++ b/playwright-tests/tests/reactions.e2e.test.js @@ -0,0 +1,228 @@ +const { test: base, expect } = require('../fixtures/base'); +const { FriendStatus, setFriendStatus } = require('../helpers/friendStatusHelpers'); +const { sendMessageTo } = require('../helpers/messageHelpers'); +const { createAndSignInUser, generateUsername } = require('../helpers/userHelpers'); +const { newContext } = require('../helpers/toastHelpers'); + +const Reaction = { + THUMBS_UP: '👍', + HEART: '❤️', + CUSTOM: '🤩', +}; + +const test = base.extend({ + users: async ({ browserName, browser }, use, testInfo) => { + // Each test needs two independent signed-in users so we can verify both + // the local reaction UI and the cross-device sync seen by the sender. + const userA = generateUsername(browserName); + const userB = generateUsername(browserName); + const ctxA = await newContext(browser); + const ctxB = await newContext(browser); + const pageA = await ctxA.newPage(); + const pageB = await ctxB.newPage(); + + await testInfo.attach('reaction-test-users.json', { + body: JSON.stringify({ userA, userB }, null, 2), + contentType: 'application/json', + }); + + try { + await Promise.all([ + createAndSignInUser(pageA, userA), + createAndSignInUser(pageB, userB), + ]); + + await use({ + a: { username: userA, page: pageA, context: ctxA }, + b: { username: userB, page: pageB, context: ctxB }, + }); + } finally { + await ctxA.close(); + await ctxB.close(); + } + }, +}); + +async function openChat(page, contactUsername) { + // Reopen an existing chat from the chat list. This is used after background + // sync work so assertions run against the same UI path a returning user sees. + await page.locator('#switchToChats').click(); + await expect(page.locator('#chatsScreen.active')).toBeVisible(); + + const chatItem = page.locator('#chatList .chat-name', { hasText: contactUsername }); + await expect(chatItem).toBeVisible({ timeout: 30_000 }); + await chatItem.click(); + await expect(page.locator('#chatModal')).toBeVisible(); +} + +function messageBubble(page, direction, message) { + // Scope message lookups to the open chat modal and the visible sent/received + // side so repeated message text elsewhere on the page does not match. + return page + .locator(`#chatModal .messages-list .message.${direction}`) + .filter({ hasText: message }) + .last(); +} + +async function openMessageContextMenu(page, direction, message) { + // Reactions are exposed from the message context menu; opening it through the + // bubble keeps the test on the same user-visible path as manual usage. + const bubble = messageBubble(page, direction, message); + await expect(bubble).toBeVisible({ timeout: 30_000 }); + await bubble.click(); + + const contextMenu = page.locator('#messageContextMenu'); + await expect(contextMenu).toBeVisible({ timeout: 10_000 }); + await expect(contextMenu.getByLabel('Quick reactions')).toBeVisible(); + return contextMenu; +} + +async function chooseQuickReaction(page, direction, message, accessibleName) { + // Quick reactions should be reachable by accessible button names rather than + // layout-specific selectors. + const contextMenu = await openMessageContextMenu(page, direction, message); + await contextMenu.getByRole('button', { name: accessibleName }).click(); +} + +async function expectReactionChip(page, direction, message, emoji, options = {}) { + const bubble = messageBubble(page, direction, message); + await expect(bubble.locator('.message-reaction-chip', { hasText: emoji })).toBeVisible({ + timeout: options.timeout || 15_000, + }); +} + +async function expectNoReactionChip(page, direction, message, emoji, options = {}) { + const bubble = messageBubble(page, direction, message); + await expect(bubble.locator('.message-reaction-chip', { hasText: emoji })).toHaveCount(0, { + timeout: options.timeout || 15_000, + }); +} + +async function expectNoReactionChips(page, direction, message, options = {}) { + const bubble = messageBubble(page, direction, message); + await expect(bubble.locator('.message-reaction-chip')).toHaveCount(0, { + timeout: options.timeout || 15_000, + }); +} + +async function prepareReactableMessage({ a, b, message }) { + // Common happy-path setup: A sends a message, A allows B to react toll-free, + // and B opens the received copy before choosing a reaction. + await sendMessageTo(a.page, b.username, message); + await setFriendStatus(a.page, b.username, FriendStatus.CONNECTION); + + await openChat(b.page, a.username); + await expect(pageTollLabel(b.page)).toHaveText('Toll free:', { timeout: 15_000 }); + await expect(messageBubble(b.page, 'received', message)).toBeVisible({ timeout: 30_000 }); +} + +function pageTollLabel(page) { + return page.locator('#tollLabel'); +} + +test.describe('Message reactions', () => { + test('user can add, change, and remove a quick reaction', async ({ users }) => { + const { a, b } = users; + const message = `reaction quick path ${Date.now()}`; + + // Arrange: prepare a received message where the recipient is allowed to + // react without paying a toll. + await prepareReactableMessage({ a, b, message }); + + // Act and assert: add a thumbs-up reaction from the quick row and verify it + // appears immediately for the reacting user. + await chooseQuickReaction(b.page, 'received', message, 'React with thumbs up'); + await expectReactionChip(b.page, 'received', message, Reaction.THUMBS_UP); + + // Assert: the sender should eventually see the same reaction on their sent + // copy of the message after sync. + await openChat(a.page, b.username); + await expectReactionChip(a.page, 'sent', message, Reaction.THUMBS_UP, { timeout: 60_000 }); + + // Act and assert: choosing a different quick reaction replaces the old one + // locally and on the sender view. + await chooseQuickReaction(b.page, 'received', message, 'React with heart'); + await expectReactionChip(b.page, 'received', message, Reaction.HEART); + await expectNoReactionChip(b.page, 'received', message, Reaction.THUMBS_UP); + await expectReactionChip(a.page, 'sent', message, Reaction.HEART, { timeout: 60_000 }); + await expectNoReactionChip(a.page, 'sent', message, Reaction.THUMBS_UP); + + // Act and assert: selecting the already-active quick reaction toggles it off + // for the reacting user. + const contextMenu = await openMessageContextMenu(b.page, 'received', message); + const activeHeart = contextMenu.getByRole('button', { name: 'React with heart' }); + await expect(activeHeart).toHaveAttribute('aria-pressed', 'true'); + await activeHeart.click(); + + await expectNoReactionChips(b.page, 'received', message); + }); + + test('expanded emoji picker sets a custom reaction and surfaces it as active', async ({ users }) => { + const { a, b } = users; + const message = `reaction picker path ${Date.now()}`; + + // Arrange: start from the same toll-free received-message state as the quick + // reaction path. + await prepareReactableMessage({ a, b, message }); + + // Act: open the expanded picker from the message context menu and choose a + // non-default emoji. + let contextMenu = await openMessageContextMenu(b.page, 'received', message); + await contextMenu.getByRole('button', { name: 'Add reaction' }).click(); + + const reactionDialog = b.page.getByRole('dialog', { name: 'Choose a reaction' }); + await expect(reactionDialog).toBeVisible(); + await reactionDialog.locator('.chat-reaction-sheet-button', { hasText: Reaction.CUSTOM }).first().click(); + + // Assert: the chosen custom emoji is rendered as the reaction chip. + await expectReactionChip(b.page, 'received', message, Reaction.CUSTOM); + + // Assert: reopening the context menu shows the custom emoji as the active + // selection, which is how a user can tell it can be toggled or changed. + contextMenu = await openMessageContextMenu(b.page, 'received', message); + const customReactionButton = contextMenu.getByRole('button', { name: `React with ${Reaction.CUSTOM}` }); + await expect(customReactionButton).toBeVisible(); + await expect(customReactionButton).toHaveAttribute('aria-pressed', 'true'); + + // Assert: the sender receives the custom reaction through sync. + await openChat(a.page, b.username); + await expectReactionChip(a.page, 'sent', message, Reaction.CUSTOM, { timeout: 60_000 }); + }); + + test('reaction sends are blocked until the message author allows free reactions', async ({ users }) => { + const { a, b } = users; + const message = `reaction restriction path ${Date.now()}`; + + // Arrange: A sends B a message but B has not added A as a connection, so A + // should not be able to send a reaction back to B. + await sendMessageTo(a.page, b.username, message); + await openChat(a.page, b.username); + await expect(messageBubble(a.page, 'sent', message)).toBeVisible({ timeout: 30_000 }); + + // Act and assert: the toll gate blocks the reaction and leaves the message + // without any optimistic reaction chip. + await chooseQuickReaction(a.page, 'sent', message, 'React with thumbs up'); + await expect(a.page.locator('.toast.info.show', { + hasText: 'You can only send reactions to people who have added you as a connection', + })).toBeVisible({ timeout: 10_000 }); + await expectNoReactionChips(a.page, 'sent', message); + + // Arrange: B opens the inbound chat so the contact exists locally, then + // blocks A to exercise the stronger blocked-user gate. + await a.page.locator('#closeChatModal').click(); + await openChat(b.page, a.username); + await expect(messageBubble(b.page, 'received', message)).toBeVisible({ timeout: 30_000 }); + await b.page.locator('#closeChatModal').click(); + await setFriendStatus(b.page, a.username, FriendStatus.BLOCKED); + + // Act and assert: the blocked state is visible in the chat and prevents the + // reaction from being sent or rendered. + await openChat(a.page, b.username); + await expect(a.page.locator('#tollValue')).toHaveText('blocked', { timeout: 15_000 }); + await chooseQuickReaction(a.page, 'sent', message, 'React with heart'); + await expect(a.page.locator('.toast.error.show', { hasText: 'You are blocked by this user' })).toBeVisible({ + timeout: 10_000, + }); + await expectNoReactionChips(a.page, 'sent', message); + }); +}); diff --git a/playwright-tests/tests/toll.e2e.test.js b/playwright-tests/tests/toll.e2e.test.js index 0393518..caf0d8d 100644 --- a/playwright-tests/tests/toll.e2e.test.js +++ b/playwright-tests/tests/toll.e2e.test.js @@ -1,6 +1,7 @@ const { test: base, expect } = require('../fixtures/base'); const { createAndSignInUser, generateUsername } = require('../helpers/userHelpers'); const { getLiberdusBalance, expectLiberdusBalanceToEqual } = require('../helpers/walletHelpers'); +const { FriendStatus, setFriendStatus } = require('../helpers/friendStatusHelpers'); const networkParams = require('../helpers/networkParams'); const { fundUserFromPage } = require('../helpers/send-create'); const { sendMessageTo } = require('../helpers/messageHelpers'); @@ -8,11 +9,6 @@ const { newContext } = require('../helpers/toastHelpers'); // Constants const TOLL_USD = networkParams.defaultTollUsd + 0.01; -const FriendStatus = { - BLOCKED: 0, - OTHER: 1, - CONNECTION: 2 -}; async function setToll(page, amount) { await page.click('#toggleSettings'); @@ -32,23 +28,6 @@ async function setToll(page, amount) { await page.click('#closeSettings'); } -async function setFriendStatus(page, username, status) { - await page.click('#switchToContacts'); - await expect(page.locator('#contactsScreen.active')).toBeVisible(); - await page.locator('#contactsList .chat-name', { hasText: username }).click(); - await expect(page.locator('#contactInfoModal.active')).toBeVisible(); - await page.click('#addFriendButtonContactInfo'); - await expect(page.locator('#friendModal.active')).toBeVisible(); - await page.check(`#friendForm input[type=radio][value="${status.toString()}"]`); - await page.click('#friendForm button[type="submit"]'); - await page.waitForEvent('console', { - timeout: 60_000, - predicate: msg => - /update_toll_required transaction successfully processed/i.test(msg.text()) - }); - await page.click('#closeContactInfoModal'); -} - const test = base.extend({ users: async ({ browser, browserName }, use) => { const ctxA = await newContext(browser); diff --git a/playwright-tests/tests/videoCall.e2e.test.js b/playwright-tests/tests/videoCall.e2e.test.js index 0c13a34..e82ad36 100644 --- a/playwright-tests/tests/videoCall.e2e.test.js +++ b/playwright-tests/tests/videoCall.e2e.test.js @@ -1,28 +1,9 @@ const { test: base, expect } = require('../fixtures/base'); const { sendMessageTo, checkReceivedMessage } = require('../helpers/messageHelpers'); +const { FriendStatus, setFriendStatusInChat } = require('../helpers/friendStatusHelpers'); const { createAndSignInUser, generateUsername } = require('../helpers/userHelpers'); const { newContext } = require('../helpers/toastHelpers'); -// Friend status enum aligned with app values -const FriendStatus = { - BLOCKED: 0, - OTHER: 1, - CONNECTION: 2 -}; - -// Helper to set friend status for a user from within chat modal -async function setFriendStatusInChat(page, status) { - await page.click('#addFriendButtonChat'); - await expect(page.locator('#friendModal.active')).toBeVisible(); - await page.check(`#friendForm input[type=radio][value="${status.toString()}"]`); - await page.click('#friendForm button[type="submit"]'); - await page.waitForEvent('console', { - timeout: 60_000, - predicate: msg => - /update_toll_required transaction successfully processed/i.test(msg.text()) - }); -} - // opens a new chat with a new user to create the contact async function addContact(page, username) { // Ensure we are on Chats screen From 29b1d337b1388929ae8e25d588c65596a8713055 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 28 Apr 2026 16:36:50 -0500 Subject: [PATCH 2/2] Add reaction rollback e2e coverage --- playwright-tests/helpers/injectHelpers.js | 88 ++++++++++++++++++++ playwright-tests/tests/reactions.e2e.test.js | 48 +++++++++++ 2 files changed, 136 insertions(+) create mode 100644 playwright-tests/helpers/injectHelpers.js diff --git a/playwright-tests/helpers/injectHelpers.js b/playwright-tests/helpers/injectHelpers.js new file mode 100644 index 0000000..15b9420 --- /dev/null +++ b/playwright-tests/helpers/injectHelpers.js @@ -0,0 +1,88 @@ +async function holdNextInject(page, responseBody) { + await page.evaluate((mockResponseBody) => { + if (!window.__injectMockOriginalFetch) { + window.__injectMockOriginalFetch = window.fetch.bind(window); + } + + let releaseResponse; + const state = { + intercepted: false, + postData: null, + released: false, + responded: false, + responseBody: mockResponseBody, + url: null, + }; + + state.releasePromise = new Promise((resolve) => { + releaseResponse = resolve; + }); + + state.release = () => { + state.released = true; + releaseResponse(); + }; + + window.__injectMockState = state; + window.fetch = async (input, init = {}) => { + const url = typeof input === 'string' ? input : input?.url || String(input); + const method = (init?.method || input?.method || 'GET').toUpperCase(); + + if (!state.intercepted && method === 'POST' && /\/inject(?:$|\?)/.test(url)) { + state.intercepted = true; + state.postData = init?.body || null; + state.url = url; + + await state.releasePromise; + state.responded = true; + return new Response(JSON.stringify(state.responseBody), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + return window.__injectMockOriginalFetch(input, init); + }; + }, responseBody); + + return { + dispose: async () => { + await page.evaluate(() => { + if (window.__injectMockOriginalFetch) { + window.fetch = window.__injectMockOriginalFetch; + } + delete window.__injectMockOriginalFetch; + delete window.__injectMockState; + }).catch(() => {}); + }, + fulfill: async () => { + await page.evaluate(() => window.__injectMockState?.release()); + await page.waitForFunction(() => window.__injectMockState?.responded === true); + }, + intercepted: page.waitForFunction(() => window.__injectMockState?.intercepted === true).then(() => { + return page.evaluate(() => ({ + postData: window.__injectMockState.postData, + url: window.__injectMockState.url, + })); + }), + release: async () => { + await page.evaluate(() => window.__injectMockState?.release()).catch(() => {}); + }, + }; +} + +function failedInjectResponse(reason = 'forced_inject_failure') { + return { + result: { + success: false, + reason, + }, + }; +} + +module.exports = { + failedInjectResponse, + holdNextInject, +}; diff --git a/playwright-tests/tests/reactions.e2e.test.js b/playwright-tests/tests/reactions.e2e.test.js index c40d329..0a245cb 100644 --- a/playwright-tests/tests/reactions.e2e.test.js +++ b/playwright-tests/tests/reactions.e2e.test.js @@ -1,5 +1,6 @@ const { test: base, expect } = require('../fixtures/base'); const { FriendStatus, setFriendStatus } = require('../helpers/friendStatusHelpers'); +const { failedInjectResponse, holdNextInject } = require('../helpers/injectHelpers'); const { sendMessageTo } = require('../helpers/messageHelpers'); const { createAndSignInUser, generateUsername } = require('../helpers/userHelpers'); const { newContext } = require('../helpers/toastHelpers'); @@ -189,6 +190,53 @@ test.describe('Message reactions', () => { await expectReactionChip(a.page, 'sent', message, Reaction.CUSTOM, { timeout: 60_000 }); }); + test('rolls back an optimistic reaction change when inject fails', async ({ users }) => { + const { a, b } = users; + const message = `reaction rollback path ${Date.now()}`; + + // Arrange: start with a successfully synced reaction so a failed replacement + // has a previous visible state to roll back to. + await prepareReactableMessage({ a, b, message }); + await chooseQuickReaction(b.page, 'received', message, 'React with thumbs up'); + await expectReactionChip(b.page, 'received', message, Reaction.THUMBS_UP); + + await openChat(a.page, b.username); + await expectReactionChip(a.page, 'sent', message, Reaction.THUMBS_UP, { timeout: 60_000 }); + + // Act: hold the next inject call so the UI can show the optimistic + // replacement before the network failure is returned. + const inject = await holdNextInject(b.page, failedInjectResponse('forced_reaction_failure')); + try { + await chooseQuickReaction(b.page, 'received', message, 'React with heart'); + await inject.intercepted; + + await expectReactionChip(b.page, 'received', message, Reaction.HEART); + + await inject.fulfill(); + + // Assert: the failed reaction set rolls back to the previous emoji and + // the picker active state follows the effective reaction. + await expect(b.page.locator('.toast.error.show', { + hasText: 'Reaction failed to send and was reverted', + })).toBeVisible({ timeout: 15_000 }); + await expectReactionChip(b.page, 'received', message, Reaction.THUMBS_UP); + await expectNoReactionChip(b.page, 'received', message, Reaction.HEART); + + const contextMenu = await openMessageContextMenu(b.page, 'received', message); + await expect(contextMenu.getByRole('button', { name: 'React with thumbs up' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(contextMenu.getByRole('button', { name: 'React with heart' })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + } finally { + inject.release(); + await inject.dispose(); + } + }); + test('reaction sends are blocked until the message author allows free reactions', async ({ users }) => { const { a, b } = users; const message = `reaction restriction path ${Date.now()}`;