Skip to content

Commit 0ab46ee

Browse files
e2e(helpers): Playwright helpers for server views and desktop (#3887)
1 parent af0725c commit 0ab46ee

98 files changed

Lines changed: 6850 additions & 548 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

e2e/fixtures/index.ts

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {test as base, type Page} from '@playwright/test';
88
import type {ElectronApplication} from 'playwright';
99
import {_electron as electron} from 'playwright';
1010

11-
import {waitForAppReady} from '../helpers/appReadiness';
11+
import {waitForAppReady, waitForMainWindow, waitForMainWindowChrome} from '../helpers/appReadiness';
1212
import {electronBinaryPath, appDir, demoConfig, writeConfigFile, type AppConfig} from '../helpers/config';
1313
import {
1414
closeElectronApp,
@@ -39,9 +39,9 @@ type Fixtures = {
3939
electronApp: ElectronApplication;
4040

4141
/**
42-
* Side-effect fixture: waits until __e2eAppReady is true in the main process.
43-
* Both serverMap and mainWindow depend on this. Playwright deduplicates it —
44-
* waitForAppReady() runs exactly once even if both fixtures are requested.
42+
* Side-effect fixture: waits until __e2eAppReady is true, then (when config
43+
* lists servers) until the main-window server dropdown button is visible.
44+
* Both serverMap and mainWindow depend on this. Playwright deduplicates it.
4545
*/
4646
appReady: void;
4747

@@ -139,13 +139,18 @@ export const test = base.extend<Fixtures, WorkerFixtures>({
139139
await fs.rm(userDataDir, {recursive: true, force: true}).catch(() => {});
140140
},
141141

142-
appReady: async ({electronApp}, use) => {
142+
appReady: async ({electronApp, appConfig}, use) => {
143143
await waitForAppReady(electronApp);
144144

145145
// Setup path: the main process is freshly launched and responsive, so
146146
// the default 3s bound is ample for sub-100ms dropdown closes and still
147147
// fails fast if app.evaluate hangs. No larger setup timeout is needed.
148148
await closeOverlayWindowsIfOpen(electronApp);
149+
150+
if (appConfig.servers.length > 0) {
151+
await waitForMainWindowChrome(electronApp, {requireServerDropdown: true});
152+
}
153+
149154
await use();
150155
},
151156

@@ -157,31 +162,7 @@ export const test = base.extend<Fixtures, WorkerFixtures>({
157162

158163
// eslint-disable-next-line @typescript-eslint/no-unused-vars
159164
mainWindow: async ({electronApp, appReady: _appReady}, use) => {
160-
let win: Page | undefined;
161-
const timeoutAt = Date.now() + 30_000;
162-
163-
while (Date.now() < timeoutAt) {
164-
win = electronApp.windows().find((w) => {
165-
try {
166-
return w.url().includes('index');
167-
} catch {
168-
return false;
169-
}
170-
});
171-
172-
if (win) {
173-
break;
174-
}
175-
176-
await new Promise((resolve) => setTimeout(resolve, 200));
177-
}
178-
179-
if (!win) {
180-
throw new Error(
181-
'mainWindow fixture: no window with \'index\' in URL.\n' +
182-
`Available: ${electronApp.windows().map((w) => w.url()).join(', ')}`,
183-
);
184-
}
165+
const win = await waitForMainWindow(electronApp);
185166
await use(win);
186167
},
187168
});

e2e/helpers/appMetrics.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
/** Shape returned by Electron `app.getAppMetrics()` (subset used by tests). */
7+
export type AppProcessMetric = {
8+
pid: number;
9+
type: string;
10+
name?: string;
11+
};
12+
13+
export type AppProcessMetricsSummary = {
14+
metrics: AppProcessMetric[];
15+
totalCount: number;
16+
tabCount: number;
17+
nonTabCount: number;
18+
types: string[];
19+
pids: number[];
20+
};
21+
22+
/**
23+
* Upper bounds for non-Tab processes under the sandboxed E2E launch flags in
24+
* `e2e/fixtures/index.ts` (`--disable-gpu`, `--no-zygote`, etc.). These differ
25+
* from a normal Task Manager baseline on an end-user install.
26+
*/
27+
const NON_TAB_PROCESS_MAX: Partial<Record<NodeJS.Platform, number>> = {
28+
darwin: 10,
29+
linux: 10,
30+
win32: 12,
31+
};
32+
33+
const DEFAULT_NON_TAB_PROCESS_MAX = 12;
34+
35+
/** Tab/renderer processes for demoConfig (2 servers + main chrome). */
36+
const TAB_PROCESS_MAX = 30;
37+
38+
export async function getAppProcessMetrics(app: ElectronApplication): Promise<AppProcessMetric[]> {
39+
return app.evaluate(({app: electronApp}) => {
40+
return electronApp.getAppMetrics().map((metric) => ({
41+
pid: metric.pid,
42+
type: metric.type,
43+
name: metric.name,
44+
}));
45+
});
46+
}
47+
48+
export function summarizeAppProcessMetrics(metrics: AppProcessMetric[]): AppProcessMetricsSummary {
49+
const tabMetrics = metrics.filter((metric) => metric.type === 'Tab');
50+
const nonTabMetrics = metrics.filter((metric) => metric.type !== 'Tab');
51+
52+
return {
53+
metrics,
54+
totalCount: metrics.length,
55+
tabCount: tabMetrics.length,
56+
nonTabCount: nonTabMetrics.length,
57+
types: [...new Set(metrics.map((metric) => metric.type))].sort(),
58+
pids: metrics.map((metric) => metric.pid),
59+
};
60+
}
61+
62+
export function getNonTabProcessMax(): number {
63+
return NON_TAB_PROCESS_MAX[process.platform] ?? DEFAULT_NON_TAB_PROCESS_MAX;
64+
}
65+
66+
export function getTabProcessMax(): number {
67+
return TAB_PROCESS_MAX;
68+
}
69+
70+
export async function summarizeProcessMetrics(app: ElectronApplication): Promise<AppProcessMetricsSummary> {
71+
const metrics = await getAppProcessMetrics(app);
72+
return summarizeAppProcessMetrics(metrics);
73+
}

e2e/helpers/appReadiness.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,67 @@
22
// See LICENSE.txt for license information.
33

44
import {expect} from '@playwright/test';
5-
import type {ElectronApplication} from 'playwright';
5+
import type {ElectronApplication, Page} from 'playwright';
6+
7+
const MAIN_WINDOW_POLL_MS = 200;
8+
9+
export function findMainWindow(app: ElectronApplication): Page | undefined {
10+
return app.windows().find((window) => {
11+
try {
12+
return window.url().includes('index');
13+
} catch {
14+
return false;
15+
}
16+
});
17+
}
18+
19+
/** Resolve the internal main window (index.html wrapper). */
20+
export async function waitForMainWindow(
21+
app: ElectronApplication,
22+
options?: {timeout?: number},
23+
): Promise<Page> {
24+
const timeout = options?.timeout ?? 30_000;
25+
let mainWindow: Page | undefined;
26+
27+
await expect.poll(async () => {
28+
mainWindow = findMainWindow(app);
29+
return mainWindow;
30+
}, {
31+
timeout,
32+
intervals: [MAIN_WINDOW_POLL_MS, 500, 1000],
33+
message: 'Main window (index.html) must appear',
34+
}).not.toBeUndefined();
35+
36+
if (!mainWindow) {
37+
throw new Error(
38+
'Main window was not available.\n' +
39+
`Available: ${app.windows().map((window) => window.url()).join(', ')}`,
40+
);
41+
}
42+
43+
return mainWindow;
44+
}
45+
46+
/**
47+
* Wait until main-window chrome needed for server management is rendered.
48+
* Used when config already lists servers — catches broken wrapper UI that
49+
* __e2eAppReady alone would miss.
50+
*/
51+
export async function waitForMainWindowChrome(
52+
app: ElectronApplication,
53+
options?: {requireServerDropdown?: boolean; timeout?: number},
54+
): Promise<Page> {
55+
const timeout = options?.timeout ?? 30_000;
56+
const deadline = Date.now() + timeout;
57+
const mainWindow = await waitForMainWindow(app, {timeout});
58+
59+
if (options?.requireServerDropdown) {
60+
const remaining = Math.max(0, deadline - Date.now());
61+
await mainWindow.waitForSelector('.ServerDropdownButton', {timeout: remaining});
62+
}
63+
64+
return mainWindow;
65+
}
666

767
export async function waitForAppReady(app: ElectronApplication): Promise<void> {
868
const timeout = process.platform === 'linux' ? 30_000 : 60_000;

e2e/helpers/blockingOverlays.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import {findMainWindow} from './appReadiness';
5+
import {closeDownloadsDropdownIfOpen} from './downloadsDropdown';
6+
import {closeOverlayWindowsIfOpen} from './overlayWindows';
7+
import {activateServerView} from './serverContext';
8+
import type {ServerView} from './serverView';
9+
10+
/** Close desktop overlays that steal focus from server views (dropdowns, modals). */
11+
export async function dismissBlockingOverlays(win: ServerView): Promise<void> {
12+
await closeDownloadsDropdownIfOpen(win.app);
13+
await closeOverlayWindowsIfOpen(win.app);
14+
await activateServerView(win.app, win.webContentsId);
15+
const mainWindow = findMainWindow(win.app);
16+
await mainWindow?.keyboard.press('Escape').catch(() => undefined);
17+
await win.keyboard.press('Escape').catch(() => undefined);
18+
}

e2e/helpers/channelMenu.ts

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ export const COPY_LINK_SELECTORS = [
2828
* The webapp migrated from #channelHeaderDropdownButton to Menu.Button
2929
* with an aria-label like "off-topic channel menu".
3030
*/
31-
export async function openChannelHeaderMenu(win: ServerView): Promise<void> {
32-
await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: 15_000});
31+
export async function openChannelHeaderMenu(win: ServerView, timeout = 20_000): Promise<void> {
32+
const menuTimeout = Math.min(Math.max(Math.floor(timeout * 0.25), 500), 5_000);
33+
const triggerTimeout = Math.max(timeout - menuTimeout, 500);
34+
await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: triggerTimeout});
3335
await win.click(CHANNEL_HEADER_MENU_TRIGGER);
34-
await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: 5_000});
36+
await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: menuTimeout});
3537
}
3638

