Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
db20b56
E2E: Playwright harness, fixtures, and CI wiring (1/10).
yasserfaraazkhan Jun 19, 2026
f46ebbc
E2E: Main-process test hooks and shared launch helpers (2/10).
yasserfaraazkhan Jun 19, 2026
8f76529
E2E: Server management, bad servers, certificate trust (6/10).
yasserfaraazkhan Jun 19, 2026
b41357d
Merge origin/master into e2e/06-server-management
yasserfaraazkhan Jun 30, 2026
4b68d19
Address CodeRabbit review on server management E2E specs
yasserfaraazkhan Jun 30, 2026
984cd72
E2E: Popout, drag-drop, startup, and Linux specs (7/10). (#3861)
yasserfaraazkhan Jul 1, 2026
dfda040
E2E: Tray, settings, and deep linking (#3864)
yasserfaraazkhan Jul 1, 2026
10c57c0
Address CodeRabbit review on deep linking, settings, and tray E2E specs.
yasserfaraazkhan Jul 1, 2026
9630cc9
fix(e2e): add missing mattermostShell helper for drag_and_drop tests
yasserfaraazkhan Jul 1, 2026
6bee0fe
fix(e2e): harden mattermostShell textbox helpers
yasserfaraazkhan Jul 1, 2026
ba7b5db
fix(e2e): await ServerView.url() and harden copy link test
yasserfaraazkhan Jul 1, 2026
c9e1022
fix code gaps
yasserfaraazkhan Jul 1, 2026
44a0ae0
fix(e2e): fix CI failures in tray_icon_hide and bad_servers specs
yasserfaraazkhan Jul 1, 2026
fb818e2
fix(e2e): fix mkdir bug in tray_icon_hide relaunch, retry dropdown click
yasserfaraazkhan Jul 1, 2026
84e0523
fix(e2e): address outstanding CodeRabbit review comments
yasserfaraazkhan Jul 1, 2026
a0ed0a5
fix(e2e): revert popout predicate removal, add bringToFront for dropdown
yasserfaraazkhan Jul 1, 2026
e66524c
fix(e2e): stop re-clicking the server dropdown toggle button on timeout
yasserfaraazkhan Jul 1, 2026
ff9b9ab
diag(e2e): instrument the server dropdown open failure instead of gue…
yasserfaraazkhan Jul 1, 2026
37a72d1
fix(e2e): fix the real root cause of the server dropdown open failure
yasserfaraazkhan Jul 1, 2026
4e906c1
fix(e2e): remove overly-broad beforeEach that destroyed server dropdo…
yasserfaraazkhan Jul 1, 2026
acba854
fix(e2e): guard config.json reads against a real non-atomic write race
yasserfaraazkhan Jul 1, 2026
da5dab5
Merge remote-tracking branch 'origin/master' into e2e/06-server-manag…
yasserfaraazkhan Jul 2, 2026
49fb2d6
fix linux failure
yasserfaraazkhan Jul 2, 2026
6d4f076
remove main code refs
yasserfaraazkhan Jul 2, 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
86 changes: 86 additions & 0 deletions e2e/helpers/deeplink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

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

import {buildServerMap} from './serverMap';

export function mattermostDeepLinkUrl(hostAndPath: string): string {
// E2E always launches the unpacked binary (electron-is-dev=true), so deep links
// must use mattermost-dev:// regardless of the Playwright worker's NODE_ENV.
return `mattermost-dev://${hostAndPath}`;
}

/** Build a channel deep link that preserves the team path from the configured server URL. */
export function channelDeepLinkUrl(serverUrl: string, channelName: string): string {
const normalized = serverUrl.endsWith('/') ? serverUrl : `${serverUrl}/`;
const parsed = new URL(normalized);
const teamPath = parsed.pathname.replace(/\/$/, '');
const channelPath = teamPath ? `${teamPath}/channels/${channelName}` : `/channels/${channelName}`;
return mattermostDeepLinkUrl(`${parsed.host}${channelPath}`);
}

export async function openDeepLinkInApp(app: ElectronApplication, url: string): Promise<void> {
await app.evaluate((_, deepLinkUrl) => {
const openDeepLink = (global as any).__e2eOpenDeepLink as ((value: string) => void) | undefined;
if (!openDeepLink) {
throw new Error('__e2eOpenDeepLink not exposed (NODE_ENV must be test)');
}
openDeepLink(deepLinkUrl);
}, url);
}

/** Poll any tab for the server until one navigates to the expected channel. */
export async function waitForServerChannelNavigation(
app: ElectronApplication,
serverName: string,
channelName: string,
options?: {timeout?: number},
): Promise<void> {
await expect.poll(async () => {
try {
const map = await buildServerMap(app);
const entries = map[serverName] ?? [];
for (const entry of entries) {
const url = await entry.win.url();
if (url.includes(channelName)) {
return true;
}
}
return false;
} catch {
return false;
}
}, {
timeout: options?.timeout ?? 15_000,
message: `Server view should navigate to ${channelName}`,
}).toBe(true);
}

/**
* Poll the server's primary view for a URL containing `urlSubstring`, then poll
* the dropdown button until it shows `serverName`. Shared by deep-link tests that
* assert both the navigation and the resulting active-server UI state.
*/
export async function waitForServerUrlAndDropdown(
app: ElectronApplication,
mainWindow: Page,
serverName: string,
urlSubstring: string,
options?: {urlTimeout?: number; dropdownTimeout?: number},
): Promise<void> {
await expect.poll(async () => {
const freshMap = await buildServerMap(app);
const freshView = freshMap[serverName]?.[0]?.win;
return (await freshView?.url()) ?? '';
}, {
timeout: options?.urlTimeout ?? 30_000,
message: `Server view for ${serverName} should navigate to a URL containing "${urlSubstring}"`,
}).toContain(urlSubstring);

await expect.poll(
() => mainWindow.innerText('.ServerDropdownButton'),
{timeout: options?.dropdownTimeout ?? 15_000, message: `Server dropdown should show ${serverName}`},
).toBe(serverName);
}
19 changes: 19 additions & 0 deletions e2e/helpers/electronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ export async function closeElectronAppFast(
return closeElectronApp(app, dataDir, FAST_TEARDOWN);
}

/**
* Close an app that may not have launched (e.g. the launch itself threw) and may
* not have a userDataDir assigned yet. Uses the fast teardown when a dir is known
* (unique per-test dir, safe to abandon); otherwise falls back to a plain close.
*/
export async function closeAppSafely(
app: ElectronApplication | undefined,
dataDir?: string,
): Promise<void> {
if (!app) {
return;
}
if (dataDir) {
await closeElectronAppFast(app, dataDir);
} else {
await app.close().catch(() => {});
}
}

function workerRegistryPath(workerPid: number = process.pid): string {
return path.join(REGISTRY_DIR, `${REGISTRY_PREFIX}-${workerPid}.txt`);
}
Expand Down
175 changes: 175 additions & 0 deletions e2e/helpers/errorView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

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

import {clearCertificateErrorCallbacks} from './dialog';
import {evaluateInMainProcessWithArg, isTransientEvaluateError} from './testRefs';

type WaitForErrorViewOptions = {
serverName?: string;
timeout?: number;
};

type ServerReloadAction = 'checkRegistered' | 'reload' | 'checkLoading';

/**
* Single main-process callback for the three server-reload polls below, so the
* "filter servers by name → get views → get WebContentsManager entry" traversal
* is written once instead of once per poll. `action` picks which step to run;
* this has to stay one function (rather than three) because Playwright serializes
* whatever function is passed to `evaluate` — helpers defined elsewhere in this
* module aren't reachable from inside it.
*
* NOTE: Playwright's ElectronApplication.evaluate(fn, arg) always passes the
* `electron` module as the FIRST argument to `fn`. The user-supplied `arg` is the
* SECOND argument. Hence the `(_electron, payload)` signature below.
*/
async function evaluateServerReloadState(
app: ElectronApplication,
action: ServerReloadAction,
targetServerName?: string,
): Promise<boolean> {
return evaluateInMainProcessWithArg(app, (_electron, payload) => {
const refs = (global as any).__e2eTestRefs;
if (!refs) {
return payload.action === 'checkLoading';
}

const servers: Array<{id: string; name: string}> = refs.ServerManager.getAllServers();
const targets = payload.targetServerName ?
servers.filter((server) => server.name === payload.targetServerName) :
servers;

if (payload.action === 'checkRegistered') {
return targets.length > 0;
}

const getWebContentsEntries = () => {
const entries: any[] = [];
for (const server of targets) {
const views: Array<{id: string}> = refs.ViewManager.getViewsByServerId(server.id);
for (const view of views) {
const wcEntry = refs.WebContentsManager.getView(view.id);
if (wcEntry) {
entries.push(wcEntry);
}
}
}
return entries;
};

if (payload.action === 'reload') {
for (const wcEntry of getWebContentsEntries()) {
wcEntry.reload?.();
}
return true;
}

// checkLoading
return getWebContentsEntries().every((wcEntry) => !wcEntry.webContents?.isLoading?.());
}, {action, targetServerName});
}

/**
* Wait for the renderer to mount, then reload server views so load failures
* that fired before IPC listeners were registered are surfaced in ErrorView.
*/
export async function waitForRendererThenReload(
app: ElectronApplication,
serverName?: string,
): Promise<void> {
const mainWindow = app.windows().find((window) => window.url().includes('index'));
if (!mainWindow) {
return;
}

await mainWindow.waitForSelector('.ServerDropdownButton', {timeout: 15_000}).catch(() => {});

if (serverName) {
await expect.poll(() => {
return !app.windows().some((window) => {
try {
return window.url().includes('newServer');
} catch {
return false;
}
});
}, {timeout: 10_000, message: 'Add server modal should close after confirm'}).toBe(true);

await expect.poll(async () => {
return mainWindow.innerText('.ServerDropdownButton');
}, {timeout: 10_000, message: `Active server should switch to ${serverName}`}).toContain(serverName);
}

// Wait until ServerManager knows about the target server. We intentionally do NOT
// require a WebContentsManager entry to exist here: on platforms where the initial
// load fails very fast (e.g. expired cert at startup), the view may never be added
// to WebContentsManager before this poll's deadline. If no view exists, the reload
// step below becomes a no-op and the existing load failure already surfaces in
// `.ErrorView`, which is what the caller is polling for.
await expect.poll(
() => evaluateServerReloadState(app, 'checkRegistered', serverName),
{timeout: 15_000, message: 'Target server should be registered before reload'},
).toBe(true);

await clearCertificateErrorCallbacks(app).catch(() => {});

await evaluateServerReloadState(app, 'reload', serverName);

await expect.poll(
() => evaluateServerReloadState(app, 'checkLoading', serverName),
{timeout: 15_000, message: 'Server views should finish reloading after renderer is ready'},
).toBe(true);
}

/**
* Only DOM-not-ready-yet failures (missing window, missing selector, transient
* evaluate errors) should be retried here. A real programming error — a bad ref,
* a renamed method, a typo — should fail immediately instead of being retried
* away for up to a minute and reported as a generic "ErrorView did not appear".
*/
function isRetryableErrorViewFailure(error: unknown): boolean {
if (isTransientEvaluateError(error)) {
return true;
}
if (error instanceof TypeError || error instanceof ReferenceError) {
return false;
}
if (error instanceof Error && error.message.startsWith('__e2eTestRefs.')) {
return false;
}
return true;
}

export async function waitForErrorView(
app: ElectronApplication,
options: WaitForErrorViewOptions = {},
): Promise<void> {
const timeout = options.timeout ?? (process.env.CI ? 60_000 : 45_000);
const deadline = Date.now() + timeout;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const mainWindow = app.windows().find((window) => window.url().includes('index'));
if (!mainWindow) {
throw new Error('Main index window is not available yet');
}
await waitForRendererThenReload(app, options.serverName);
await mainWindow.waitForSelector('.ErrorView', {
timeout: Math.min(10_000, deadline - Date.now()),
});
return;
} catch (error) {
if (!isRetryableErrorViewFailure(error)) {
throw error;
}
lastError = error;
await clearCertificateErrorCallbacks(app).catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 250));
}
}

throw lastError instanceof Error ? lastError : new Error('ErrorView did not appear before timeout');
}
Loading
Loading