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
15 changes: 15 additions & 0 deletions e2e-tests/playwright/lib/src/ui/components/channels/post_create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ export default class ChannelsPostCreate {
await this.input.fill(message);
}

/**
* Types the message into the input one keystroke at a time, the way a user does, without sending it.
* Prefer this over writeMessage when the behaviour under test depends on the input changing more than
* once, such as the autocomplete, which debounces each change before searching the server.
* @param message : Message to be typed into the input
* @param options.delay : Milliseconds to wait between keystrokes. Use a delay longer than the
* autocomplete's debounce when a search per keystroke is wanted.
*/
async typeMessage(message: string, options?: {delay?: number}) {
await this.input.waitFor();
await expect(this.input).toBeVisible();

await this.input.pressSequentially(message, options);
}

/**
* Returns the value of the message input
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

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

/**
* @objective Verify that the forward-post channel selector normalizes search results, selects a destination, and
* forwards the post there.
*/
test('forward post channel selector searches and selects a destination channel', {tag: '@channels'}, async ({pw}) => {
const {team, user, adminClient} = await pw.initSetup();
const target = await adminClient.createPublicChannel(team.id, `Forward Target ${pw.random.id()}`);
await adminClient.addToChannel(user.id, target.id);

const message = `forward-selector-${pw.random.id()}`;
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await channelsPage.postMessage(message);

// # Open the Forward message modal from the posted message
const post = await channelsPage.getLastPost();
const postId = await post.getId();
await post.hover();
await post.postMenu.toBeVisible();
await post.postMenu.openDotMenu();
await channelsPage.postDotMenu.toBeVisible();
await channelsPage.postDotMenu.forwardMenuItem.click();

const modal = page.getByRole('dialog', {name: 'Forward message'});
await expect(modal).toBeVisible();

// # Search for a destination that was not recently viewed, exercising the provider normalization path.
// # react-select renders the combobox itself and drops the data-testid given to it, so find it by role.
const input = modal.getByRole('combobox');
await input.fill(target.display_name);

const option = page.getByRole('option').filter({hasText: target.display_name}).first();
await expect(option).toBeVisible();
await option.click();

// * Verify selecting the normalized channel result enables forwarding
const forwardButton = modal.getByRole('button', {name: 'Forward', exact: true});
await expect(forwardButton).toBeEnabled();

// # Forward the post and verify it arrives in the selected channel
await forwardButton.click();
await expect(modal).not.toBeVisible();

await channelsPage.goto(team.name, target.name);
await channelsPage.toBeVisible();

// * Verify the forwarded post links back to the original. Assert on the permalink rather than the
// * original message because the permalink preview that renders the message is only generated when the
// * link matches the server's SiteURL, which isn't the origin the browser uses in every environment.
await channelsPage.centerView.waitUntilLastPostContains(`/pl/${postId}`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import type {Locator, Page} from '@playwright/test';

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

const AUTOCOMPLETE_ROUTE = /\/api\/v4\/teams\/[^/]+\/channels\/autocomplete/;

function searchedName(url: string) {
return new URL(url).searchParams.get('name') ?? '';
}

async function typeMentionAndWaitForRequests(input: Locator, page: Page, message: string) {
let name = '';

for (const [index, character] of [...message].entries()) {
let request: Promise<unknown> | undefined;
if (index > 0) {
name += character;
request = page.waitForRequest(
(request) => AUTOCOMPLETE_ROUTE.test(request.url()) && searchedName(request.url()) === name,
);
}

await input.pressSequentially(character);
await request;
}
}

/**
* Holds every channel search until the test releases it, so that the autocomplete can be observed while a
* search is still in flight.
*/
async function holdChannelSearch(page: Page) {
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
});

await page.route(AUTOCOMPLETE_ROUTE, async (route) => {
await released;
await route.continue();
});

return release;
}

/**
* @objective Verify that the ~channel autocomplete renders the channels it already knows about while a
* search for more channels is still in flight, and preserves those results when the response adds a member.
*/
test(
'channel mention autocomplete shows local channels while searching for more',
{tag: ['@mentions']},
async ({pw}) => {
// # Initialize setup
const {team, user, adminClient} = await pw.initSetup();

// # Create a public channel that the user is a member of, so it comes from the local store
const localChannel = await adminClient.createChannel({
team_id: team.id,
name: 'ac-local-' + Date.now(),
display_name: 'AC Z Local',
type: 'O',
});
await adminClient.addToChannel(user.id, localChannel.id);

// # Create a private channel that the user is a member of. Private channels are not included in the
// # initial local public-channel results, but the server response adds them to the same channel list.
const privateChannel = await adminClient.createPrivateChannel(
team.id,
'AC A Private',
'ac-private-' + Date.now(),
);
await adminClient.addToChannel(user.id, privateChannel.id);

// # Create a channel that the user is not a member of, so it can only come from the search
await adminClient.createChannel({
team_id: team.id,
name: 'ac-remote-' + Date.now(),
display_name: 'AC Remote',
type: 'O',
});

// # Log in as regular user
const {channelsPage, page} = await pw.testBrowser.login(user);

// # Hold the channel search open so the initial local results can be observed independently of the response
const releaseSearch = await holdChannelSearch(page);

// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();

// # Type a channel mention matching both channels
const postCreate = channelsPage.centerView.postCreate;
await postCreate.typeMessage('~ac-');

// * Verify the channels the user is a member of are shown without waiting for the search
const suggestionList = postCreate.suggestionList;
const myChannels = suggestionList.getByRole('group', {name: 'My Channels'});
await expect(myChannels.getByRole('option')).toContainText(['AC Z Local']);

// * Verify the group of channels being searched for shows that it is still loading
const otherChannels = suggestionList.getByRole('group', {name: 'Other Channels'});
await expect(otherChannels.getByTestId('loadingSpinner')).toBeVisible();

// # Release the response and wait for it to be delivered
const searchResponse = page.waitForResponse(
(response) => AUTOCOMPLETE_ROUTE.test(response.url()) && searchedName(response.url()) === 'ac-',
);
releaseSearch();
await searchResponse;

// * Verify the searched channel is shown once the search finishes
await expect(otherChannels.getByRole('option')).toContainText(['AC Remote']);

// * Verify the member channel added by the response is paired with its own term, rather than mutating the
// * already-rendered local result. The order the two are rendered in is not part of what's under test.
const myChannelOptions = myChannels.getByRole('option');
await expect(myChannelOptions).toHaveCount(2);
await expect(myChannelOptions.filter({hasText: 'AC A Private'})).toHaveCount(1);
await expect(myChannelOptions.filter({hasText: 'AC Z Local'})).toHaveCount(1);
await expect(otherChannels.getByTestId('loadingSpinner')).not.toBeVisible();
},
);

/**
* @objective Verify that the ~channel autocomplete keeps showing results for what has been typed when an
* earlier, slower search finishes after a later one.
*/
test('channel mention autocomplete ignores a search that finishes out of order', {tag: ['@mentions']}, async ({pw}) => {
// # Initialize setup
const {team, user, adminClient} = await pw.initSetup();

// # Create channels that the user is not a member of, so they can only come from the search
await adminClient.createChannel({
team_id: team.id,
name: 'ac-alpha-' + Date.now(),
display_name: 'AC Alpha',
type: 'O',
});
await adminClient.createChannel({
team_id: team.id,
name: 'ac-beta-' + Date.now(),
display_name: 'AC Beta',
type: 'O',
});

// # Log in as regular user
const {channelsPage, page} = await pw.testBrowser.login(user);

// # Hold the shortest search while allowing the fully typed search to finish first
const staleSearchName = 'ac-';
let releaseStaleSearch!: () => void;
const staleSearchReleased = new Promise<void>((resolve) => {
releaseStaleSearch = resolve;
});

await page.route(AUTOCOMPLETE_ROUTE, async (route) => {
if (searchedName(route.request().url()) === staleSearchName) {
await staleSearchReleased;
}
await route.continue();
});

// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();

// # Note when the delayed search for the shortest mention finishes
const staleSearch = page.waitForResponse(
(response) => AUTOCOMPLETE_ROUTE.test(response.url()) && searchedName(response.url()) === staleSearchName,
);
const currentSearch = page.waitForResponse(
(response) => AUTOCOMPLETE_ROUTE.test(response.url()) && searchedName(response.url()) === 'ac-alpha',
);

// # Type a channel mention, waiting for each request so the stale response can be held deterministically
const postCreate = channelsPage.centerView.postCreate;
await typeMentionAndWaitForRequests(postCreate.input, page, '~ac-alpha');

// # Ensure the current search response is the one that populated the list before releasing the stale response
await currentSearch;

// * Verify only the channel matching what was typed is shown
const otherChannels = postCreate.suggestionList.getByRole('group', {name: 'Other Channels'});
await expect(otherChannels.getByRole('option')).toContainText(['AC Alpha']);
await expect(otherChannels.getByRole('option')).toHaveCount(1);

// # Release the stale response and wait for it to finish last
releaseStaleSearch();
await staleSearch;

// * Verify its results are ignored, since they no longer match what was typed
await expect(otherChannels.getByRole('option')).toHaveCount(1);
await expect(otherChannels.getByRole('option')).toContainText(['AC Alpha']);

// * Verify the mention can still be completed, so the suggestion list is still interactive
await otherChannels.getByRole('option').first().click();
await expect(postCreate.input).toHaveValue(/~ac-alpha/);
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ test(
await channelsPage.toBeVisible();

// # Type a channel mention in the message input to trigger autocomplete
await channelsPage.centerView.postCreate.writeMessage('~gamma');
await channelsPage.centerView.postCreate.typeMessage('~gamma');

// # Wait for the suggestion list to appear
const suggestionList = channelsPage.centerView.postCreate.suggestionList;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,42 @@ test('remove extra whitespace when selecting a user', async ({pw}) => {
const expectedText = `from:${admin.username} `;
await expect(searchInput).toHaveValue(expectedText);
});

/**
* @objective Verify that grouped search suggestions are trimmed to ten results and retain the selected channel's
* term/item pairing.
*/
test('limits grouped channel suggestions and preserves the selected channel', {tag: '@search'}, async ({pw}) => {
const {team, user, adminClient} = await pw.initSetup();
const prefix = `search-trim-${pw.random.id()}`;
const channels = [];

// The search endpoint returns up to 50 channels; the search UI trims the grouped results to 10.
for (let i = 0; i < 12; i++) {
const channel = await adminClient.createPublicChannel(team.id, `Search Trim ${i} ${prefix}`, `${prefix}-${i}`);
await adminClient.addToChannel(user.id, channel.id);
channels.push(channel);
}

const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();

await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.toBeVisible();
const {searchInput, container} = channelsPage.searchBox;
await searchInput.fill(`In:${prefix}`);

// * Verify trimResults applies across the grouped channel results
const suggestions = container.getByRole('option');
await expect(suggestions).toHaveCount(10);

// # Select a result that survived trimming and verify the term/item pairing updates the query correctly
const renderedText = (await suggestions.allInnerTexts()).join('\n');
const selectedChannel = channels.find((channel) => renderedText.includes(channel.display_name));
if (!selectedChannel) {
throw new Error('Expected at least one created channel to remain in the trimmed suggestions');
}
await suggestions.filter({hasText: selectedChannel.display_name}).first().click();
await expect(searchInput).toHaveValue(new RegExp(`In:${selectedChannel.name}\\s`));
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {expect, setWysiwygUserPreference, test, WysiwygEditor} from '@mattermost/playwright-lib';

const TAGS = {tag: ['@channels', '@wysiwyg_editor']};
const AUTOCOMPLETE_ROUTE = /\/api\/v4\/teams\/[^/]+\/channels\/autocomplete/;

test.describe('WYSIWYG editor - autocomplete suggestions', TAGS, () => {
test('slash command autocomplete opens and completes on Enter', async ({pw}) => {
Expand Down Expand Up @@ -64,6 +65,54 @@ test.describe('WYSIWYG editor - autocomplete suggestions', TAGS, () => {
await expect(editor.input).toContainText(`~${linked.name}`);
});

/**
* @objective Verify that WYSIWYG channel autocomplete renders the channels already known locally while a search
* for more channels is still in flight.
*
* The post_textbox equivalent goes on to verify that the search response is merged into what is already
* rendered. The WYSIWYG editor never applies that response — the searched group keeps its loading indicator
* indefinitely — so this test asserts only what the editor does today.
*/
test('~channel autocomplete shows local results while a search is in flight', async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
await setWysiwygUserPreference(userClient, user.id, true);

const localChannel = await adminClient.createPublicChannel(team.id, 'AC Z WYSIWYG Local', 'ac-wysiwyg-local');
await adminClient.addToChannel(user.id, localChannel.id);

const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'off-topic');

const editor = new WysiwygEditor(page.getByTestId('post-create'));
await editor.toBeVisible();

let releaseSearch!: () => void;
const searchReleased = new Promise<void>((resolve) => {
releaseSearch = resolve;
});
await page.route(AUTOCOMPLETE_ROUTE, async (route) => {
await searchReleased;
await route.continue();
});

await editor.type('~ac-');

const list = editor.suggestionList();
const myChannels = list.getByRole('group', {name: 'My Channels'});
const otherChannels = list.getByRole('group', {name: 'Other Channels'});

// * Verify the local public result is visible while the server response is still pending
await expect(myChannels.getByRole('option')).toContainText(['AC Z WYSIWYG Local']);

// * Verify the group of channels being searched for shows that it is still loading
await expect(otherChannels.getByTestId('loadingSpinner')).toBeVisible();

// # Let the held search finish so it isn't left blocked when the test ends
const searchResponse = page.waitForResponse((response) => AUTOCOMPLETE_ROUTE.test(response.url()));
releaseSearch();
await searchResponse;
});

test('emoji shortcode autocomplete opens and closes on Escape', async ({pw}) => {
const {user, userClient, team} = await pw.initSetup();
await setWysiwygUserPreference(userClient, user.id, true);
Expand Down
Loading
Loading