Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/server-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
# - server-ci-report.yml
# - sentry.yaml
# If you rename this workflow, be sure to update those workflows as well.
name: Server CI

Check warning on line 6 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

6:1 [document-start] missing document start "---"

Check warning on line 6 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

6:1 [document-start] missing document start "---"
on:

Check warning on line 7 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

7:1 [truthy] truthy value should be one of [false, true]

Check warning on line 7 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

7:1 [truthy] truthy value should be one of [false, true]
push:
branches:
- master
Expand Down Expand Up @@ -36,14 +36,14 @@
gomod-changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 39 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

39:73 [comments] too few spaces before comment: expected 2

Check warning on line 39 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

39:73 [comments] too few spaces before comment: expected 2
- name: Calculate version
id: calculate
working-directory: server/
run: echo GO_VERSION=$(cat .go-version) >> "${GITHUB_OUTPUT}"
- name: Check for go.mod changes
id: changed-files
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5

Check warning on line 46 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

46:81 [comments] too few spaces before comment: expected 2

Check warning on line 46 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

46:81 [comments] too few spaces before comment: expected 2
with:
files: |
**/go.mod
Expand All @@ -57,7 +57,7 @@
working-directory: server
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 60 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

60:73 [comments] too few spaces before comment: expected 2

Check warning on line 60 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

60:73 [comments] too few spaces before comment: expected 2
- name: Run setup-go-work
run: make setup-go-work
- name: Generate mocks
Expand All @@ -74,7 +74,7 @@
working-directory: server
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 77 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

77:73 [comments] too few spaces before comment: expected 2

Check warning on line 77 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

77:73 [comments] too few spaces before comment: expected 2
- name: Run setup-go-work
run: make setup-go-work
- name: Run go mod tidy
Expand Down Expand Up @@ -247,6 +247,7 @@
artifact-pattern: postgres-server-test-logs-shard-*
artifact-name: postgres-server-test-logs
save-timing-cache: true
all-shards-passed: ${{ needs.test-postgres-normal.result == 'success' }}

test-elasticsearch-v8:
name: Elasticsearch v8 Compatibility
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/server-test-merge-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ on:
required: false
type: boolean
default: false
all-shards-passed:
description: "Whether every upstream shard succeeded. Used to gate the timing-cache save so a single shard failure doesn't poison the cache with missing-package data."
required: false
type: boolean
default: false

jobs:
merge:
Expand Down Expand Up @@ -79,11 +84,17 @@ jobs:
echo "has_timing=false" >> "$GITHUB_OUTPUT"
fi

# Only save when every upstream shard succeeded. If even one shard
# failed/was killed, its gotestsum.json is missing and the merged report
# has no timings for that shard's packages — saving that would poison
# future shard splits (missing packages default to 1ms, all bin-pack
# onto the lightest shard, overloading it and repeating the failure).
- name: Save test timing cache
if: inputs.save-timing-cache && steps.timing-prep.outputs.has_timing == 'true' && github.ref_name == github.event.repository.default_branch
if: inputs.save-timing-cache && inputs.all-shards-passed && steps.timing-prep.outputs.has_timing == 'true' && github.ref_name == github.event.repository.default_branch
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
server/prev-report.xml
server/prev-gotestsum.json
key: server-test-timing-master-${{ github.run_id }}
# The v2 prefix matches the v2 restore prefix in server-test-template.yml.
key: server-test-timing-v2-master-${{ github.run_id }}
10 changes: 8 additions & 2 deletions .github/workflows/server-test-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,15 @@ jobs:
server/prev-gotestsum.json
# Always restore from master — timing is only saved on the default
# branch and is stable enough for shard balancing.
key: server-test-timing-master
# NOTE: the v2 prefix invalidates pre-existing caches that were
# poisoned by shard failures (a killed shard loses its gotestsum.json,
# so the merged report was missing those packages' timings; on the
# next run they all defaulted to 1ms and bin-packed onto the lightest
# shard, overloading it and perpetuating the cycle). See also the
# all-shards-passed guard in server-test-merge-template.yml.
key: server-test-timing-v2-master
restore-keys: |
server-test-timing-
server-test-timing-v2-

