snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ServerChannel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {Page} from '@playwright/test';
import {expect, setupFileServer, test, testConfig} from '@mattermost/playwright-lib';
import type {ChannelsPage, PlaywrightClient4} from '@mattermost/playwright-lib';
import {watchPostListScroll, type PostListScrollWatcher} from './scroll_helpers';
test.describe('Post list initial scroll in read channel', () => {
let adminClient: PlaywrightClient4;
let adminUser: UserProfile;
let userClient: PlaywrightClient4;
let user: UserProfile;
let team: Team;
let channel: ServerChannel;
let channelsPage: ChannelsPage;
let page: Page;
let fileServerUrl: string;
setupFileServer().then((serverUrl) => {
fileServerUrl = serverUrl;
});
test.beforeEach(async ({pw}) => {
({adminClient, adminUser, team, user, userClient} = await pw.initSetup());
channel = await userClient.createChannel({
team_id: team.id,
name: `post-list-${pw.random.id()}`,
display_name: 'Post list scroll position',
type: 'O',
});
await adminClient.addToChannel(adminUser.id, channel.id);
({channelsPage, page} = await pw.testBrowser.login(user));
});
// These tests create a read channel by only having the current user post in the channel.
const testCases = [
{
name: 'empty channel',
setupPosts: async () => {
// # Delete the "User has joined the channel" posts to ensure that the channel is completely empty
const posts = await adminClient.getPosts(channel.id);
for (const postId of posts.order) {
await adminClient.deletePost(postId);
}
},
},
{
name: 'less than a screen of posts',
setupPosts: async () => {
for (let i = 0; i < 3; i++) {
await userClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with a page of text posts',
setupPosts: async () => {
for (let i = 0; i < 60; i++) {
await userClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with multiple pages of text posts',
setupPosts: async () => {
for (let i = 0; i < 120; i++) {
await userClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with multiple pages of long text posts',
setupPosts: async () => {
for (let i = 0; i < 120; i++) {
await userClient.createPost({
channel_id: channel.id,
message: new Array(100).fill(`this is a long post ${i}`).join('\n'),
});
}
},
},
{
name: 'with multiple pages of image attachments',
setupPosts: async () => {
for (let i = 0; i < 120; i++) {
await userClient.createTestPost(makeTestPost(i), ['mattermost.png']);
}
},
},
{
name: 'with multiple pages of markdown images',
setupPosts: async () => {
for (let i = 0; i < 120; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `![test image](${fileServerUrl}/mattermost.png)`,
});
}
},
},
{
name: 'with multiple pages of link previews',
setupPosts: async () => {
for (let i = 0; i < 120; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${fileServerUrl}/opengraph.html`,
});
}
},
},
{
name: 'with multiple pages of post previews',
setupPosts: async () => {
const firstPost = await userClient.createTestPost({
channel_id: channel.id,
});
for (let i = 0; i < 120; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${testConfig.internalBaseURL}/${team.name}/pl/${firstPost.id}`,
});
}
},
},
];
for (const testCase of testCases) {
test.describe(testCase.name, () => {
test.beforeEach(testCase.setupPosts);
test(`${testCase.name} - should stay at the bottom during initial load`, async ({}) => {
const watcher = await watchPostListScroll(page, channel.id);
// # Open the web app directly to that channel
await channelsPage.goto(team.name, channel.name);
// * Verify that the post list didn't scroll or change height
expect(await waitForScrollToSettle(watcher)).toHaveLength(1);
});
test(`${testCase.name} - should stay at the bottom when switching to the channel`, async ({}) => {
const watcher = await watchPostListScroll(page, channel.id);
// # Start in Town Square and wait for its contents to load
await channelsPage.goto(team.name, 'town-square');
await channelsPage.centerView.getLastPost();
// # Switch to the channel
await channelsPage.sidebarLeft.goToItem(channel.name);
// * Verify that the post list didn't scroll or change height
expect(await waitForScrollToSettle(watcher)).toHaveLength(1);
});
});
}
function makeTestPost(n: number) {
return {
channel_id: channel.id,
message: `message ${n}\nsecond line\nthird line`,
};
}
async function waitForScrollToSettle(watcher: PostListScrollWatcher) {
await channelsPage.centerView.toBeVisible();
// # Wait until the post list hasn't scrolled for 500ms before returning results
return watcher.waitForObservations(500);
}
});

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ServerChannel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {Page} from '@playwright/test';
import {expect, setupFileServer, test, testConfig} from '@mattermost/playwright-lib';
import type {ChannelsPage, PlaywrightClient4} from '@mattermost/playwright-lib';
import {watchPostListScroll, type PostListScrollWatcher} from './scroll_helpers';
test.describe('Post list initial scroll in unread channel', () => {
let adminClient: PlaywrightClient4;
let adminUser: UserProfile;
let userClient: PlaywrightClient4;
let user: UserProfile;
let team: Team;
let channel: ServerChannel;
let channelsPage: ChannelsPage;
let page: Page;
let fileServerUrl: string;
setupFileServer().then((serverUrl) => {
fileServerUrl = serverUrl;
});
test.beforeEach(async ({pw}) => {
({adminClient, adminUser, team, user, userClient} = await pw.initSetup());
channel = await userClient.createChannel({
team_id: team.id,
name: `post-list-${pw.random.id()}`,
display_name: 'Post list scroll position',
type: 'O',
});
await adminClient.addToChannel(adminUser.id, channel.id);
({channelsPage, page} = await pw.testBrowser.login(user));
});
// These tests create an unread channel by having the current user create some posts and then another user
// creates more.
const testCases = [
{
name: 'less than a screen of posts',
setupPosts: async () => {
// # Make a post as the current user first so that the New Messages line shows up
await userClient.createPost(makeTestPost(0));
for (let i = 0; i < 3; i++) {
await adminClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with a page of text posts',
setupPosts: async () => {
for (let i = 0; i < 20; i++) {
await userClient.createPost(makeTestPost(i));
}
for (let i = 0; i < 20; i++) {
await adminClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with multiple pages of text posts',
setupPosts: async () => {
for (let i = 0; i < 80; i++) {
await userClient.createPost(makeTestPost(i));
}
for (let i = 0; i < 80; i++) {
await adminClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with multiple pages of long text posts',
setupPosts: async () => {
for (let i = 0; i < 80; i++) {
await userClient.createPost({
channel_id: channel.id,
message: new Array(100).fill(`this is a long post ${i}`).join('\n'),
});
}
for (let i = 0; i < 80; i++) {
await adminClient.createPost({
channel_id: channel.id,
message: new Array(100).fill(`this is a long post ${i}`).join('\n'),
});
}
},
},
{
name: 'with multiple pages of image attachments',
setupPosts: async () => {
for (let i = 0; i < 80; i++) {
await userClient.createTestPost(makeTestPost(i), ['mattermost.png']);
}
for (let i = 0; i < 80; i++) {
await adminClient.createTestPost(makeTestPost(i), ['mattermost.png']);
}
},
},
{
name: 'with multiple pages of markdown images',
setupPosts: async () => {
for (let i = 0; i < 80; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `![test image](${fileServerUrl}/mattermost.png)`,
});
}
for (let i = 0; i < 80; i++) {
await adminClient.createTestPost({
channel_id: channel.id,
message: `![test image](${fileServerUrl}/mattermost.png)`,
});
}
},
},
{
name: 'with multiple pages of link previews',
setupPosts: async () => {
for (let i = 0; i < 80; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${fileServerUrl}/opengraph.html`,
});
}
for (let i = 0; i < 80; i++) {
await adminClient.createTestPost({
channel_id: channel.id,
message: `${fileServerUrl}/opengraph.html`,
});
}
},
},
{
name: 'with multiple pages of post previews',
setupPosts: async () => {
const linkedPost = await userClient.createTestPost({
channel_id: channel.id,
});
for (let i = 0; i < 80; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${testConfig.internalBaseURL}/${team.name}/pl/${linkedPost.id}`,
});
}
for (let i = 0; i < 80; i++) {
await adminClient.createTestPost({
channel_id: channel.id,
message: `${testConfig.internalBaseURL}/${team.name}/pl/${linkedPost.id}`,
});
}
},
},
];
for (const testCase of testCases) {
test.describe(testCase.name, () => {
test.beforeEach(testCase.setupPosts);
test(`${testCase.name} - should stay at the bottom during initial load`, async ({}) => {
const watcher = await watchPostListScroll(page, channel.id);
// # Open the web app directly to that channel
await channelsPage.goto(team.name, channel.name);
// * Verify that the New Messages line is actually visible
await expect(channelsPage.centerView.notificationSeparator).toBeVisible();
expect(await waitForScrollToSettle(watcher)).toHaveLength(1);
});
test(`${testCase.name} - should stay at the bottom when switching to the channel`, async ({}) => {
const watcher = await watchPostListScroll(page, channel.id);
// # Start in Town Square and wait for its contents to load
await channelsPage.goto(team.name, 'town-square');
await channelsPage.centerView.getLastPost();
// * Verify that the channel starts as unread
await channelsPage.sidebarLeft.assertItemUnread(channel.name);
// # Switch to the channel
await channelsPage.sidebarLeft.goToItem(channel.name);
// * Verify that the New Messages line is still visible
await expect(channelsPage.centerView.notificationSeparator).toBeVisible();
// * Verify that the post list didn't scroll or change height
expect(await waitForScrollToSettle(watcher)).toHaveLength(1);
});
});
}
function makeTestPost(n: number) {
return {
channel_id: channel.id,
message: `message ${n}\nsecond line\nthird line`,
};
}
async function waitForScrollToSettle(watcher: PostListScrollWatcher) {
await channelsPage.centerView.toBeVisible();
// # Wait until the post list hasn't scrolled for 500ms before returning results
return watcher.waitForObservations(500);
}
});

View File

@@ -0,0 +1,288 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ServerChannel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {Page} from '@playwright/test';
import type {Post} from '@mattermost/types/posts';
import {expect, setupFileServer, test, testConfig} from '@mattermost/playwright-lib';
import type {ChannelsPage, PlaywrightClient4} from '@mattermost/playwright-lib';
import {watchPostListScroll, type PostListScrollWatcher} from './scroll_helpers';
test.describe('Post list scroll to permalink', () => {
let adminClient: PlaywrightClient4;
let adminUser: UserProfile;
let userClient: PlaywrightClient4;
let user: UserProfile;
let team: Team;
let channel: ServerChannel;
let channelsPage: ChannelsPage;
let page: Page;
let fileServerUrl: string;
setupFileServer().then((serverUrl) => {
fileServerUrl = serverUrl;
});
test.beforeEach(async ({pw}) => {
({adminClient, adminUser, team, user, userClient} = await pw.initSetup());
channel = await userClient.createChannel({
team_id: team.id,
name: `post-list-${pw.random.id()}`,
display_name: 'Post list scroll position',
type: 'O',
});
await adminClient.addToChannel(adminUser.id, channel.id);
({channelsPage, page} = await pw.testBrowser.login(user));
});
const testCases = [
{
name: 'less than a screen of posts',
setupPostsBefore: async () => {
for (let i = 0; i < 2; i++) {
await userClient.createPost(makeTestPost(i));
}
},
setupPostsAfter: async () => {
for (let i = 2; i < 4; i++) {
await userClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with a page of text posts',
setupPostsBefore: async () => {
for (let i = 0; i < 30; i++) {
await userClient.createPost(makeTestPost(i));
}
},
setupPostsAfter: async () => {
for (let i = 30; i < 60; i++) {
await userClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with multiple pages of text posts',
setupPostsBefore: async () => {
for (let i = 0; i < 60; i++) {
await userClient.createPost(makeTestPost(i));
}
},
setupPostsAfter: async () => {
for (let i = 60; i < 120; i++) {
await userClient.createPost(makeTestPost(i));
}
},
},
{
name: 'with multiple pages of long text posts',
setupPostsBefore: async () => {
for (let i = 0; i < 60; i++) {
await userClient.createPost({
channel_id: channel.id,
message: new Array(100).fill(`this is a long post ${i}`).join('\n'),
});
}
},
setupPostsAfter: async () => {
for (let i = 60; i < 120; i++) {
await userClient.createPost({
channel_id: channel.id,
message: new Array(100).fill(`this is a long post ${i}`).join('\n'),
});
}
},
},
{
name: 'with multiple pages of image attachments',
setupPostsBefore: async () => {
for (let i = 0; i < 60; i++) {
await userClient.createTestPost(makeTestPost(i), ['mattermost.png']);
}
},
setupPostsAfter: async () => {
for (let i = 60; i < 120; i++) {
await userClient.createTestPost(makeTestPost(i), ['mattermost.png']);
}
},
},
{
name: 'with multiple pages of markdown images',
setupPostsBefore: async () => {
for (let i = 0; i < 60; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `![test image](${fileServerUrl}/mattermost.png)`,
});
}
},
setupPostsAfter: async () => {
for (let i = 60; i < 120; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `![test image](${fileServerUrl}/mattermost.png)`,
});
}
},
},
{
name: 'with multiple pages of link previews',
setupPostsBefore: async () => {
for (let i = 0; i < 60; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${fileServerUrl}/opengraph.html`,
});
}
},
setupPostsAfter: async () => {
for (let i = 60; i < 120; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${fileServerUrl}/opengraph.html`,
});
}
},
},
{
name: 'with multiple pages of post previews',
setupPostsBefore: async () => {
const firstPost = await userClient.createTestPost({
channel_id: channel.id,
});
for (let i = 0; i < 60; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${testConfig.internalBaseURL}/${team.name}/pl/${firstPost.id}`,
});
}
},
setupPostsAfter: async () => {
const firstPost = await userClient.createTestPost({
channel_id: channel.id,
});
for (let i = 60; i < 120; i++) {
await userClient.createTestPost({
channel_id: channel.id,
message: `${testConfig.internalBaseURL}/${team.name}/pl/${firstPost.id}`,
});
}
},
},
];
for (const testCase of testCases) {
test.describe(testCase.name, () => {
let linkedPost: Post;
let linkedPostUrl: string;
test.beforeEach(async () => {
await testCase.setupPostsBefore();
linkedPost = await userClient.createPost({
channel_id: channel.id,
message: 'linked post',
});
linkedPostUrl = `${testConfig.baseURL}/${team.name}/pl/${linkedPost.id}`;
await testCase.setupPostsAfter();
test.setTimeout(30000);
});
test(`${testCase.name} - should stay at the linked post during initial load`, async ({}) => {
const watcher = await watchPostListScroll(page, channel.id);
// # Open the web app directly to that post
await page.goto(linkedPostUrl);
// * Verify that the permalinked post is visible and highlighted
const linkedPostComponent = await channelsPage.centerView.getPostById(linkedPost.id);
await linkedPostComponent.toBeVisible();
await expect(linkedPostComponent.container).toHaveClass(/\bpost--highlight\b/);
// * Verify that the post list didn't scroll or change height
expect(await waitForScrollToSettle(watcher)).toHaveLength(1);
});
test(`${testCase.name} - should stay at the linked post when switching to the channel`, async ({}) => {
const watcher = await watchPostListScroll(page, channel.id);
// # Start in Town Square and wait for its contents to load
await channelsPage.goto(team.name, 'town-square');
await channelsPage.centerView.getLastPost();
// # Post a permalink pointing to the other channel
await channelsPage.centerView.postMessage(linkedPostUrl);
// # Switch to the channel by clicking on that link
await page.locator(`a[href="${linkedPostUrl}"]`).click();
// * Verify that the permalinked post is visible and highlighted
const linkedPostComponent = await channelsPage.centerView.getPostById(linkedPost.id);
await linkedPostComponent.toBeVisible();
await expect(linkedPostComponent.container).toHaveClass(/\bpost--highlight\b/);
// * Verify that the post list didn't scroll or change height
expect(await waitForScrollToSettle(watcher)).toHaveLength(1);
});
test(`${testCase.name} - should move to the linked post when clicking on an in-channel permalink`, async ({}) => {
const watcher = await watchPostListScroll(page, channel.id);
// # Open the web app directly to that channel
await channelsPage.goto(team.name, channel.name);
// # Post a permalink pointing to that post
await channelsPage.centerView.postMessage(linkedPostUrl);
// # Clear the observer so that it doesn't include the shifts due to making a post
await watcher.reset();
// # Click on that link
await page.locator(`a[href="${linkedPostUrl}"]`).click();
// * Verify that the permalinked post is visible and highlighted
const linkedPostComponent = await channelsPage.centerView.getPostById(linkedPost.id);
await linkedPostComponent.toBeVisible();
await expect(linkedPostComponent.container).toHaveClass(/\bpost--highlight\b/);
if (testCase.name.includes('less than a screen of posts')) {
// * Verify that the post list didn't move because there's not enough posts to scroll
expect(await waitForScrollToSettle(watcher)).toHaveLength(1);
} else {
// * Verify that the post list scrolled up without jumping around
expect(await waitForScrollToSettle(watcher)).toHaveLength(2);
}
});
});
}
// Helpers
function makeTestPost(n: number) {
return {
channel_id: channel.id,
message: `message ${n}\nsecond line\nthird line`,
};
}
async function waitForScrollToSettle(watcher: PostListScrollWatcher) {
await channelsPage.centerView.toBeVisible();
// # Wait until the post list hasn't scrolled for 500ms before returning results
return watcher.waitForObservations(500);
}
});

View File

@@ -0,0 +1,648 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {ServerChannel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {Disposable, Locator, Page} from '@playwright/test';
import type {Post} from '@mattermost/types/posts';
import {expect, setupFileServer, test, testConfig, watchElementSize} from '@mattermost/playwright-lib';
import type {ChannelsPage, ChannelsPost, PlaywrightClient4} from '@mattermost/playwright-lib';
test.describe('Post height', () => {
let userClient: PlaywrightClient4;
let user: UserProfile;
let team: Team;
let channel: ServerChannel;
let channelsPage: ChannelsPage;
let page: Page;
let fileServerUrl: string;
setupFileServer().then((serverUrl) => {
fileServerUrl = serverUrl;
});
test.beforeEach(async ({pw}) => {
let adminClient: Client4;
// # Initialize a user with an empty channel of its own
({userClient, user, team, adminClient} = await pw.initSetup());
channel = await userClient.createChannel({
team_id: team.id,
name: `post-list-${pw.random.id()}`,
display_name: 'Post List Layout Shift',
type: 'O',
});
// # Enable SVG rendering and let the server fetch metadata from the mock file server.
await adminClient.patchConfig({
ServiceSettings: {
EnableSVGs: true,
EnableLinkPreviews: true,
AllowedUntrustedInternalConnections: new URL(fileServerUrl).hostname,
},
});
// # Log in, but don't navigate to a channel yet
({channelsPage, page} = await pw.testBrowser.login(user));
});
type PostHeightTestCase = {
name: string;
/** Returns the post to be measured and does any other prep work needed to set up the post. */
makePost: (options: {fileServerUrl: string; siteUrl: string}) => Promise<Post>;
/** Extra assertions to run once the post has loaded. */
additionalCheck?: (args: {postComponent: ChannelsPost}) => Promise<void>;
/** Playwright project names for which this test case should be skipped. */
skipProjects?: string[];
};
const testCases: PostHeightTestCase[] = [
{
name: 'text only post',
makePost: () =>
seedPost({
message: 'text only post',
}),
},
{
name: 'post with replies',
makePost: () =>
seedPost({
message: 'post with replies',
replyCount: 3,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the thread footer has rendered
const image = postComponent.container.locator('.ThreadFooter');
await expect(image).toBeVisible();
},
},
{
name: 'post with reactions',
makePost: () =>
seedPost({
message: 'post with reactions',
reactions: ['thumbsup', 'heart', 'tada'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the reactions have rendered
const image = postComponent.container.locator('.Reaction');
await expect(image).toHaveCount(4);
},
},
{
name: 'post with a single image',
makePost: () =>
seedPost({
message: 'post with a single image',
files: ['mattermost.png'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a single small image',
makePost: () =>
seedPost({
message: 'post with a single small image',
files: ['small-image.png'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image has rendered
const image = postComponent.container.locator('.small-image__container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a single large image',
// MM-69979 Skip this on iPad because images that are too wide but above the minimum height cause layout shift
skipProjects: ['ipad'],
makePost: () =>
seedPost({
message: 'post with a single large image',
files: ['huge-image.jpg'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a single wide image',
makePost: () =>
seedPost({
message: 'post with a single wide image',
files: ['image-400x40.jpg'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image has rendered
const image = postComponent.container.locator('.small-image__container img');
await expect(image).toBeVisible();
},
},
{
name: 'post with a single tall image',
makePost: () =>
seedPost({
message: 'post with a single tall image',
files: ['image-40x400.jpg'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image has rendered
const image = postComponent.container.locator('.small-image__container img');
await expect(image).toBeVisible();
},
},
{
name: 'post with a non-image file attachment',
makePost: () =>
seedPost({
message: 'post with a non-image file attachment',
files: ['sample_text_file.txt'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the attachment has rendered
const image = postComponent.container.locator('.post-image__columns');
await expect(image).toBeVisible();
},
},
{
name: 'post with multiple images',
makePost: () =>
seedPost({
message: 'post with multiple images',
files: ['mattermost.png', 'mattermost-icon_128x128.png', 'mattermost.png'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the images have rendered
const image = postComponent.container.locator('.MediaGallery__tile img');
await expect(image).toHaveCount(3);
},
},
{
name: 'post with a code block without a language',
makePost: () =>
seedPost({
message: '```\nconst foo = 1;\nconst bar = 2;\n```',
}),
},
{
name: 'post with a syntax-highlighted code block',
makePost: () =>
seedPost({
message: '```javascript\nconst foo = 1;\nconst bar = 2;\n```',
}),
},
{
name: 'post with a message attachment',
// For some reason, the font size of the attachment title changes slightly on Firefox with MM Blocks
// and concurrent React enabled at the same time.
skipProjects: ['firefox'],
makePost: () =>
seedPost({
message: 'post with a message attachment',
props: {
attachments: [
{
author_name: 'Author',
title: 'Message attachment title',
title_link: 'https://example.com',
text: 'Message attachment body text',
},
],
},
}),
},
{
name: 'post with a single SVG attachment',
makePost: () =>
seedPost({
message: 'post with a single SVG attachment',
files: ['icon.svg'],
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the SVG has rendered as an image
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a Markdown image',
makePost: ({fileServerUrl}) =>
seedPost({
message: `![mattermost](${fileServerUrl}/mattermost.png)`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the Markdown image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a small Markdown image',
makePost: ({fileServerUrl}) =>
seedPost({
message: `![small image](${fileServerUrl}/small-image.png)`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the Markdown image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a large Markdown image',
// MM-69979 Images that are too wide but above the minimum height cause layout shift
skipProjects: ['chrome', 'firefox', 'ipad'],
makePost: ({fileServerUrl}) =>
seedPost({
message: `![large image](${fileServerUrl}/huge-image.jpg)`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the Markdown image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a wide Markdown image',
makePost: ({fileServerUrl}) =>
seedPost({
message: `![wide image](${fileServerUrl}/image-400x40.jpg)`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the Markdown image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a tall Markdown image',
makePost: ({fileServerUrl}) =>
seedPost({
message: `![tall image](${fileServerUrl}/image-40x400.jpg)`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the Markdown image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with an SVG Markdown image',
// As of MM-67372, the server no longer provides dimensions for external SVGs
skipProjects: ['chrome', 'firefox', 'ipad'],
makePost: ({fileServerUrl}) =>
seedPost({
message: `![icon](${fileServerUrl}/icon.svg)`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the Markdown image has rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with an image preview',
makePost: ({fileServerUrl}) =>
seedPost({
message: `${fileServerUrl}/mattermost.png`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image is rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a small image preview',
makePost: ({fileServerUrl}) =>
seedPost({
message: `${fileServerUrl}/small-image.png`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image is rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a large image preview',
// MM-69979 Images that are too wide but above the minimum height cause layout shift
skipProjects: ['chrome', 'firefox', 'ipad'],
makePost: ({fileServerUrl}) =>
seedPost({
message: `${fileServerUrl}/huge-image.jpg`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image is rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a wide image preview',
makePost: ({fileServerUrl}) =>
seedPost({
message: `${fileServerUrl}/image-400x40.jpg`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image is rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with a tall image preview',
makePost: ({fileServerUrl}) =>
seedPost({
message: `${fileServerUrl}/image-40x400.jpg`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that the image is rendered
const image = postComponent.container.locator('.image-loaded-container');
await expect(image).toBeVisible();
},
},
{
name: 'post with an OpenGraph preview',
makePost: ({fileServerUrl}) =>
seedPost({
message: `${fileServerUrl}/opengraph.html`,
}),
additionalCheck: async ({postComponent}) => {
// * Verify that an OpenGraph preview was rendered
const preview = postComponent.container.locator('.PostAttachmentOpenGraph');
await expect(preview).toBeVisible();
await expect(preview.locator('.sitename')).toHaveText('Mattermost Test');
await expect(preview.locator('.title')).toHaveText('OpenGraph Preview Title');
await expect(preview.locator('.description')).toHaveText(
'This is a test page to generate an OpenGraph link preview.',
);
await expect(preview.locator('.PostAttachmentOpenGraph__image img')).toBeVisible();
},
},
{
name: 'post with an OpenGraph preview with a larger image',
makePost: ({fileServerUrl}) =>
seedPost({
message: `${fileServerUrl}/opengraph-huge.html`,
}),
additionalCheck: async ({postComponent}) => {
const preview = postComponent.container.locator('.PostAttachmentOpenGraph');
await expect(preview).toBeVisible();
await expect(preview.locator('.sitename')).toHaveText('Mattermost Test');
await expect(preview.locator('.title')).toHaveText('OpenGraph Preview Title');
await expect(preview.locator('.description')).toHaveText('This is a test page with a large image.');
await expect(preview.locator('.PostAttachmentOpenGraph__image img')).toBeVisible();
},
},
{
name: 'post with a post preview',
makePost: async ({siteUrl}) => {
const linkedPost = await seedPost({
message: 'This is a post to be previewed.',
});
return seedPost({
message: `${siteUrl}/${team.name}/pl/${linkedPost.id}`,
});
},
},
{
name: 'post with a long post preview',
makePost: async ({siteUrl}) => {
const linkedPost = await seedPost({
message: new Array(50).fill('This is a multi-line post to be previewed.').join('\n'),
});
return seedPost({
message: `${siteUrl}/${team.name}/pl/${linkedPost.id}`,
});
},
additionalCheck: async ({postComponent}) => {
// * Verify that the preview is faded out and has the "Show more" link visible
const showMoreButton = postComponent.container.locator('.post-preview-collapse__show-more-button');
await expect(showMoreButton).toBeVisible();
await expect(postComponent.container.locator('.post-message-preview--overflow')).toBeVisible();
},
},
];
for (const testCase of testCases) {
test(
`post should keep a fixed height as it loads (${testCase.name})`,
{tag: '@post_list'},
async ({}, testInfo) => {
test.skip(
testCase.skipProjects?.includes(testInfo.project.name) ?? false,
`Not supported on ${testInfo.project.name}`,
);
const post = await testCase.makePost({
fileServerUrl,
siteUrl: testConfig.internalBaseURL,
});
const {sizeWatcher, postComponent} = await openChannelAndGetPost(post.id);
// # Wait for any images to load
await waitForImagesLoaded(postComponent.container);
// # Wait for any additional checks to occur
if (testCase.additionalCheck) {
await testCase.additionalCheck({postComponent});
}
// * Verify no height changes were detected
expect(await sizeWatcher.getObservations()).toHaveLength(1);
},
);
}
test("a post changes height when it's replied to for the first time", {tag: '@post_list'}, async () => {
// # Create a post with no replies or attachments
const post = await seedPost({
message: 'post without a reply yet',
});
const {sizeWatcher, postComponent} = await openChannelAndGetPost(post.id);
// * Verify no height changes were detected during initial rendering
expect(await sizeWatcher.getObservations()).toHaveLength(1);
// * Verify that the post has no replies
await postComponent.toBeVisible();
await expect(postComponent.threadFooter.container).not.toBeVisible();
// # Reply to the post from another client
await userClient.createPost({
channel_id: post.channel_id,
root_id: post.id,
message: 'reply',
});
// * Verify that the post now has the the thread footer
await postComponent.threadFooter.toBeVisible();
await postComponent.threadFooter.toHaveNReplies(1);
// * Verify that the post height changed size
expect(await sizeWatcher.getObservations()).toHaveLength(2);
// # Reply to the post again from another client
await userClient.createPost({
channel_id: post.channel_id,
root_id: post.id,
message: 'reply',
});
// * Verify that the thread footer has been updated
await postComponent.threadFooter.toHaveNReplies(2);
// * Verify that the post height changed size
expect(await sizeWatcher.getObservations()).toHaveLength(2);
});
test("a post may change height when it's edited", {tag: '@post_list'}, async () => {
// # Create a post with no replies or attachments
const post = await seedPost({
message: 'unedited post',
});
const {sizeWatcher, postComponent} = await openChannelAndGetPost(post.id);
// * Verify no height changes were detected during initial rendering
expect(await sizeWatcher.getObservations()).toHaveLength(1);
// * Verify the initial post text
await postComponent.toContainText('unedited post');
// # Edit the post from another client
await userClient.updatePost({
...post,
message: 'edited post',
});
// * Verify that the post text has changed
await postComponent.toContainText('edited post');
// * Verify that the post height didn't change
expect(await sizeWatcher.getObservations()).toHaveLength(1);
// # Edit the post to be multiple lines from another client
await userClient.updatePost({
...post,
message: 'edited post\nwith multiple lines',
});
// * Verify that the post text has changed
await postComponent.toContainText('edited post\nwith multiple lines');
// * Verify that the post height changed since it now takes up an extra line
expect(await sizeWatcher.getObservations()).toHaveLength(2);
});
// Helpers specific to these tests
/**
* Navigate to the seeded channel, wait for the target post to render, freeze animations so geometry is measured
* at rest, and start watching for height changes
*/
async function openChannelAndGetPost(postId: string) {
// # Initialize element size watcher
const sizeWatcher = await watchElementSize(page, `post_${postId}`);
// # Disable animations and transitions
await freezeAnimations(page);
// # Navigate to the channel
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Get and return the post component
const postComponent = await channelsPage.centerView.getPostById(postId);
await postComponent.toBeVisible();
// * Verify that the post element has been found by the height observer
expect(await sizeWatcher.isWatchingElement()).toBe(true);
return {sizeWatcher, postComponent};
}
type SeedOptions = {
message: string;
/** Asset filenames to upload and attach to the post. */
files?: string[];
/** Props to set on the post. */
props?: Record<string, unknown>;
/** Emoji names to react to the post with. */
reactions?: string[];
/** Number of replies to add under the post. */
replyCount?: number;
};
/** Create a post (with optional attachments, reactions, replies) and return its root post. */
async function seedPost(opts: SeedOptions) {
const root = await userClient.createTestPost(
{
channel_id: channel.id,
message: opts.message,
props: opts.props,
},
opts.files,
);
for (const emoji of opts.reactions ?? []) {
await userClient.addReaction(user.id, root.id, emoji);
}
for (let i = 0; i < (opts.replyCount ?? 0); i++) {
await userClient.createPost({
channel_id: channel.id,
root_id: root.id,
message: `reply ${i + 1}`,
});
}
return root;
}
});
/** Wait until every <img> within the locator has finished decoding. */
async function waitForImagesLoaded(locator: Locator): Promise<void> {
await expect
.poll(async () =>
locator
.locator('img')
.evaluateAll(
(imgs) =>
(imgs as HTMLImageElement[]).filter((img) => !(img.complete && img.naturalWidth > 0)).length,
),
)
.toBe(0);
}
/**
* Disable transitions/animations so we measure final geometry rather than a * frame captured mid-animation.
*/
async function freezeAnimations(page: Page): Promise<Disposable> {
return page.addInitScript(() => {
const style = document.createElement('style');
style.textContent = `*, *::before, *::after {
transition-duration: 0s !important;
transition-delay: 0s !important;
animation-duration: 0s !important;
animation-delay: 0s !important;
scroll-behavior: auto !important;
}`;
document.documentElement.appendChild(style);
});
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, type Page} from '@playwright/test';
export type ScrollObservation = {
distanceFromBottom: number | null;
clientHeight: number | null;
scrollTop: number | null;
scrollHeight: number | null;
containerTop: number | null;
separatorTop: number | null;
separatorViewportTop: number | null;
at: number;
};
export type PostListScrollWatcher = {
/** Clear recorded observations. */
reset: () => Promise<void>;
/** Waits until the scroll position settles before returning all observations. */
waitForObservations: (quietMs: number) => Promise<ScrollObservation[]>;
};
/**
* Installs a watcher that records the scroll position of the post list for the given channel.
*/
export async function watchPostListScroll(page: Page, channelId: string): Promise<PostListScrollWatcher> {
const SCROLL_WATCHER_KEY = 'postListScrollWatcher';
type WatcherState = {observations: ScrollObservation[]; lastKey: string};
await page.addInitScript(
([key, channelId]) => {
const state: WatcherState = {observations: [], lastKey: ''};
(window as unknown as Record<string, WatcherState>)[key] = state;
const sample = () => {
const container = document.querySelector(
`#postListContent[data-channel-id="${channelId}"] #postListScrollContainer`,
);
if (container) {
const containerRect = container.getBoundingClientRect();
const distanceFromBottom = container.scrollHeight - container.clientHeight - container.scrollTop;
const separator = document.querySelector('.NotificationSeparator');
const separatorViewportTop = separator ? separator.getBoundingClientRect().top : null;
const separatorTop =
separator && separatorViewportTop !== null ? separatorViewportTop - containerRect.top : null;
const observation = {
distanceFromBottom,
clientHeight: container.clientHeight,
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
containerTop: containerRect.top,
separatorTop,
separatorViewportTop,
at: performance.now(),
};
const dedupeKey = [
observation.distanceFromBottom,
observation.clientHeight,
observation.scrollTop,
observation.scrollHeight,
observation.containerTop,
observation.separatorTop,
].join('|');
if (dedupeKey !== state.lastKey) {
state.lastKey = dedupeKey;
state.observations.push(observation);
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
[SCROLL_WATCHER_KEY, channelId],
);
const getObservations = async () => {
return page.evaluate((key) => {
const state = (window as unknown as Record<string, WatcherState>)[key];
return state ? state.observations : [];
}, SCROLL_WATCHER_KEY);
};
const reset = async () => {
await page.evaluate((key) => {
const state = (window as unknown as Record<string, WatcherState>)[key];
if (state) {
state.observations = [];
state.lastKey = '';
}
}, SCROLL_WATCHER_KEY);
};
const waitForObservations = async (quietMs = 750) => {
await expect
.poll(
async () => {
const observations = await getObservations();
if (observations.length === 0) {
return false;
}
const now = await page.evaluate(() => Math.round(performance.now()));
return now - observations[observations.length - 1].at >= quietMs;
},
{timeout: 5000, intervals: [100, 200, 300]},
)
.toBe(true);
return getObservations();
};
return {reset, waitForObservations};
}