Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3cd4202
e2e(ci): enable public links before Playwright and improve flaky-test…
yasserfaraazkhan Jul 7, 2026
57c3771
fix(e2e): patch only public link settings instead of full config.
yasserfaraazkhan Jul 7, 2026
7374cae
refactor(e2e): convert enable-public-links script to TypeScript.
yasserfaraazkhan Jul 7, 2026
d2cbfad
chore(e2e): remove enable-public-links.mjs after TypeScript conversion.
yasserfaraazkhan Jul 7, 2026
e5244b8
fix(e2e): reuse shared API client and satisfy lint in enable-public-l…
yasserfaraazkhan Jul 7, 2026
73496d6
e2e(hooks): expose tray menu actions for Playwright and fix history n…
yasserfaraazkhan Jul 7, 2026
edd38a1
e2e(helpers): shared Playwright helpers for server views, menus, and …
yasserfaraazkhan Jul 7, 2026
2a229dc
fix(e2e): patch only public link settings in test helper.
yasserfaraazkhan Jul 7, 2026
c5ee149
Merge remote-tracking branch 'origin/master' into stack/e2e-03-helpers
yasserfaraazkhan Jul 7, 2026
fa76ac9
fix(e2e): address review findings in Playwright helpers.
yasserfaraazkhan Jul 7, 2026
faf8abd
refactor(e2e): apply remaining helper review nitpicks.
yasserfaraazkhan Jul 7, 2026
19ee0c6
e2e(specs): startup, menu bar, tray, and notification Playwright spec…
yasserfaraazkhan Jul 8, 2026
6e371fd
test(e2e): address PR review findings in UI-focused Playwright specs.
yasserfaraazkhan Jul 8, 2026
be44a81
fix(e2e): remove unused expect import in tray_icon_theme spec.
yasserfaraazkhan Jul 8, 2026
aa6d814
fix(e2e): harden launchEmptyApp cleanup and welcome screen wait.
yasserfaraazkhan Jul 8, 2026
1cc8b9e
test(e2e): remaining Playwright specs for downloads, mattermost, and …
yasserfaraazkhan Jul 8, 2026
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
41 changes: 11 additions & 30 deletions e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {test as base, type Page} from '@playwright/test';
import type {ElectronApplication} from 'playwright';
import {_electron as electron} from 'playwright';