3739
const SIDEBAR_CHANNEL_MENU_BUTTON = (channelItemSelector: string) => [
@@ -117,34 +119,59 @@ export async function clickCopyLinkInMenu(win: ServerView): Promise<void> {
117119
* helper only toggles the preference — callers wait for bookmark items later.
118120
*/
119121
export async function enableBookmarksBar(win: ServerView): Promise<void> {
120-
const alreadyVisible = await win.runInRenderer(`
122+
const isBookmarksBarVisible = async (): Promise<boolean> => win.runInRenderer(`
121123
const container = document.querySelector('[data-testid="channel-bookmarks-container"]');
122124
if (!container) {
123125
return false;
124126
}
125127
const rect = container.getBoundingClientRect();
126128
return rect.width > 0 && rect.height > 0;
127129
`);
128-
if (alreadyVisible) {
130+
131+
if (await isBookmarksBarVisible()) {
129132
return;
130133
}
131134

132-
await openChannelHeaderMenu(win);
133-
const toggled = await win.runInRenderer(`
134-
const items = Array.from(document.querySelectorAll(
135-
'[role="menuitem"], .MenuItem, [id^="channel-menu-"]',
136-
));
137-
const barItem = items.find((item) => /bookmarks bar/i.test((item.textContent || '').trim()));
138-
if (!barItem) {
139-
return false;
135+
const deadline = Date.now() + 15_000;
136+
while (Date.now() < deadline) {
137+
const remaining = Math.max(deadline - Date.now(), 1_000);
138+
await openChannelHeaderMenu(win, remaining);
139+
140+
const toggleResult = await win.runInRenderer(`
141+
const submenuTrigger = document.querySelector('[id^="channel-menu-"][id$="-bookmarks"]');
142+
if (submenuTrigger instanceof HTMLElement) {
143+
submenuTrigger.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true}));
144+
submenuTrigger.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
145+
}
146+
147+
const items = Array.from(document.querySelectorAll(
148+
'[role="menuitem"], .MenuItem, [id^="channel-menu-"]',
149+
));
150+
const barItem = items.find((item) => {
151+
const text = (item.textContent || '').trim();
152+
return /bookmarks bar/i.test(text) && !/add a link|add bookmark|attach file/i.test(text);
153+
});
154+
if (!barItem) {
155+
// Modern webapp: bookmarks live under the Bookmarks submenu and the bar
156+
// autoshows once a bookmark exists — no separate Show/Hide toggle.
157+
return submenuTrigger ? 'submenu-only' : 'missing';
158+
}
159+
const label = (barItem.textContent || '').trim().toLowerCase();
160+
const checked = barItem.getAttribute('aria-checked');
161+
if (label.includes('hide') || checked === 'true') {
162+
return 'enabled';
163+
}
164+
barItem.click();
165+
return 'clicked';
166+
`, true);
167+
await win.keyboard.press('Escape').catch(() => undefined);
168+
if (toggleResult === 'enabled' || toggleResult === 'clicked' || toggleResult === 'submenu-only') {
169+
return;
140170
}
141-
barItem.click();
142-
return true;
143-
`, true);
144-
if (!toggled) {
145-
throw new Error('Bookmarks Bar menu item not found in channel header menu');
171+
await new Promise((resolve) => setTimeout(resolve, 300));
146172
}
147-
await win.keyboard.press('Escape');
173+
174+
throw new Error('Bookmarks Bar menu item not found in channel header menu');
148175
}
149176

150177
export const TEAM_SIDEBAR_BUTTON = [

0 commit comments

Comments
 (0)