diff --git a/e2e-tests/playwright/lib/src/constant.ts b/e2e-tests/playwright/lib/src/constant.ts index a7bebd373a0..7414e8f68b6 100644 --- a/e2e-tests/playwright/lib/src/constant.ts +++ b/e2e-tests/playwright/lib/src/constant.ts @@ -5,6 +5,17 @@ export const appsPluginId = 'com.mattermost.apps'; export const callsPluginId = 'com.mattermost.calls'; export const playbooksPluginId = 'playbooks'; +// License SKU short names — mirrored from webapp/channels/src/utils/constants.tsx LicenseSkus +export const LicenseSkus = { + E10: 'E10', + E20: 'E20', + Starter: 'starter', + Professional: 'professional', + Enterprise: 'enterprise', + EnterpriseAdvanced: 'advanced', + Entry: 'entry', +} as const; + // Remote users hour limit taken from webapp/channels/src/utils/constants.ts export const REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY = 22; export const REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY = 6; diff --git a/e2e-tests/playwright/lib/src/index.ts b/e2e-tests/playwright/lib/src/index.ts index 3acc077d02e..c69303492c6 100644 --- a/e2e-tests/playwright/lib/src/index.ts +++ b/e2e-tests/playwright/lib/src/index.ts @@ -8,6 +8,7 @@ export {TestBrowser} from './browser_context'; export {getBlobFromAsset, getFileFromAsset} from './file'; export {decomposeKorean, koreanTestPhrase, typeHangulCharacterWithIme, typeHangulWithIme} from './ime'; export {duration, getRandomId, wait, newTestPassword} from './util'; +export {LicenseSkus, appsPluginId, callsPluginId, playbooksPluginId} from './constant'; export { ChannelsPage, diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts b/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts index 609c6da8f54..9364f7412d4 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts @@ -153,6 +153,12 @@ export default class ChannelsCenterView { await expect(this.channelBanner).not.toBeVisible(); } + async assertChannelBannerTextNotClipped() { + const bannerText = this.channelBanner.getByTestId('channel_banner_text'); + await expect(bannerText).toBeVisible(); + await this.assertElementContainedInBanner(bannerText); + } + async assertChannelBannerHasBoldText(text: string) { const boldText = await this.channelBanner.locator('strong'); expect(boldText).toBeVisible(); @@ -176,4 +182,86 @@ export default class ChannelsCenterView { const actualText = await strikethroughText.textContent(); expect(actualText).toBe(text); } + + async assertChannelBannerHasEmoticon() { + const emoji = this.channelBanner.locator('.emoticon:not(.emoticon--unicode)').first(); + await expect(emoji).toBeVisible(); + + const backgroundImage = await emoji.evaluate((el) => { + return window.getComputedStyle(el).getPropertyValue('background-image'); + }); + + expect(backgroundImage).not.toBe('none'); + } + + async assertChannelBannerImageEmojiSize(expectedSizePx: number) { + const emoji = this.channelBanner.locator('.emoticon:not(.emoticon--unicode)').first(); + await expect(emoji).toBeVisible(); + + const {width, height} = await emoji.evaluate((el) => { + const styles = window.getComputedStyle(el); + return { + width: styles.getPropertyValue('width'), + height: styles.getPropertyValue('height'), + }; + }); + + expect(width).toBe(`${expectedSizePx}px`); + expect(height).toBe(`${expectedSizePx}px`); + + await this.assertElementContainedInBanner(emoji); + } + + async assertChannelBannerUnicodeEmojiSize(expectedSizePx: number) { + const emoji = this.channelBanner.locator('.emoticon--unicode').first(); + await expect(emoji).toBeVisible(); + + const fontSize = await emoji.evaluate((el) => { + return window.getComputedStyle(el).getPropertyValue('font-size'); + }); + + expect(fontSize).toBe(`${expectedSizePx}px`); + + await this.assertElementContainedInBanner(emoji); + } + + /** + * Asserts that the given element's bounding box lies fully within the channel + * banner's content area (banner bounds minus computed padding). + * + * Uses getBoundingClientRect() coordinates, which are NOT clipped by parent + * overflow — so if an element protrudes into or beyond the padding zone it will + * be visually clipped by `overflow: hidden` on the text container, and this + * assertion will catch that. + * + * A small epsilon is applied to each boundary to avoid flaky failures caused + * by sub-pixel rounding differences in layout engines. + */ + private async assertElementContainedInBanner(element: Locator) { + const EPSILON = 0.5; + + const bannerBox = await this.channelBanner.boundingBox(); + const elementBox = await element.boundingBox(); + + expect(bannerBox).not.toBeNull(); + expect(elementBox).not.toBeNull(); + + const banner = bannerBox!; + const el = elementBox!; + + const {paddingTop, paddingBottom, paddingLeft, paddingRight} = await this.channelBanner.evaluate((node) => { + const styles = window.getComputedStyle(node); + return { + paddingTop: parseFloat(styles.paddingTop), + paddingBottom: parseFloat(styles.paddingBottom), + paddingLeft: parseFloat(styles.paddingLeft), + paddingRight: parseFloat(styles.paddingRight), + }; + }); + + expect(el.y).toBeGreaterThanOrEqual(banner.y + paddingTop - EPSILON); + expect(el.y + el.height).toBeLessThanOrEqual(banner.y + banner.height - paddingBottom + EPSILON); + expect(el.x).toBeGreaterThanOrEqual(banner.x + paddingLeft - EPSILON); + expect(el.x + el.width).toBeLessThanOrEqual(banner.x + banner.width - paddingRight + EPSILON); + } } diff --git a/e2e-tests/playwright/specs/functional/channels/channel_banner/channel_banner.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_banner/channel_banner.spec.ts index 07d2a3d23aa..9ef994f633d 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_banner/channel_banner.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_banner/channel_banner.spec.ts @@ -3,6 +3,8 @@ import {test} from '@mattermost/playwright-lib'; +const EMOJI_SIZE = 16; + test('Should show channel banner when configured', async ({pw}) => { const {adminUser, adminClient} = await pw.initSetup(); const license = await adminClient.getClientLicenseOld(); @@ -89,9 +91,12 @@ test('Should not show channel banner in thread view when disabled', async ({pw}) await channelsPage.toBeVisible(); await channelsPage.newChannel(pw.random.id(), 'O'); + await channelsPage.centerView.toBeVisible(); - // Post a message and open the thread - await channelsPage.centerView.postMessage('Message without banner'); + // Focus and type character-by-character to avoid React clearing a programmatic fill() + await channelsPage.centerView.postCreate.input.click(); + await channelsPage.centerView.postCreate.input.pressSequentially('Message without banner'); + await channelsPage.centerView.postCreate.sendMessage(); const post = await channelsPage.centerView.getLastPost(); await post.reply(); @@ -99,6 +104,82 @@ test('Should not show channel banner in thread view when disabled', async ({pw}) await channelsPage.sidebarRight.assertChannelBannerNotVisible(); }); +test('Should render image emoticons without clipping', async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + const license = await adminClient.getClientLicenseOld(); + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license'); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + await channelsPage.newChannel(pw.random.id(), 'O'); + + const channelSettingsModal = await channelsPage.openChannelSettings(); + const configurationTab = await channelSettingsModal.openConfigurationTab(); + + await configurationTab.enableChannelBanner(); + await configurationTab.setChannelBannerTextColor('77DD88'); + // :dog: is in Mattermost's emoji map → renders as .emoticon (background-image). + // Unicode emojis that are also in the map (e.g. 🐶) follow the same path. + await configurationTab.setChannelBannerText('Hello :dog:'); + await configurationTab.save(); + await channelSettingsModal.close(); + + await channelsPage.centerView.assertChannelBannerImageEmojiSize(EMOJI_SIZE); +}); + +test('Should render unsupported unicode emoji without clipping', async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + const license = await adminClient.getClientLicenseOld(); + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license'); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + await channelsPage.newChannel(pw.random.id(), 'O'); + + const channelSettingsModal = await channelsPage.openChannelSettings(); + const configurationTab = await channelSettingsModal.openConfigurationTab(); + + await configurationTab.enableChannelBanner(); + await configurationTab.setChannelBannerTextColor('77DD88'); + // 🫠 (U+1FAE0, Unicode 14.0) is above Mattermost's emoji map ceiling (1FAD6) + // so it falls through to the .emoticon--unicode span path. + await configurationTab.setChannelBannerText('Hello 🫠'); + await configurationTab.save(); + await channelSettingsModal.close(); + + await channelsPage.centerView.assertChannelBannerUnicodeEmojiSize(EMOJI_SIZE); +}); + +test('Should render text with descenders without clipping', async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + const license = await adminClient.getClientLicenseOld(); + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license'); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + await channelsPage.newChannel(pw.random.id(), 'O'); + + const channelSettingsModal = await channelsPage.openChannelSettings(); + const configurationTab = await channelSettingsModal.openConfigurationTab(); + + await configurationTab.enableChannelBanner(); + await configurationTab.setChannelBannerTextColor('77DD88'); + // Characters with descenders (parts that extend below the baseline). + // Previously clipped because line-height equalled font-size (13px), leaving + // no room below the baseline for g, j, p, q, y etc. + await configurationTab.setChannelBannerText('YyGgQqJj'); + await configurationTab.save(); + await channelSettingsModal.close(); + + await channelsPage.centerView.assertChannelBannerTextNotClipped(); +}); + test('Should render markdown', async ({pw}) => { const {adminUser, adminClient} = await pw.initSetup(); const license = await adminClient.getClientLicenseOld(); diff --git a/webapp/channels/src/components/channel_banner/style.scss b/webapp/channels/src/components/channel_banner/style.scss index 88a101bfae3..ede418cfaee 100644 --- a/webapp/channels/src/components/channel_banner/style.scss +++ b/webapp/channels/src/components/channel_banner/style.scss @@ -5,8 +5,9 @@ width: 100%; min-height: variables.$channel-banner-height; max-height: variables.$channel-banner-height; + align-items: center; justify-content: center; - padding-block: 5px; + padding-block: 2px; padding-inline: 24px; white-space: nowrap; @@ -15,7 +16,7 @@ overflow: hidden; max-width: 100%; font-size: 13px; - line-height: 13px; + line-height: 20px; text-align: center; text-overflow: ellipsis; @@ -24,10 +25,22 @@ text-decoration: underline; } + .emoticon { + width: 16px; + min-width: 16px; + height: 16px; + min-height: 16px; + } + + .emoticon--unicode { + font-size: 16px; + line-height: 20px; + } + .markdown__heading { overflow: hidden; margin: 2px; - font-size: 18px; + font-size: 16px; text-overflow: ellipsis; }