Skip to content

Commit 5967e04

Browse files
test(e2e): remaining Playwright specs for downloads, mattermost, and permissions.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ba72a87 commit 5967e04

42 files changed

Lines changed: 2858 additions & 162 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import type {ElectronApplication} from 'playwright';
5+
6+
import {test, expect} from '../../fixtures/index';
7+
import {demoMattermostConfig, mattermostURL, type AppConfig} from '../../helpers/config';
8+
import {loginToMattermost} from '../../helpers/login';
9+
import {
10+
POST_TEXTBOX_SELECTOR,
11+
typeIntoPostTextbox,
12+
pressPostTextboxKey,
13+
waitForMattermostShellReady,
14+
} from '../../helpers/mattermostShell';
15+
import {prepareMattermostServerView} from '../../helpers/prepareServerView';
16+
import {
17+
getShellOpenExternalCalls,
18+
restoreShellOpenExternal,
19+
stubShellOpenExternal,
20+
} from '../../helpers/shell';
21+
import {buildServerMap} from '../../helpers/serverMap';
22+
import type {ServerView} from '../../helpers/serverView';
23+
24+
// ── MM-T1430: Cross-server permalink (MM-19919) ────────────────────────
25+
// Clicking a permalink to a post on server A while viewing server B must
26+
// switch to server A and navigate to the post in-app — never opening an
27+
// external browser or a new Electron BrowserWindow.
28+
29+
const SERVER_A_NAME = 'serverA';
30+
const SERVER_B_NAME = 'serverB';
31+
32+
function alternateMattermostURL(): string {
33+
const url = new URL(mattermostURL);
34+
if (url.hostname === 'localhost') {
35+
url.hostname = '127.0.0.1';
36+
return url.toString();
37+
}
38+
if (url.hostname === '127.0.0.1') {
39+
url.hostname = 'localhost';
40+
return url.toString();
41+
}
42+
43+
// Trailing-slash variants normalize to the same URL; use a distinct path so
44+
// ServerManager keeps two entries for the same Mattermost instance.
45+
if (url.pathname === '/' || url.pathname === '') {
46+
return `${url.origin}/login`;
47+
}
48+
return `${url.origin}/`;
49+
}
50+
51+
const crossServerConfig: AppConfig = {
52+
...demoMattermostConfig,
53+
servers: [
54+
{name: SERVER_A_NAME, url: mattermostURL, order: 0},
55+
{name: SERVER_B_NAME, url: alternateMattermostURL(), order: 1},
56+
],
57+
lastActiveServer: 0,
58+
};
59+
60+
async function switchToServer(app: ElectronApplication, serverName: string) {
61+
await app.evaluate((_, targetServerName) => {
62+
const refs = (global as any).__e2eTestRefs;
63+
const server = refs?.ServerManager?.getAllServers?.().find((candidate: {name: string}) => candidate.name === targetServerName);
64+
if (!server) {
65+
throw new Error(`Server not found: ${targetServerName}`);
66+
}
67+
refs.ServerManager.updateCurrentServer(server.id);
68+
}, serverName);
69+
}
70+
71+
async function getCurrentServerName(app: ElectronApplication): Promise<string> {
72+
return app.evaluate(() => {
73+
const refs = (global as any).__e2eTestRefs;
74+
const currentServerId = refs?.ServerManager?.getCurrentServerId?.();
75+
const server = currentServerId ? refs?.ServerManager?.getServer?.(currentServerId) : undefined;
76+
return server?.name ?? '';
77+
});
78+
}
79+
80+
async function getActiveTabUrl(app: ElectronApplication, serverName: string): Promise<string | null> {
81+
return app.evaluate(({webContents}, targetServerName) => {
82+
const refs = (global as any).__e2eTestRefs;
83+
const server = refs?.ServerManager?.getAllServers?.().find((candidate: {name: string}) => candidate.name === targetServerName);
84+
if (!server) {
85+
return null;
86+
}
87+
88+
const activeTab = refs.TabManager.getCurrentTabForServer(server.id);
89+
if (!activeTab) {
90+
return null;
91+
}
92+
93+
const webContentsView = refs.WebContentsManager.getView(activeTab.id);
94+
if (!webContentsView) {
95+
return null;
96+
}
97+
98+
const wc = webContents.fromId(webContentsView.webContentsId);
99+
return wc?.getURL() ?? null;
100+
}, serverName);
101+
}
102+
103+
async function waitForServerPermalinkNavigation(
104+
app: ElectronApplication,
105+
serverName: string,
106+
permalinkMessage: string,
107+
): Promise<string> {
108+
let matchedUrl = '';
109+
await expect.poll(async () => {
110+
const activeUrl = await getActiveTabUrl(app, serverName);
111+
if (activeUrl?.includes('/pl/')) {
112+
matchedUrl = activeUrl;
113+
return true;
114+
}
115+
116+
const map = await buildServerMap(app);
117+
for (const entry of map[serverName] ?? []) {
118+
const onPermalink = await entry.win.evaluate((needle) => {
119+
return window.location.pathname.includes('/pl/') &&
120+
(document.body.textContent ?? '').includes(needle);
121+
}, permalinkMessage);
122+
if (onPermalink) {
123+
matchedUrl = await entry.win.url();
124+
return true;
125+
}
126+
}
127+
return false;
128+
}, {timeout: 45_000, message: 'Server must navigate to the permalink post'}).toBe(true);
129+
130+
return matchedUrl;
131+
}
132+
133+
async function openTownSquare(serverWin: ServerView): Promise<void> {
134+
await waitForMattermostShellReady(serverWin, {channelItem: '#sidebarItem_town-square'});
135+
const onTownSquare = await serverWin.runInRenderer<boolean>(`
136+
const item = document.querySelector('#sidebarItem_town-square');
137+
return Boolean(
138+
item?.classList.contains('active')
139+
|| item?.classList.contains('active-link')
140+
|| item?.getAttribute('aria-current') === 'page',
141+
);
142+
`);
143+
if (!onTownSquare) {
144+
await serverWin.click('#sidebarItem_town-square');
145+
}
146+
await serverWin.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 30_000});
147+
}
148+
149+
async function clickPostedPermalink(serverWin: ServerView, permalinkUrl: string, useWindowOpen: boolean): Promise<void> {
150+
await expect.poll(async () => serverWin.evaluate((targetUrl) => {
151+
const posts = Array.from(document.querySelectorAll('[id^="post_"]'));
152+
for (let index = posts.length - 1; index >= 0; index--) {
153+
const post = posts[index];
154+
if (!(post.textContent ?? '').includes(targetUrl)) {
155+
continue;
156+
}
157+
return Boolean(post.querySelector('a[href*="/pl/"]'));
158+
}
159+
return false;
160+
}, permalinkUrl), {timeout: 15_000, message: 'Posted permalink must render as a link'}).toBe(true);
161+
162+
await serverWin.evaluate(({targetUrl, openInNewWindow}) => {
163+
const posts = Array.from(document.querySelectorAll('[id^="post_"]'));
164+
for (let index = posts.length - 1; index >= 0; index--) {
165+
const post = posts[index];
166+
if (!(post.textContent ?? '').includes(targetUrl)) {
167+
continue;
168+
}
169+
170+
const link = post.querySelector('a[href*="/pl/"]') as HTMLAnchorElement | null;
171+
if (!link) {
172+
continue;
173+
}
174+
175+
const href = new URL(link.getAttribute('href') ?? link.href, window.location.href).href;
176+
if (openInNewWindow) {
177+
window.open(href, '_blank');
178+
} else {
179+
link.click();
180+
}
181+
return;
182+
}
183+
184+
window.open(targetUrl, '_blank');
185+
}, {targetUrl: permalinkUrl, openInNewWindow: useWindowOpen});
186+
}
187+
188+
async function postMessageInChannel(serverWin: ServerView, message: string): Promise<void> {
189+
await typeIntoPostTextbox(serverWin, message);
190+
191+
const sent = await serverWin.evaluate(() => {
192+
const sendButton = document.querySelector(
193+
'#channelHeaderSubmitButton, button[aria-label*="Send" i], [data-testid="SendMessageButton"]',
194+
) as HTMLButtonElement | null;
195+
if (!sendButton) {
196+
return false;
197+
}
198+
sendButton.click();
199+
return true;
200+
});
201+
if (!sent) {
202+
await pressPostTextboxKey(serverWin, 'Enter');
203+
}
204+
}
205+
206+
async function postMessageAndCapturePermalink(serverWin: ServerView, message: string): Promise<string> {
207+
await postMessageInChannel(serverWin, message);
208+
209+
await expect.poll(
210+
() => serverWin.evaluate((needle) => {
211+
const posts = Array.from(document.querySelectorAll('[id^="post_"], .post'));
212+
return posts.some((post) => (post.textContent ?? '').includes(needle));
213+
}, message),
214+
{timeout: 15_000, message: 'Posted message must appear in the channel'},
215+
).toBe(true);
216+
217+
const permalink = await serverWin.evaluate((needle) => {
218+
const posts = Array.from(document.querySelectorAll('[id^="post_"]'));
219+
for (const post of posts) {
220+
const text = post.textContent ?? '';
221+
if (!text.includes(needle)) {
222+
continue;
223+
}
224+
225+
post.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
226+
const link = post.querySelector('.post__permalink a, a[href*="/pl/"]') as HTMLAnchorElement | null;
227+
const href = link?.href ?? link?.getAttribute('href') ?? '';
228+
if (href.includes('/pl/')) {
229+
return href;
230+
}
231+
232+
const postId = post.id.replace(/^post_/, '');
233+
const team = window.location.pathname.split('/').filter(Boolean)[0];
234+
if (postId && team) {
235+
return `${window.location.origin}/${team}/pl/${postId}`;
236+
}
237+
}
238+
return '';
239+
}, message) as string;
240+
241+
expect(permalink, 'Permalink href must be available on the posted message').toBeTruthy();
242+
expect(permalink).toMatch(/\/pl\//);
243+
return permalink;
244+
}
245+
246+
test.describe('deep_linking/cross_server_permalink', () => {
247+
test.describe.configure({mode: 'serial'});
248+
test.use({appConfig: crossServerConfig});
249+
test.setTimeout(180_000);
250+
251+
test(
252+
'MM-T1430 cross-server permalink switches server tabs in-app',
253+
{tag: ['@P2', '@all']},
254+
async ({electronApp}) => {
255+
if (!process.env.MM_TEST_SERVER_URL) {
256+
test.skip(true, 'MM_TEST_SERVER_URL required');
257+
return;
258+
}
259+
260+
let serverMap!: Awaited<ReturnType<typeof buildServerMap>>;
261+
await expect.poll(async () => {
262+
serverMap = await buildServerMap(electronApp);
263+
return Boolean(serverMap[SERVER_A_NAME]?.[0] && serverMap[SERVER_B_NAME]?.[0]);
264+
}, {timeout: 30_000, message: 'Both server views must be registered'}).toBe(true);
265+
266+
const serverA = serverMap[SERVER_A_NAME]![0].win;
267+
const serverB = serverMap[SERVER_B_NAME]![0].win;
268+
269+
const windowsBefore = await electronApp.evaluate(({BrowserWindow}) => {
270+
return BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()).length;
271+
});
272+
273+
await stubShellOpenExternal(electronApp);
274+
275+
try {
276+
await prepareMattermostServerView(electronApp, serverMap[SERVER_A_NAME]![0].webContentsId);
277+
await loginToMattermost(serverA!);
278+
await openTownSquare(serverA!);
279+
280+
const permalinkMessage = `MM-T1430 permalink ${Date.now()}`;
281+
const permalinkUrl = await postMessageAndCapturePermalink(serverA!, permalinkMessage);
282+
283+
await switchToServer(electronApp, SERVER_B_NAME);
284+
await prepareMattermostServerView(electronApp, serverMap[SERVER_B_NAME]![0].webContentsId);
285+
await loginToMattermost(serverB!);
286+
await openTownSquare(serverB!);
287+
288+
await postMessageInChannel(serverB!, permalinkUrl);
289+
290+
const sameHost = new URL(mattermostURL).hostname === new URL(alternateMattermostURL()).hostname;
291+
await clickPostedPermalink(serverB!, permalinkUrl, sameHost);
292+
293+
await expect.poll(
294+
() => getCurrentServerName(electronApp),
295+
{timeout: 15_000, message: 'Permalink click must activate server A'},
296+
).toBe(SERVER_A_NAME);
297+
298+
const permalinkDestination = await waitForServerPermalinkNavigation(
299+
electronApp,
300+
SERVER_A_NAME,
301+
permalinkMessage,
302+
);
303+
expect(permalinkDestination).toMatch(/\/pl\//);
304+
305+
expect(await getShellOpenExternalCalls(electronApp)).toHaveLength(0);
306+
expect(
307+
await electronApp.evaluate(({BrowserWindow}) => {
308+
return BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()).length;
309+
}),
310+
'Permalink must not open a new BrowserWindow',
311+
).toBe(windowsBefore);
312+
} finally {
313+
await restoreShellOpenExternal(electronApp);
314+
}
315+
},
316+
);
317+
});

