// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. // *************************************************************** // - [#] indicates a test step (e.g. # Go to a page) // - [*] indicates an assertion (e.g. * Check the title) // - Use element ID when selecting an element. Create one if none. // *************************************************************** // Group: @channels @channel @channel_bookmarks // node run_tests.js --group='@channel' import type {Channel} from '@mattermost/types/channels'; import type {Team} from '@mattermost/types/teams'; import type {UserProfile} from '@mattermost/types/users'; import * as TIMEOUTS from '@/fixtures/timeouts'; import {getRandomId, stubClipboard} from '@/utils'; describe('Channel Bookmarks', () => { let testTeam: Team; let user1: UserProfile; let admin: UserProfile; let publicChannel: Channel; let privateChannel: Channel; let channelToArchive: Channel; const BOOKMARK_LIMIT = 50; before(() => { cy.apiRequireLicense(); cy.apiGetMe().then(({user: adminUser}) => { admin = adminUser; cy.apiInitSetup().then(({team, user, channel}) => { testTeam = team; user1 = user; publicChannel = channel; cy.apiCreateChannel(testTeam.id, 'private-channel', 'private channel', 'P').then((result) => { privateChannel = result.channel; cy.apiAddUserToChannel(privateChannel.id, user1.id); }); cy.apiCreateChannel(testTeam.id, 'public-channel-archive', 'public channel archive', 'O').then((result) => { channelToArchive = result.channel; cy.apiAddUserToChannel(channelToArchive.id, user1.id); }); cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); }); }); }); describe('functionality', () => { it('bookmarks bar hidden when empty', () => { // # Go to channel menu cy.uiGetChannelInfoButton(); cy.makeClient().then(async (client) => { const bookmarks = await client.getChannelBookmarks(publicChannel.id); cy.wrap(bookmarks.length).should('eq', 0); }); // * Verify bookmarks bar not present cy.findByTestId('channel-bookmarks-container').should('not.exist'); }); it('create link bookmark from channel menu', () => { // # Create link const {link, realLink} = createLinkBookmark({fromChannelMenu: true}); // * Verify bar now visible cy.findByTestId('channel-bookmarks-container').within(() => { // * Verify href cy.findByRole('link', {name: link}).should('have.attr', 'href', realLink); }); }); it('create link bookmark', () => { // # Create link const {link, realLink} = createLinkBookmark(); cy.findByTestId('channel-bookmarks-container').within(() => { // * Verify href cy.findByRole('link', {name: link}).should('have.attr', 'href', realLink); }); }); it('create link bookmark, with emoji and custom title', () => { const {realLink, displayName, emojiName} = createLinkBookmark({displayName: 'custom display name', emojiName: 'smiling_face_with_3_hearts'}); cy.findByTestId('channel-bookmarks-container').within(() => { // * Verify emoji, displayname, and href cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).should('have.attr', 'href', realLink); }); }); it('create file bookmark from channel menu', () => { // # Create bookmark const {file} = createFileBookmark({file: 'mp4-video-file.mp4', fromChannelMenu: true}); // * Verify uploaded cy.findByRole('link', {name: file}); }); it('create file bookmark, and open preview', () => { // # Create bookmark const {file} = createFileBookmark({file: 'small-image.png'}); // # Open the bookmark — may be in overflow menu cy.findByTestId('channel-bookmarks-container').then(($container) => { const barLink = findVisibleBarLink($container, file); if (barLink.length) { // Bookmark visible in bar — click directly cy.wrap(barLink).first().find('.file-icon.image').should('exist'); cy.wrap(barLink).first().click(); } else { // Bookmark is in overflow — open menu and click cy.get('#channelBookmarksBarMenuButton').click(); cy.findByRole('menuitem', {name: file}).click(); } }); // * Verify preview opened cy.get('.file-preview-modal').findByRole('heading', {name: file}); cy.get('.file-preview-modal__file-details-user-name').should('have.text', admin.username); cy.get('.file-preview-modal__channel').should('have.text', `Shared in ~${publicChannel.display_name}`); cy.get('.icon-close').click(); }); it('create file bookmark, progress and cancel upload', () => { const file = 'powerpointx-file.pptx'; cy.intercept( {method: 'POST', pathname: 'files', middleware: true, times: 1}, () => { return new Promise((resolve) => setTimeout(() => resolve(), 2000), ); }).as('uploadRequest'); // # Create bookmark createFileBookmark({file, save: false}); // # Cancel upload cy.get('a.file-preview__remove').click(); // * Verify empty preview container cy.get('.file-preview__container.empty'); // * Verify upload cancelled cy.wait('@uploadRequest').its('state').should('eq', 'Errored'); // * Verify cannot save cy.findByRole('button', {name: 'Add bookmark'}).should('be.disabled'); // # Try upload file again cy.get('#bookmark-create-file-input-in-modal').attachFile(file); // * Verify uploaded cy.findByTestId('titleInput').should('have.value', file); cy.findByRole('link', {name: `file thumbnail ${file}`}); // # Save editModalCreate(); // * Verify bookmark created — may be in bar or overflow findBookmarkAnywhere(file); }); it('create file bookmark, with emoji and custom title', () => { // # Create bookmark const {file, displayName} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname small-image', emojiName: 'smiling_face_with_3_hearts'}); // * Verify emoji and custom display name, then open preview — may be in bar or overflow cy.findByTestId('channel-bookmarks-container').then(($container) => { const barLink = findVisibleBarLink($container, displayName); if (barLink.length) { cy.wrap(barLink).first().click(); } else { cy.get('#channelBookmarksBarMenuButton').click(); cy.contains('[role="menuitem"]', displayName).click(); } }); // * Verify preview opened cy.get('.file-preview-modal').findByRole('heading', {name: file}); cy.get('.icon-close').click(); }); it('edit link bookmark', () => { // # Create link const {displayName} = createLinkBookmark(); const nextLink = 'google.com/q=test001'; const realNextLink = `http://${nextLink}`; const nextDisplayName = 'Next custom display name'; const nextEmojiName = 'handshake'; // # Open edit openEditModal(displayName); // # Change link, displayname, emoji editTextInput('linkInput', nextLink); editTextInput('titleInput', nextDisplayName); selectEmoji(nextEmojiName); // # Save editModalSave(); cy.get('.modal').should('not.exist'); // * Verify changes — bookmark may be in bar or overflow cy.findByTestId('channel-bookmarks-container').then(($container) => { const barLink = findVisibleBarLink($container, nextDisplayName); if (barLink.length) { cy.wrap(barLink).first().should('have.attr', 'href', realNextLink); } else { cy.get('#channelBookmarksBarMenuButton').click(); cy.contains('[role="menuitem"]', nextDisplayName).should('exist'); cy.get('body').type('{esc}'); // * Verify URL was saved via API (overflow items don't render href) cy.makeClient().then(async (client) => { const bookmarks = await client.getChannelBookmarks(publicChannel.id); const edited = bookmarks.find((b) => b.display_name === nextDisplayName); expect(edited?.link_url).to.eq(realNextLink); }); } }); }); it('edit link bookmark, only display name and emoji', () => { // # Create link const {displayName, realLink} = createLinkBookmark(); const nextDisplayName = 'Next custom display name 2'; const nextEmojiName = 'handshake'; // # Open edit openEditModal(displayName); // # Change link, displayname, emoji editTextInput('titleInput', nextDisplayName); selectEmoji(nextEmojiName); // # Save editModalSave(); cy.get('.modal').should('not.exist'); // * Verify changes — bookmark may be in bar or overflow cy.findByTestId('channel-bookmarks-container').then(($container) => { const barLink = findVisibleBarLink($container, nextDisplayName); if (barLink.length) { cy.wrap(barLink).first().should('have.attr', 'href', realLink); } else { cy.get('#channelBookmarksBarMenuButton').click(); cy.contains('[role="menuitem"]', nextDisplayName).should('exist'); cy.get('body').type('{esc}'); // * Verify URL unchanged via API (overflow items don't render href) cy.makeClient().then(async (client) => { const bookmarks = await client.getChannelBookmarks(publicChannel.id); const edited = bookmarks.find((b) => b.display_name === nextDisplayName); expect(edited?.link_url).to.eq(realLink); }); } }); }); it('delete bookmark', () => { const {displayName} = createLinkBookmark(); // * Verify bookmark exists (bar or overflow) findBookmarkAnywhere(displayName); // # Start delete bookmark flow openDotMenu(displayName); cy.findByRole('menuitem', {name: 'Delete'}).click(); cy.findByRole('dialog', {name: 'Delete bookmark'}).within(() => { // * Verify delete dialog contents cy.findByRole('heading', {name: 'Delete bookmark'}); cy.contains(`Are you sure you want to delete the bookmark ${displayName}?`); // # Delete bookmark cy.findByRole('button', {name: 'Yes, delete'}).click(); }); // * Verify bookmark deleted (not in bar) cy.findByTestId('channel-bookmarks-container').then(($container) => { const barLink = findVisibleBarLink($container, displayName); expect(barLink.length).to.eq(0); }); // * Verify bookmark absent from server (covers both bar and overflow) cy.makeClient().then(async (client) => { const bookmarks = await client.getChannelBookmarks(publicChannel.id); const found = bookmarks.find((b) => b.display_name === displayName); expect(found).to.be.undefined; }); }); it('reorder bookmark', () => { // Use a fresh channel to avoid overflow — need items visible in bar cy.apiCreateChannel(testTeam.id, `reorder-${getRandomId(4)}`, 'reorder test', 'O').then((result) => { cy.visit(`/${testTeam.name}/channels/${result.channel.name}`); const {displayName: name1} = createLinkBookmark({displayName: 'Reorder Item A', fromChannelMenu: true}); const {displayName: name2} = createLinkBookmark({displayName: 'Reorder Item B'}); cy.findByTestId('channel-bookmarks-container').within(() => { cy.findAllByRole('link').should('have.length', 2).as('bookmarks'); cy.get('@bookmarks').eq(0).should('contain', name1); cy.get('@bookmarks').eq(1).should('contain', name2); // # Keyboard reorder: Space to select, ArrowRight to move, Space to confirm cy.findByRole('link', {name: name1}).focus().type(' '); cy.wait(TIMEOUTS.HALF_SEC); cy.findByRole('link', {name: name1}).type('{rightarrow}'); cy.wait(TIMEOUTS.HALF_SEC); // * Verify focus follows the moved item cy.focused().should('contain', name1); cy.findByRole('link', {name: name1}).type(' '); }); cy.wait(TIMEOUTS.ONE_SEC); // * Verify order swapped cy.findByTestId('channel-bookmarks-container').within(() => { cy.findAllByRole('link').should('have.length', 2).as('after'); cy.get('@after').eq(0).should('contain', name2); cy.get('@after').eq(1).should('contain', name1); }); // # Return to original channel for subsequent tests cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); }); }); it('reorder is disabled when only one bookmark exists', () => { // # Use a fresh channel with exactly one bookmark cy.apiCreateChannel(testTeam.id, `single-${getRandomId(4)}`, 'single bookmark', 'O').then((result) => { cy.visit(`/${testTeam.name}/channels/${result.channel.name}`); const {displayName} = createLinkBookmark({displayName: 'Solo Bookmark', fromChannelMenu: true}); // # Press Space on the only bookmark cy.findByTestId('channel-bookmarks-container').findByRole('link', {name: displayName}).focus(). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); cy.wait(TIMEOUTS.HALF_SEC); // * Reorder visual state did not engage (no 3px reorder outline) cy.findByTestId('channel-bookmarks-container'). find('[data-bookmark-id] > div').first(). should('not.have.css', 'outline-width', '3px'); // * Bookmark count and identity unchanged cy.findByTestId('channel-bookmarks-container').findAllByRole('link'). should('have.length', 1).first().should('contain', displayName); // # Return to original channel for subsequent tests cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); }); }); }); describe('manage bookmarks - permissions enforcement', () => { before(() => { // # Prep: disable manage bookmarks for users - public and private channels cy.uiResetPermissionsToDefault(); cy.findByTestId('all_users-public_channel-manage_public_channel_bookmarks-checkbox').click(); cy.findByTestId('all_users-private_channel-manage_private_channel_bookmarks-checkbox').click(); cy.uiSaveConfig(); cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); }); after(() => { // # Reset permissions cy.apiAdminLogin(); cy.uiResetPermissionsToDefault(); cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); }); it('can add bookmark: public channel, admin', () => { // # To public channel with bookmark createLinkBookmark({fromChannelMenu: true}); // * Verify admin can create in public channel verifyCanCreate(); }); it('can add bookmark: private channel, admin', () => { // # To private channel with bookmark cy.visit(`/${testTeam.name}/channels/${privateChannel.name}`); createLinkBookmark({fromChannelMenu: true}); // * Verify admin can create in private channel verifyCanCreate(); }); it('cannot add bookmark: archived channel', () => { // # private channel cy.visit(`/${testTeam.name}/channels/${channelToArchive.name}`); createLinkBookmark({fromChannelMenu: true}); cy.apiDeleteChannel(channelToArchive.id); // * Verify cannot create in archived channel verifyCannotCreate(); }); it('cannot add bookmark: private channel, non-admin', () => { // # Switch to non-admin user and reload to get fresh permissions cy.apiLogin(user1); cy.visit(`/${testTeam.name}/channels/${privateChannel.name}`); cy.wait(TIMEOUTS.ONE_SEC); // * Verify non-admin user cannot create in private channel verifyCannotCreate(); }); it('cannot add bookmark: public channel, non-admin', () => { // # To public channel with bookmark cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); // * Verify non-admin user cannot create in public channel verifyCannotCreate(); }); const verifyCanCreate = () => { // * Verify can access create UI - bookmarks bar promptAddLink(); cy.uiCloseModal('Add a bookmark'); // * Verify can access create UI - channel menu promptAddLink(true); cy.uiCloseModal('Add a bookmark'); }; const verifyCannotCreate = () => { // * Verify create bookmark submenu not in channel menu cy.uiOpenChannelMenu(); cy.findByRole('menuitem', {name: 'Bookmarks Bar'}).should('not.exist'); cy.get('body').type('{esc}'); // * Verify add actions not in bookmarks bar menu (if button exists due to overflow items) cy.get('body').then(($body) => { if ($body.find('#channelBookmarksBarMenuButton').length) { cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksAddLink').should('not.exist'); cy.get('#channelBookmarksAttachFile').should('not.exist'); cy.get('body').type('{esc}'); } }); }; }); describe('overflow and reorder', () => { let overflowChannel: Channel; const OVERFLOW_COUNT = 15; before(() => { cy.apiAdminLogin(); cy.apiCreateChannel(testTeam.id, `overflow-${getRandomId(4)}`, 'overflow test', 'O').then((result) => { overflowChannel = result.channel; cy.makeClient().then(async (client) => { for (let i = 1; i <= OVERFLOW_COUNT; i++) { await client.createChannelBookmark(overflowChannel.id, { type: 'link', display_name: `OvBm ${String(i).padStart(2, '0')}`, link_url: `https://example.com/bm-${i}`, }, ''); } }); cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); }); }); // Dismiss any stale menu/backdrop from previous test beforeEach(() => { cy.get('body').then(($body) => { if ($body.find('#backdropForMenuComponent').length) { cy.get('#backdropForMenuComponent').click({force: true}); cy.wait(TIMEOUTS.HALF_SEC); } }); }); it('shows overflow count badge', () => { // * Verify overflow button is visible with a positive count cy.get('#channelBookmarksBarMenuButton').should('be.visible'); cy.get('#channelBookmarksBarMenuButton').invoke('text').then((text) => { const count = parseInt(text.replace(/\D/g, ''), 10); expect(count).to.be.greaterThan(0); }); }); it('opens overflow menu and shows items', () => { cy.get('#channelBookmarksBarMenuButton').click(); // * Verify overflow items appear as menuitems cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.get('[data-bookmark-id]').should('have.length.gte', 2); }); // * Verify add actions below separator — scroll if needed cy.get('#channelBookmarksAddLink').scrollIntoView().should('exist'); cy.get('#channelBookmarksAttachFile').should('exist'); dismissMenu(); }); it('keyboard reorder within overflow', () => { // # Open overflow menu and get the first two item names cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').eq(0).invoke('text').then((firstText) => { const firstName = firstText.trim(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').eq(1).invoke('text').then((secondText) => { const secondName = secondText.trim(); // # Select first item with Space, move down, confirm with Space cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstName).focus(). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); }); cy.wait(TIMEOUTS.HALF_SEC); cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstName). trigger('keydown', {key: 'ArrowDown', code: 'ArrowDown', bubbles: true}); }); cy.wait(TIMEOUTS.HALF_SEC); cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstName). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); }); cy.wait(TIMEOUTS.ONE_SEC); // * Verify overflow menu stays open after confirm cy.get('#channelBookmarksBarMenuDropdown').should('exist'); // * Verify items swapped — first item should now be what was second cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').eq(0).should('contain', secondName); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').eq(1).should('contain', firstName); dismissMenu(); }); }); }); it('keyboard reorder: Escape cancels', () => { cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').eq(0).invoke('text').then((firstText) => { const firstName = firstText.trim(); // # Select, move, then Escape to cancel cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstName).focus(). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); }); cy.wait(TIMEOUTS.HALF_SEC); cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstName). trigger('keydown', {key: 'ArrowDown', code: 'ArrowDown', bubbles: true}); }); cy.wait(TIMEOUTS.HALF_SEC); cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstName). trigger('keydown', {key: 'Escape', code: 'Escape', bubbles: true}); }); cy.wait(TIMEOUTS.ONE_SEC); // Close menu and backdrop before reopening to verify dismissMenu(); // * Verify original order restored — first item unchanged cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').eq(0).should('contain', firstName); dismissMenu(); }); }); it('edit from overflow dot menu', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); const newName = `Edited OvBm ${getRandomId(4)}`; // # Open overflow menu cy.get('#channelBookmarksBarMenuButton').click(); // # Hover first overflow item to reveal dot menu, then click it clickOverflowDotMenu(cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first()); cy.findByRole('menuitem', {name: 'Edit'}).click(); // # Change display name editTextInput('titleInput', newName); editModalSave(); cy.get('.modal').should('not.exist'); // * Verify the name changed — may be in bar or overflow findBookmarkAnywhere(newName); }); it('copy link from overflow dot menu', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Stub clipboard to intercept the copy stubClipboard().as('clipboard'); // # Open overflow menu and get the first item's link URL cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first().invoke('attr', 'data-bookmark-id').then((bookmarkId) => { // # Click first overflow item's dot menu clickOverflowDotMenu(cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first()); // # Click "Copy link" cy.findByRole('menuitem', {name: 'Copy link'}).should('exist').click(); // * Verify dot menu closed cy.get('#channelBookmarksDotMenuDropdown').should('not.exist'); // * Verify clipboard contains the bookmark URL cy.makeClient().then(async (client) => { const bookmarks = await client.getChannelBookmarks(overflowChannel.id); const bookmark = bookmarks.find((b) => b.id === bookmarkId); cy.get('@clipboard').its('contents').should('eq', bookmark?.link_url); }); }); }); it('delete from overflow dot menu', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Open overflow menu and get first item name cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first().invoke('text').then((itemText) => { const itemName = itemText.trim(); // # Hover first overflow item to reveal dot menu, then click it clickOverflowDotMenu(cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first()); cy.findByRole('menuitem', {name: 'Delete'}).click(); cy.findByRole('dialog', {name: 'Delete bookmark'}).within(() => { cy.findByRole('button', {name: 'Yes, delete'}).click(); }); cy.get('.modal').should('not.exist'); // * Verify item is gone — reopen menu and check cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', itemName).should('not.exist'); }); dismissMenu(); }); }); it('overflow recalculates on viewport resize', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Record initial overflow count cy.get('#channelBookmarksBarMenuButton').invoke('text').then((text) => { const initialCount = parseInt(text.replace(/\D/g, ''), 10); // # Shrink viewport to push more items into overflow cy.viewport(800, 660); cy.wait(TIMEOUTS.ONE_SEC); // * Verify overflow count increased cy.get('#channelBookmarksBarMenuButton').invoke('text').then((newText) => { const newCount = parseInt(newText.replace(/\D/g, ''), 10); expect(newCount).to.be.greaterThan(initialCount); }); // # Restore viewport cy.viewport(1300, 660); }); }); it('ArrowDown on open menu panel focuses first overflow item', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Open overflow menu — MUI focuses the Paper (menu panel), not the button cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown').should('be.visible'); // # Press ArrowDown on the menu panel (where focus rests after open) cy.get('#channelBookmarksBarMenuDropdown').trigger('keydown', {key: 'ArrowDown', code: 'ArrowDown'}); // * Verify first overflow item has focus cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first().then(($el) => { cy.focused().closest('[data-bookmark-id]').should('have.attr', 'data-bookmark-id', $el.attr('data-bookmark-id')); }); dismissMenu(); }); it('keyboard reorder: ArrowRight moves last bar item into overflow', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Capture the last visible bar item's name (scoped to bar to exclude overflow) cy.findByTestId('channel-bookmarks-container').findAllByRole('link').last().invoke('text').then((text) => { const lastName = text.trim(); // # Start reorder, move right to cross into overflow, confirm cy.findByRole('link', {name: lastName}).focus(). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); cy.focused().trigger('keydown', {key: 'ArrowRight', code: 'ArrowRight', bubbles: true}); cy.focused().trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); // * Verify via API the item moved past its original last-visible position cy.makeClient().then(async (client) => { const bookmarks = await client.getChannelBookmarks(overflowChannel.id); const movedIndex = bookmarks.findIndex((b) => b.display_name === lastName); expect(movedIndex).to.be.greaterThan(0); }); // * Verify DOM: item is present in the open overflow menu (menu stays // open after confirm when item ends in overflow) cy.get('#channelBookmarksBarMenuDropdown'). should('be.visible'). contains(lastName). should('be.visible'); // # Close menu to remove MUI backdrop covering the bar dismissMenu(); cy.get('#channelBookmarksBarMenuDropdown').should('not.exist'); // * Verify DOM: item is no longer rendered in the visible bar cy.findByTestId('channel-bookmarks-container').findAllByRole('link').then(($links) => { const barNames = [...$links].map((el) => el.textContent?.trim()); expect(barNames).to.not.include(lastName); }); }); }); it('keyboard reorder: ArrowUp moves first overflow item into bar', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Open overflow and start reorder on first item, move up to bar cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first().invoke('text').then((text) => { const firstOverflowName = text.trim(); cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstOverflowName).focus(). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); cy.contains('[data-bookmark-id]', firstOverflowName). trigger('keydown', {key: 'ArrowUp', code: 'ArrowUp', bubbles: true}); }); // * Verify overflow menu closed (transitioned to bar) cy.get('#channelBookmarksBarMenuDropdown').should('not.exist'); // # Confirm placement on bar cy.focused().trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); // * Verify the moved item is now visible in the bar cy.findByTestId('channel-bookmarks-container').within(() => { cy.findByRole('link', {name: firstOverflowName}).should('exist'); }); }); }); it('ArrowRight opens dot menu from overflow item', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Open overflow and focus first item cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first().focus(); // # ArrowRight to open dot menu cy.focused().trigger('keydown', {key: 'ArrowRight', code: 'ArrowRight', bubbles: true}); // * Verify dot menu dropdown opened cy.get('#channelBookmarksDotMenuDropdown').should('be.visible'); // # ArrowLeft to close and return to item cy.get('#channelBookmarksDotMenuDropdown').trigger('keydown', {key: 'ArrowLeft', code: 'ArrowLeft', bubbles: true}); // * Verify dot menu closed cy.get('#channelBookmarksDotMenuDropdown').should('not.exist'); dismissMenu(); }); it('keyboard reorder Space confirm does not activate link', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); const initialUrl = `/${testTeam.name}/channels/${overflowChannel.name}`; // # Open overflow menu cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first().invoke('text').then((text) => { const firstName = text.trim(); // # Space to select, ArrowDown to move, Space to confirm cy.get('#channelBookmarksBarMenuDropdown').within(() => { cy.contains('[data-bookmark-id]', firstName).focus(). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); cy.contains('[data-bookmark-id]', firstName). trigger('keydown', {key: 'ArrowDown', code: 'ArrowDown', bubbles: true}); cy.contains('[data-bookmark-id]', firstName). trigger('keydown', {key: ' ', code: 'Space', bubbles: true}); }); // * Verify we did NOT navigate (URL unchanged, no new tab) cy.url().should('include', initialUrl); }); dismissMenu(); }); it('overflow menu button aria-label reflects count', () => { // # Overflow channel — button should mention count cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); cy.get('#channelBookmarksBarMenuButton').should(($btn) => { const label = $btn.attr('aria-label') ?? ''; expect(label).to.match(/more bookmark/i); }); }); it('overflow recalculates when a bookmark is renamed to a much longer name', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Capture initial overflow count cy.get('#channelBookmarksBarMenuButton').invoke('text').then((text) => { const initialCount = parseInt(text.replace(/\D/g, ''), 10); // # Rename the first bookmark via API — edit UI is tested separately. // This test verifies overflow recalculation behavior, not the rename flow. cy.makeClient().then(async (client) => { const bookmarks = await client.getChannelBookmarks(overflowChannel.id); await client.updateChannelBookmark(overflowChannel.id, bookmarks[0].id, { display_name: 'A very very very very very very very long renamed bookmark', }, ''); }); // * Verify overflow count increased (item got wider → more items pushed into overflow) cy.get('#channelBookmarksBarMenuButton').should(($btn) => { const newCount = parseInt($btn.text().replace(/\D/g, ''), 10); expect(newCount).to.be.greaterThan(initialCount); }); }); }); it('channel switch preserves overflow detection', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); cy.get('#channelBookmarksBarMenuButton').should('be.visible'); // # Switch to publicChannel (few/no bookmarks — overflow button should not show count) cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); // # Back to overflow channel — detection still works cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); cy.get('#channelBookmarksBarMenuButton').should(($btn) => { const count = parseInt($btn.text().replace(/\D/g, ''), 10); expect(count).to.be.greaterThan(0); }); }); it('dot menu "Open" action fires exactly once', () => { cy.visit(`/${testTeam.name}/channels/${overflowChannel.name}`); cy.findByTestId('channel-bookmarks-container').should('be.visible'); // # Stub window.open so we can count external-tab opens from "Open" action cy.window().then((win) => { cy.stub(win, 'open').as('windowOpen'); }); // # Open overflow, click dot menu on first item cy.get('#channelBookmarksBarMenuButton').click(); clickOverflowDotMenu(cy.get('#channelBookmarksBarMenuDropdown [data-bookmark-id]').first()); // # Press Enter on "Open" menu item — the keydown + ButtonBase synthetic click // used to fire onClick twice. handledRef guard ensures single dispatch. cy.findByRole('menuitem', {name: 'Open'}).should('exist').focus(). trigger('keydown', {key: 'Enter', code: 'Enter', bubbles: true}); // * Verify window.open called exactly once (no Menu.Item double-fire) cy.get('@windowOpen').should('have.been.calledOnce'); }); }); describe('limits enforced', () => { it('max bookmarks', () => { // # Create links, fill to max makeBookmarks(publicChannel); cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); // * Verify overflow menu button is still enabled (shows overflow items) cy.get('#channelBookmarksBarMenuButton').should('not.be.disabled'); // # Open the overflow/plus menu cy.get('#channelBookmarksBarMenuButton').click(); // * Verify add actions are disabled when at limit cy.get('#channelBookmarksAddLink').should('have.attr', 'aria-disabled', 'true'); cy.get('#channelBookmarksAttachFile').should('have.attr', 'aria-disabled', 'true'); }); }); function makeBookmarks(channel: Channel, n?: number) { cy.makeClient().then(async (client) => { const nToMake = n ?? BOOKMARK_LIMIT - (await client.getChannelBookmarks(channel.id))?.length; await Promise.allSettled(Array(nToMake).fill(0).map(() => { return client.createChannelBookmark(channel.id, { type: 'link', display_name: 'google.com', link_url: `https://google.com/?q=test${getRandomId(7)}`, }, ''); })); }); } }); function promptAddLink(fromChannelMenu = false) { if (fromChannelMenu) { cy.uiOpenChannelMenu(); cy.findByRole('menuitem', {name: 'Bookmarks Bar'}).trigger('mouseover'); cy.findByRole('menuitem', {name: 'Add a link'}).click(); } else { cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksAddLink').click(); } } function promptAddFile(fromChannelMenu = false) { if (fromChannelMenu) { cy.uiOpenChannelMenu(); cy.findByRole('menuitem', {name: 'Bookmarks Bar'}).trigger('mouseover'); cy.findByRole('menuitem', {name: 'Attach a file'}).click(); } else { cy.get('#channelBookmarksBarMenuButton').click(); cy.get('#channelBookmarksAttachFile').click(); } } function openEditModal(name: string) { openDotMenu(name); cy.findByRole('menuitem', {name: 'Edit'}).click(); } /** * Opens the dot menu for a bookmark by name. * Handles both bar items and overflow items. */ function openDotMenu(name: string) { // Wait for any stale modal to close (e.g. file preview from previous test) cy.get('.modal').should('not.exist'); cy.findByTestId('channel-bookmarks-container').then(($container) => { const barLink = findVisibleBarLink($container, name); if (barLink.length) { // Bar item — dot menu hidden via opacity:0 until :hover (CSS), // force:true bypasses since Cypress can't trigger :hover. cy.wrap(barLink).first().closest('[data-bookmark-id]').scrollIntoView(); cy.wrap(barLink).first().closest('[data-bookmark-id]'). find('button[aria-label="Bookmark menu"]').click({force: true}); } else { // Overflow item — open overflow menu, find the item's dot menu cy.get('#channelBookmarksBarMenuButton').click(); clickOverflowDotMenu( cy.get('#channelBookmarksBarMenuDropdown').contains('[role="menuitem"]', name), ); } }); } /** * Finds a bookmark anywhere — bar or overflow — and asserts it exists. */ function findBookmarkAnywhere(name: string) { cy.findByTestId('channel-bookmarks-container').then(($container) => { const barLink = findVisibleBarLink($container, name); if (barLink.length) { cy.wrap(barLink).first().should('be.visible'); } else { cy.get('#channelBookmarksBarMenuButton').click(); cy.contains('[role="menuitem"]', name).should('exist'); cy.get('body').type('{esc}'); } }); } function editModalSave() { cy.findByRole('button', {name: 'Save bookmark'}).click(); } function editModalCreate() { cy.findByRole('button', {name: 'Add bookmark'}).click(); cy.get('.modal').should('not.exist'); } function createLinkBookmark({ link = `google.com/?q=test${getRandomId(7)}`, displayName = '', emojiName = '', // e.g. smile save = true, fromChannelMenu = false, } = {}) { const realLink = `http://${link}`; // # Add link promptAddLink(fromChannelMenu); // # Enter link editTextInput('linkInput', link); cy.wait(TIMEOUTS.HALF_SEC); if (displayName) { // # Enter displayname editTextInput('titleInput', displayName); } if (emojiName) { // # Select emoji selectEmoji(emojiName); } if (save) { // # Save editModalCreate(); } return {link, realLink, displayName: displayName || link, emojiName}; } function createFileBookmark({ file = 'small-image.png', displayName = '', emojiName = '', // e.g. smile save = true, fromChannelMenu = false, } = {}) { promptAddFile(fromChannelMenu); cy.get('#root-portal #bookmark-create-file-input').attachFile(file); if (displayName) { // # Enter displayname cy.findByTestId('titleInput').should('have.value', file); editTextInput('titleInput', displayName); } if (emojiName) { // # Select emoji selectEmoji(emojiName); } if (save) { // # Save cy.findByRole('button', {name: 'Add bookmark'}).click(); cy.get('.modal').should('not.exist'); } return {file, displayName, emojiName}; } function editTextInput(testid: string, nextValue: string) { cy.findByTestId(testid). focus(). clear(). wait(TIMEOUTS.HALF_SEC). type(nextValue). wait(TIMEOUTS.HALF_SEC). should('have.value', nextValue); } /** * * @param emojiName Name of emoji to select. Be overly specific * e.g `smile` will have overlapping results, but `smiling_face_with_3_hearts` is unique with no overlapping results */ function selectEmoji(emojiName: string) { cy.findByRole('dialog').within(() => { cy.findByRole('button', {name: 'select an emoji'}).click(); }); cy.focused().type(`${emojiName}{downArrow}{enter}`); } /** * Dismisses any open overflow/bookmark menu by clicking the backdrop. * Waits for the menu to fully close before proceeding. */ function dismissMenu() { cy.get('body').then(($body) => { if ($body.find('#channelBookmarksBarMenuDropdown').length) { cy.get('#backdropForMenuComponent').click({force: true}); cy.wait(TIMEOUTS.HALF_SEC); } }); } /** * Clicks the dot menu button on an overflow bookmark item. * The button is hidden via CSS opacity:0 until :hover, which * can't be triggered by Cypress synthetic events in headless * mode. force:true bypasses the visibility check. */ function clickOverflowDotMenu(item: Cypress.Chainable) { item.scrollIntoView(). find('.channelBookmarksDotMenuButton--overflow').click({force: true}); } /** * Finds visible bar links by name, excluding hidden measurement items. * Hidden items (used for overflow measurement) have `visibility: hidden` * on an ancestor, which is inherited by the anchor element. */ function findVisibleBarLink($container: JQuery, name: string): JQuery { return $container.find(`a:contains("${name}")`).filter(function() { return Cypress.$(this).css('visibility') !== 'hidden'; }); }