- name: Setup BUILD_IMAGE
id: build
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ docker-compose.override.yaml
.notice-work/
.aider*
.env
.envrc
.planning/

**/CLAUDE.local.md
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ describe('Environment', () => {

cy.get('#TestS3Connection').scrollIntoView().should('be.visible').within(() => {
cy.findByText('Test Connection').should('be.visible').click().wait(TIMEOUTS.ONE_SEC);
waitForAlert('Connection unsuccessful: Unable to connect to S3. Verify your Amazon S3 connection authorization parameters and authentication settings.');
waitForAlert('Connection unsuccessful: Unable to authenticate against the file storage backend. Verify your credentials and authentication settings.');
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import type {UserProfile} from '@mattermost/types/users';
import {Locator, expect} from '@playwright/test';

export default class DirectChannelsModal {
readonly container;

readonly goButton;
readonly results;
readonly searchInput;

constructor(container: Locator) {
this.container = container;

this.goButton = container.getByRole('button', {name: 'Go'});
this.results = container.locator('.more-modal__list');
this.searchInput = container.getByRole('combobox', {name: 'Search for people'});
}

async toBeVisible() {
await expect(this.container).toBeVisible();
}

async selectUser(user: UserProfile) {
await this.fillSearchInput(user.username);

// This may fail if there's too many group channels containing the provided user
const row = this.results
.locator('.more-modal__row:not(:has(.more-modal__gm-icon))')
.getByText(`@${user.username}`, {exact: false});

await row.click();

await expect(this.container.getByRole('button', {name: `Remove ${user.username}`})).toBeVisible();
}

async toHaveNUsersSelected(count: number) {
await expect(this.results.locator('.react-select_multi-value')).toHaveCount(count);
}

async goToChannel() {
await this.goButton.click();

await expect(this.container).not.toBeAttached();
}

async toHaveNResults(count: number) {
await expect(this.results.locator('.more-modal__row')).toHaveCount(count);
}

async fillSearchInput(text: string) {
await this.searchInput.fill(text);
}

async toHaveUserAsNthResult(user: UserProfile, index: number) {
const row = this.results.locator('.more-modal__row').nth(index);

await expect(row).toContainText(`@${user.username}`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default class ChannelsSidebarLeft {
readonly findChannelButton;
readonly scheduledPostBadge;
readonly unreadChannelFilter;
readonly openDirectMessageButton;

constructor(container: Locator) {
this.container = container;
Expand All @@ -20,6 +21,7 @@ export default class ChannelsSidebarLeft {
this.findChannelButton = container.getByRole('button', {name: 'Find Channels'});
this.scheduledPostBadge = container.locator('span.scheduledPostBadge');
this.unreadChannelFilter = container.locator('.SidebarFilters_filterButton');
this.openDirectMessageButton = container.getByRole('button', {name: 'Write a direct message'});
}

async toBeVisible() {
Expand Down
3 changes: 3 additions & 0 deletions e2e-tests/playwright/lib/src/ui/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import ChannelsSidebarRight from './channels/sidebar_right';
import DeletePostConfirmationDialog from './channels/delete_post_confirmation_dialog';
import DeletePostModal from './channels/delete_post_modal';
import DeleteScheduledPostModal from './channels/delete_scheduled_post_modal';
import DirectChannelsModal from './channels/direct_channels_modal';
import DraftPost from './channels/draft_post';
import EmojiGifPicker from './channels/emoji_gif_picker';
import FindChannelsModal from './channels/find_channels_modal';
Expand Down Expand Up @@ -89,6 +90,7 @@ const components = {
DeletePostConfirmationDialog,
DeletePostModal,
DeleteScheduledPostModal,
DirectChannelsModal,
DraftPost,
EmojiGifPicker,
FindChannelsModal,
Expand Down Expand Up @@ -172,6 +174,7 @@ export {
FlagPostConfirmationDialog,
NewChannelModal,
BrowseChannelsModal,
DirectChannelsModal,
GenericConfirmModal,
InvitePeopleModal,
MembersInvitedModal,
Expand Down
11 changes: 11 additions & 0 deletions e2e-tests/playwright/lib/src/ui/pages/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default class ChannelsPage {
readonly findChannelsModal;
readonly newChannelModal;
readonly browseChannelsModal;
readonly directChannelsModal;
public invitePeopleModal: InvitePeopleModal | undefined;
public membersInvitedModal: MembersInvitedModal | undefined;
readonly profileModal;
Expand Down Expand Up @@ -77,6 +78,9 @@ export default class ChannelsPage {
this.findChannelsModal = new components.FindChannelsModal(page.getByRole('dialog', {name: 'Find Channels'}));
this.newChannelModal = new NewChannelModal(page.getByRole('dialog', {name: 'Create a new channel'}));
this.browseChannelsModal = new BrowseChannelsModal(page.getByRole('dialog', {name: 'Browse Channels'}));
this.directChannelsModal = new components.DirectChannelsModal(
page.getByRole('dialog', {name: 'Direct Messages'}),
);
this.profileModal = new components.ProfileModal(page.getByRole('dialog', {name: 'Profile'}));
this.settingsModal = new components.SettingsModal(page.getByRole('dialog', {name: 'Settings'}));
this.teamSettingsModal = new components.TeamSettingsModal(page.getByRole('dialog', {name: 'Team Settings'}));
Expand Down Expand Up @@ -242,6 +246,13 @@ export default class ChannelsPage {
return this.browseChannelsModal;
}

async openDirectChannelsModal() {
await this.sidebarLeft.openDirectMessageButton.click();
await this.directChannelsModal.toBeVisible();

return this.directChannelsModal;
}

async openCreateTeamForm(): Promise<CreateTeamForm> {
await this.sidebarLeft.teamMenuButton.click();
await this.teamMenu.toBeVisible();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import type {Page} from '@playwright/test';

import {expect, test} from '@mattermost/playwright-lib';

/**
* @objective Verify that a group message whose channel has fallen out of the sidebar (because the user
* has more DMs/GMs than the configured "Number of direct messages to show" limit) still appears in the
* Direct Messages modal with its members fully loaded — i.e. with a non-zero member count and the
* participant usernames as its name.
*/
test(
"MM-65058 Direct Messages modal should load group members for GMs which haven't been loaded otherwise",
{tag: '@direct_messages'},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup({withDefaultProfileImage: false});

// Use a lower visible DM limit than the UI normally lets you use to speed up this test
const totalGms = 2;
const visibleLimit = 1;

// # Limit the user's visible DMs/GMs in the sidebar so one GM falls off the sidebar
await userClient.savePreferences(user.id, [
{
user_id: user.id,
category: 'sidebar_settings',
name: 'limit_visible_dms_gms',
value: visibleLimit.toString(),
},
]);

// # Create enough users to populate 11 GMs with unique users
const users = [];
for (let i = 0; i < totalGms * 2; i++) {
const user = await pw.createNewUserProfile(adminClient, {prefix: `mm65058gm${i}`});
users.push(user);
}

// # Log the user in and open the channels page
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();

// # Create 11 GMs using the Direct Channels modal
const gmChannels = [];
for (let i = 0; i < totalGms; i++) {
const memberA = users[i * 2];
const memberB = users[i * 2 + 1];

// # Open the modal
const dialog = await channelsPage.openDirectChannelsModal();

// # Select the users and create the channel
await dialog.selectUser(memberA);
await dialog.selectUser(memberB);
await dialog.goToChannel();

// # Make a post in the channel to ensure that it has a last_post_at value
await channelsPage.postMessage(`gm message ${i}`);

// # Save the channel's information for later
gmChannels.push({
channel: await getCurrentChannel(page),
members: [memberA, memberB],
});
}

const targetGm = gmChannels[0];
const otherGms = gmChannels.slice(1);

// # Refresh the app and go back to Town Square
await channelsPage.goto(team.name, 'town-square');

// * Verify the target GM is not present in the sidebar to ensure that the sidebar hasn't loaded it
await expect(page.locator(`#sidebarItem_${targetGm.channel.name}`)).toHaveCount(0);

// * Wait until the other GMs are loaded and present in the sidebar
for (const otherGm of otherGms) {
const otherGmEntry = page.locator(`#sidebarItem_${otherGm.channel.name}`);

await expect(otherGmEntry).toHaveCount(1);
await expect(otherGmEntry).toContainText(gmChannelDisplayName(otherGm.members));
}

// * Verify that the members of the target GM haven't been loaded and the members of other GMs have
await assertChannelUsersNotLoaded(page, targetGm.channel.id);
for (const otherGm of otherGms) {
await assertChannelUsersLoaded(page, otherGm.channel.id, otherGm.members);
}

// # Open the Direct Messages modal again
const dialog = await channelsPage.openDirectChannelsModal();

// # Wait for the list to populate
const rows = dialog.container.locator('#multiSelectList .more-modal__row');
await expect.poll(async () => rows.count()).toBeGreaterThanOrEqual(totalGms);

// * Verify the modal contains an entry for every GM the user has, including the one that fell
// * out of the sidebar
for (const {channel, members} of gmChannels) {
// Each GM row renders the member usernames joined by ', '. We use the second member's
// username (which is unique per GM) to locate the corresponding row.
const usernameMarker = `@${members[1].username}`;
const gmRow = rows.filter({hasText: usernameMarker});

// * Verify the row is rendered
await expect(gmRow, `expected to find a row in the DM modal for GM ${channel.id}`).toHaveCount(1);

// * Verify the GM icon shows the correct member count (channel members minus current user)
await expect(
gmRow.locator('.more-modal__gm-icon'),
`expected GM ${channel.id} to show a member count of ${members.length}`,
).toHaveText(members.length.toString());

// * Verify the row's name section includes every participant's username
const nameContainer = gmRow.locator('.more-modal__name');
for (const participant of members) {
await expect(
nameContainer,
`expected GM ${channel.id} to include @${participant.username} in its name`,
).toContainText(`@${participant.username}`);
}
}

// * Double check that the members of the target GM have been loaded now
await assertChannelUsersLoaded(page, targetGm.channel.id, targetGm.members);
},
);

async function getCurrentChannel(page: Page) {
return await page.evaluate<Channel>(
'store.getState().entities.channels.channels[store.getState().entities.channels.currentChannelId]',
);
}

function gmChannelDisplayName(users: UserProfile[]) {
return users
.toSorted((a, b) => {
return a.username.localeCompare(b.username, undefined, {numeric: true});
})
.map((user) => user.username)
.join(', ');
}

async function assertChannelUsersLoaded(page: Page, channelId: string, expectedUsers: UserProfile[]) {
// profilesInChannel contains Sets which aren't serializable for return from page.evaluate
const loadedIds = await page.evaluate(
`Array.from(store.getState().entities.users.profilesInChannel['${channelId}'])`,
);

await expect(loadedIds).toHaveLength(expectedUsers.length);
await expect(loadedIds).toEqual(expect.arrayContaining(expectedUsers.map((user) => user.id)));
}

async function assertChannelUsersNotLoaded(page: Page, channelId: string) {
const loadedIds = await page.evaluate(`store.getState().entities.users.profilesInChannel['${channelId}']`);

await expect(loadedIds).toBeUndefined();
}
4 changes: 2 additions & 2 deletions server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.4
PLUGIN_PACKAGES += mattermost-plugin-github-v2.7.1
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.2
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.7.0
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.1
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.9.0
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.13.0
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.0.3
Expand All @@ -178,7 +178,7 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0
# download the package from to work. This will no longer be needed when we unify
# the way we pre-package FIPS and non-FIPS plugins.
ifeq ($(FIPS_ENABLED),true)
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.8.1%2Bac0a223-fips
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.9.0%2Bdfb5b30-fips
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.0.3%2Bcab391a-fips
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4%2B5855fe1-fips
endif
Expand Down
Loading
Loading