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
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:
@@ -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: ``,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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: ``,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await adminClient.createTestPost({
|
||||
channel_id: channel.id,
|
||||
message: ``,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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: ``,
|
||||
});
|
||||
}
|
||||
},
|
||||
setupPostsAfter: async () => {
|
||||
for (let i = 60; i < 120; i++) {
|
||||
await userClient.createTestPost({
|
||||
channel_id: channel.id,
|
||||
message: ``,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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: ``,
|
||||
}),
|
||||
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: ``,
|
||||
}),
|
||||
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: ``,
|
||||
}),
|
||||
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: ``,
|
||||
}),
|
||||
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: ``,
|
||||
}),
|
||||
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: ``,
|
||||
}),
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
Reference in New Issue
Block a user