MM-68259 Fixing text and emojis being clipped in channel banners (#36076)

* Fixing text and emojis being clipped in channel banners

- Added E2E tests that confirm that emojis respect the correct size and aren't clipping through container

* Applying PR feedback

* Running prettier on playwright tests

* Fixing E2E Tests

* Added test case for descending characters

* Fixing linter issues

* Resolving issue with e2e test failing
This commit is contained in:
Andre Vasconcelos
2026-04-21 09:33:10 +03:00
committed by GitHub
parent 9c8191c3b8
commit 39d25d94dd
5 changed files with 199 additions and 5 deletions
+11
View File
@@ -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;
+1
View File
@@ -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,
@@ -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);
}
}
@@ -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();
@@ -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;
}