e2e/specs/deep_linking/deeplink_running.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {loginToMattermost} from '../../helpers/login';
1010
test.use({appConfig: demoMattermostConfig});
1111

1212
test(
13-
'deep link navigates to correct server while app is running',
13+
'MM-T6127 deep link navigates to correct server while app is running',
1414
{tag: ['@P1', '@darwin', '@win32']},
1515
async ({electronApp, serverMap}) => {
1616
if (!process.env.MM_TEST_SERVER_URL) {
@@ -49,7 +49,7 @@ test.describe('deep link server URL without trailing slash', () => {
4949
test.use({appConfig: configWithoutTrailingSlash});
5050

5151
test(
52-
'DL-01 deep link navigates when configured server URL has no trailing slash',
52+
'MM-T6128 deep link navigates when configured server URL has no trailing slash',
5353
{tag: ['@P1', '@darwin', '@win32']},
5454
async ({electronApp, serverMap}) => {
5555
if (!process.env.MM_TEST_SERVER_URL) {

e2e/specs/deep_linking/oauth_callback.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {demoConfig} from '../../helpers/config';
66
import {mattermostDeepLinkUrl, openDeepLinkInApp, waitForServerUrlAndDropdown} from '../../helpers/deeplink';
77

88
test(
9-
'DL-03 OAuth callback deep link navigates the active server view',
9+
'MM-T6129 OAuth callback deep link navigates the active server view',
1010
{tag: ['@P1', '@all']},
1111
async ({electronApp, mainWindow}) => {
1212
const serverName = demoConfig.servers[0].name;

e2e/specs/downloads/download_cancel.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '../../helpers/downloads';
1616

1717
test(
18-
'DL-06 in-progress download can be cancelled from the downloads dropdown menu',
18+
'MM-T6130 in-progress download can be cancelled from the downloads dropdown menu',
1919
{tag: ['@P1', '@all']},
2020
async ({}, testInfo) => {
2121
const filename = 'slow-cancel.txt';

e2e/specs/downloads/download_clear_all.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const secondFile = {
2525
};
2626

2727
test(
28-
'DL-07 clear all removes every completed download from the dropdown',
28+
'MM-T6131 clear all removes every completed download from the dropdown',
2929
{tag: ['@P1', '@all']},
3030
async ({}, testInfo) => {
3131
const userDataDir = path.join(testInfo.outputDir, 'userdata');

e2e/specs/downloads/download_completion.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ async function startDownloadServer(filename: string, contents: string) {
5656
}
5757

5858
test(
59-
'downloaded file exists on disk after download completes',
59+
'MM-T6132 downloaded file exists on disk after download completes',
6060
{tag: ['@P1', '@all']},
6161
async ({}, testInfo) => {
6262
const filename = 'downloaded-file.txt';

e2e/specs/downloads/download_open.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from '../../helpers/downloads';
1515

1616
test(
17-
'DL-05 completed download can be opened from the downloads dropdown',
17+
'MM-T6133 completed download can be opened from the downloads dropdown',
1818
{tag: ['@P1', '@all']},
1919
async ({}, testInfo) => {
2020
const filename = 'open-me.txt';

0 commit comments

Comments
 (0)