import {waitForAppReady} from '../helpers/appReadiness';
import {waitForAppReady, waitForMainWindow, waitForMainWindowChrome} from '../helpers/appReadiness';
import {electronBinaryPath, appDir, demoConfig, writeConfigFile, type AppConfig} from '../helpers/config';
import {
closeElectronApp,
Expand Down Expand Up @@ -39,9 +39,9 @@ type Fixtures = {
electronApp: ElectronApplication;

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

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

appReady: async ({electronApp}, use) => {
appReady: async ({electronApp, appConfig}, use) => {
await waitForAppReady(electronApp);

// Setup path: the main process is freshly launched and responsive, so
// the default 3s bound is ample for sub-100ms dropdown closes and still
// fails fast if app.evaluate hangs. No larger setup timeout is needed.
await closeOverlayWindowsIfOpen(electronApp);

if (appConfig.servers.length > 0) {
await waitForMainWindowChrome(electronApp, {requireServerDropdown: true});
}

await use();
},

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

// eslint-disable-next-line @typescript-eslint/no-unused-vars
mainWindow: async ({electronApp, appReady: _appReady}, use) => {
let win: Page | undefined;
const timeoutAt = Date.now() + 30_000;

while (Date.now() < timeoutAt) {
win = electronApp.windows().find((w) => {
try {
return w.url().includes('index');
} catch {
return false;
}
});

if (win) {
break;
}

await new Promise((resolve) => setTimeout(resolve, 200));
}

if (!win) {
throw new Error(
'mainWindow fixture: no window with \'index\' in URL.\n' +
`Available: ${electronApp.windows().map((w) => w.url()).join(', ')}`,
);
}
const win = await waitForMainWindow(electronApp);
await use(win);
},
});
Expand Down
73 changes: 73 additions & 0 deletions e2e/helpers/appMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import type {ElectronApplication} from 'playwright';

/** Shape returned by Electron `app.getAppMetrics()` (subset used by tests). */
export type AppProcessMetric = {
pid: number;
type: string;
name?: string;
};

export type AppProcessMetricsSummary = {
metrics: AppProcessMetric[];
totalCount: number;
tabCount: number;
nonTabCount: number;
types: string[];
pids: number[];
};

/**
* Upper bounds for non-Tab processes under the sandboxed E2E launch flags in
* `e2e/fixtures/index.ts` (`--disable-gpu`, `--no-zygote`, etc.). These differ
* from a normal Task Manager baseline on an end-user install.
*/
const NON_TAB_PROCESS_MAX: Partial<Record<NodeJS.Platform, number>> = {
darwin: 10,
linux: 10,
win32: 12,
};

const DEFAULT_NON_TAB_PROCESS_MAX = 12;

/** Tab/renderer processes for demoConfig (2 servers + main chrome). */
const TAB_PROCESS_MAX = 30;

export async function getAppProcessMetrics(app: ElectronApplication): Promise<AppProcessMetric[]> {
return app.evaluate(({app: electronApp}) => {
return electronApp.getAppMetrics().map((metric) => ({
pid: metric.pid,
type: metric.type,
name: metric.name,
}));
});
}

export function summarizeAppProcessMetrics(metrics: AppProcessMetric[]): AppProcessMetricsSummary {
const tabMetrics = metrics.filter((metric) => metric.type === 'Tab');
const nonTabMetrics = metrics.filter((metric) => metric.type !== 'Tab');

return {
metrics,
totalCount: metrics.length,
tabCount: tabMetrics.length,
nonTabCount: nonTabMetrics.length,
types: [...new Set(metrics.map((metric) => metric.type))].sort(),
pids: metrics.map((metric) => metric.pid),
};
}

export function getNonTabProcessMax(): number {
return NON_TAB_PROCESS_MAX[process.platform] ?? DEFAULT_NON_TAB_PROCESS_MAX;
}

export function getTabProcessMax(): number {
return TAB_PROCESS_MAX;
}

export async function summarizeProcessMetrics(app: ElectronApplication): Promise<AppProcessMetricsSummary> {
const metrics = await getAppProcessMetrics(app);
return summarizeAppProcessMetrics(metrics);
}
62 changes: 61 additions & 1 deletion e2e/helpers/appReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,67 @@
// See LICENSE.txt for license information.

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

const MAIN_WINDOW_POLL_MS = 200;

export function findMainWindow(app: ElectronApplication): Page | undefined {
return app.windows().find((window) => {
try {
return window.url().includes('index');
} catch {
return false;
}
});
}

/** Resolve the internal main window (index.html wrapper). */
export async function waitForMainWindow(
app: ElectronApplication,
options?: {timeout?: number},
): Promise<Page> {
const timeout = options?.timeout ?? 30_000;
let mainWindow: Page | undefined;

await expect.poll(async () => {
mainWindow = findMainWindow(app);
return mainWindow;
}, {
timeout,
intervals: [MAIN_WINDOW_POLL_MS, 500, 1000],
message: 'Main window (index.html) must appear',
}).not.toBeUndefined();

if (!mainWindow) {
throw new Error(
'Main window was not available.\n' +
`Available: ${app.windows().map((window) => window.url()).join(', ')}`,
);
}

return mainWindow;
}

/**
* Wait until main-window chrome needed for server management is rendered.
* Used when config already lists servers — catches broken wrapper UI that
* __e2eAppReady alone would miss.
*/
export async function waitForMainWindowChrome(
app: ElectronApplication,
options?: {requireServerDropdown?: boolean; timeout?: number},
): Promise<Page> {
const timeout = options?.timeout ?? 30_000;
const deadline = Date.now() + timeout;
const mainWindow = await waitForMainWindow(app, {timeout});

if (options?.requireServerDropdown) {
const remaining = Math.max(0, deadline - Date.now());
await mainWindow.waitForSelector('.ServerDropdownButton', {timeout: remaining});
}

return mainWindow;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function waitForAppReady(app: ElectronApplication): Promise<void> {
const timeout = process.platform === 'linux' ? 30_000 : 60_000;
Expand Down
18 changes: 18 additions & 0 deletions e2e/helpers/blockingOverlays.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {findMainWindow} from './appReadiness';
import {closeDownloadsDropdownIfOpen} from './downloadsDropdown';
import {closeOverlayWindowsIfOpen} from './overlayWindows';
import {activateServerView} from './serverContext';
import type {ServerView} from './serverView';

/** Close desktop overlays that steal focus from server views (dropdowns, modals). */
export async function dismissBlockingOverlays(win: ServerView): Promise<void> {
await closeDownloadsDropdownIfOpen(win.app);
await closeOverlayWindowsIfOpen(win.app);
await activateServerView(win.app, win.webContentsId);
const mainWindow = findMainWindow(win.app);
await mainWindow?.keyboard.press('Escape').catch(() => undefined);
await win.keyboard.press('Escape').catch(() => undefined);
}
65 changes: 46 additions & 19 deletions e2e/helpers/channelMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ export const COPY_LINK_SELECTORS = [
* The webapp migrated from #channelHeaderDropdownButton to Menu.Button
* with an aria-label like "off-topic channel menu".
*/
export async function openChannelHeaderMenu(win: ServerView): Promise<void> {
await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: 15_000});
export async function openChannelHeaderMenu(win: ServerView, timeout = 20_000): Promise<void> {
const menuTimeout = Math.min(Math.max(Math.floor(timeout * 0.25), 500), 5_000);
const triggerTimeout = Math.max(timeout - menuTimeout, 500);
Comment on lines +32 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why these timeouts has to be computed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@saturninoabril this was mostly an AI suggestion while I was fixing repeated CI flakiness around the channel header menu, especially in enableBookmarksBar.

The idea is timeout should mean total wait for the helper, not per step. Earlier we had fixed 15s for the trigger + 5s for the menu. When enableBookmarksBar retries and passes remaining, each attempt could still burn the full 15s + 5s. So even with a 15s outer deadline, one retry could overshoot, eat into the test timeout, and fail in a confusing way.

With the split, if you pass 20s, trigger gets ~15s and menu gets ~5s — together ~20s, not 40s. That way we stay within the budget and it’s clearer whether it failed waiting for the trigger or for the menu to open.

await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: triggerTimeout});
await win.click(CHANNEL_HEADER_MENU_TRIGGER);
await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: 5_000});
await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: menuTimeout});
}

const SIDEBAR_CHANNEL_MENU_BUTTON = (channelItemSelector: string) => [
Expand Down Expand Up @@ -117,34 +119,59 @@ export async function clickCopyLinkInMenu(win: ServerView): Promise<void> {
* helper only toggles the preference — callers wait for bookmark items later.
*/
export async function enableBookmarksBar(win: ServerView): Promise<void> {
const alreadyVisible = await win.runInRenderer(`
const isBookmarksBarVisible = async (): Promise<boolean> => win.runInRenderer(`
const container = document.querySelector('[data-testid="channel-bookmarks-container"]');
if (!container) {
return false;
}
const rect = container.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
`);
if (alreadyVisible) {

if (await isBookmarksBarVisible()) {
return;
}

await openChannelHeaderMenu(win);
const toggled = await win.runInRenderer(`
const items = Array.from(document.querySelectorAll(
'[role="menuitem"], .MenuItem, [id^="channel-menu-"]',
));
const barItem = items.find((item) => /bookmarks bar/i.test((item.textContent || '').trim()));
if (!barItem) {
return false;
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const remaining = Math.max(deadline - Date.now(), 1_000);
await openChannelHeaderMenu(win, remaining);

const toggleResult = await win.runInRenderer(`
const submenuTrigger = document.querySelector('[id^="channel-menu-"][id$="-bookmarks"]');
if (submenuTrigger instanceof HTMLElement) {
submenuTrigger.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true}));
submenuTrigger.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
}

const items = Array.from(document.querySelectorAll(
'[role="menuitem"], .MenuItem, [id^="channel-menu-"]',
));
const barItem = items.find((item) => {
const text = (item.textContent || '').trim();
return /bookmarks bar/i.test(text) && !/add a link|add bookmark|attach file/i.test(text);
});
if (!barItem) {
// Modern webapp: bookmarks live under the Bookmarks submenu and the bar
// autoshows once a bookmark exists — no separate Show/Hide toggle.
return submenuTrigger ? 'submenu-only' : 'missing';
}
const label = (barItem.textContent || '').trim().toLowerCase();
const checked = barItem.getAttribute('aria-checked');
if (label.includes('hide') || checked === 'true') {
return 'enabled';
}
barItem.click();
return 'clicked';
`, true);
await win.keyboard.press('Escape').catch(() => undefined);
if (toggleResult === 'enabled' || toggleResult === 'clicked' || toggleResult === 'submenu-only') {
return;
}
barItem.click();
return true;
`, true);
if (!toggled) {
throw new Error('Bookmarks Bar menu item not found in channel header menu');
await new Promise((resolve) => setTimeout(resolve, 300));
}
await win.keyboard.press('Escape');

throw new Error('Bookmarks Bar menu item not found in channel header menu');
}

export const TEAM_SIDEBAR_BUTTON = [
Expand Down
Loading
Loading