Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .github/workflows/e2e-functional-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ jobs:
- name: e2e/upload-report-to-tsio
if: ${{ always() && inputs.tsio-config != '' && hashFiles('e2e/test-results/results.json') != '' }}
continue-on-error: true
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-report-upload@81017b2d568d1fc1f1fdaa7299b177fae53c63e1 # 0.10.0 / 2026-05-16
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-report-upload@19aef73a2a9b62ad94ed695e71c142f35ee4c6f1 # 0.12.1 / 2026-07-20
with:
composite-identity: ${{ toJSON(fromJSON(inputs.tsio-config).composite_identity) }}
total-reports-expected: ${{ fromJSON(inputs.tsio-config).total_reports_expected }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-functional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ jobs:
- name: e2e/upload-report-to-tsio
if: ${{ always() && needs.prepare-matrix.outputs.tsio-composite-identity != '' && hashFiles('e2e/test-results/results.json') != '' }}
continue-on-error: true
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-report-upload@81017b2d568d1fc1f1fdaa7299b177fae53c63e1 # 0.10.0 / 2026-05-16
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-report-upload@19aef73a2a9b62ad94ed695e71c142f35ee4c6f1 # 0.12.1 / 2026-07-20
with:
composite-identity: ${{ needs.prepare-matrix.outputs.tsio-composite-identity }}
total-reports-expected: ${{ needs.prepare-matrix.outputs.tsio-total-reports-expected }}
Expand Down
136 changes: 98 additions & 38 deletions e2e/specs/menu_bar/window_menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {appDir, demoMattermostConfig, electronBinaryPath, writeConfigFile} from
import {closeDownloadsDropdownIfOpen} from '../../helpers/downloadsDropdown';
import {closeElectronAppFast, registerElectronMainProcess, waitForWindow} from '../../helpers/electronApp';
import {loginToMattermost} from '../../helpers/login';
import {waitForMattermostShellReady} from '../../helpers/mattermostShell';
import {waitForMattermostShell, waitForMattermostShellReady} from '../../helpers/mattermostShell';
import {prepareMattermostServerView} from '../../helpers/prepareServerView';
import {buildServerMap} from '../../helpers/serverMap';
import {buildServerMap, type ServerMap} from '../../helpers/serverMap';

const windowMenuConfig = {
...demoMattermostConfig,
Expand Down Expand Up @@ -232,63 +232,104 @@ async function createExtraTabs() {
await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(3)', {timeout: 15_000});

const serverName = windowMenuConfig.servers[0].name;
let map = await buildServerMap(electronApp);
const deadline = Date.now() + 30_000;
while ((map[serverName]?.length ?? 0) < 3 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 200));
map = await buildServerMap(electronApp);
}
expect(map[serverName]?.length, 'Three Mattermost tabs should be registered').toBeGreaterThanOrEqual(3);
await expect.poll(async () => {
const map = await buildServerMap(electronApp);
return map[serverName]?.length ?? 0;
}, {
timeout: 30_000,
message: 'Three Mattermost tabs should be registered',
}).toBeGreaterThanOrEqual(3);

return buildServerMap(electronApp);
}

function getServerWebContentsIds(map: ServerMap, serverName: string): number[] {
return (map[serverName] ?? []).map((entry) => entry.webContentsId);
}

async function assertServerMapContainsIds(
serverName: string,
expectedIds: number[],
message: string,
) {
const map = await buildServerMap(electronApp);
const actualIds = getServerWebContentsIds(map, serverName);
expect(actualIds.length, message).toBeGreaterThanOrEqual(expectedIds.length);
expect(actualIds, message).toEqual(expect.arrayContaining(expectedIds));
return map;
}

/** Regression: secondary tabs must not be torn down while they finish loading. */
async function assertSecondaryTabsRemainRegistered(
serverName: string,
expectedMap: ServerMap,
) {
const expectedIds = getServerWebContentsIds(expectedMap, serverName);
expect(expectedIds.length, 'Three Mattermost tabs should be registered').toBeGreaterThanOrEqual(3);

const secondaryEntries = (expectedMap[serverName] ?? []).slice(1, 3);
for (const entry of secondaryEntries) {
await waitForMattermostShell(entry.win, {timeout: 60_000});
await assertServerMapContainsIds(
serverName,
expectedIds,
'Secondary Mattermost tabs must remain registered with stable webContentsIds',
);
}
}

async function switchToTabAndOpenChannel(
serverName: string,
tabIndex: number,
channelItem: string,
expectedIds: number[],
) {
await expect.poll(async () => {
const map = await buildServerMap(electronApp);
return map[serverName]?.length ?? 0;
}, {timeout: 30_000}).toBeGreaterThanOrEqual(tabIndex);

const tab = await mainWindow.waitForSelector(
`.TabBar li.serverTabItem:nth-child(${tabIndex})`,
{timeout: 15_000},
);
await tab.click();

// Focus + re-resolve after click. Do NOT loginToMattermost on secondary tabs:
// a TAB_LOGIN_CHANGED(false) during reload/login tears down non-primary tabs
// (TabManager.handleServerLoggedInChanged), leaving stale webContentsIds.
await focusMainWindow();
let localServerMap = await buildServerMap(electronApp);
await expect.poll(async () => {
localServerMap = await buildServerMap(electronApp);
const actualIds = getServerWebContentsIds(localServerMap, serverName);
if (!expectedIds.every((id) => actualIds.includes(id))) {
return null;
}
return localServerMap[serverName]?.[tabIndex - 1]?.webContentsId ?? null;
}, {
timeout: 30_000,
message: `Mattermost tab ${tabIndex} must remain registered after switch`,
message: `Mattermost tab ${tabIndex} must be registered before switch`,
}).not.toBeNull();

const view = localServerMap[serverName][tabIndex - 1].win;
await prepareMattermostServerView(electronApp, view.webContentsId);
await waitForMattermostShellReady(view, {channelItem});
await view.click(channelItem);
const expectedId = expectedIds[tabIndex - 1];
const entry = localServerMap[serverName].find((candidate) => candidate.webContentsId === expectedId) ??
localServerMap[serverName][tabIndex - 1];
expect(entry?.webContentsId, `Mattermost tab ${tabIndex} webContentsId must match createExtraTabs map`).toBe(expectedId);

// Switch via TabManager (same path the Window menu uses). Avoid DOM tab click +
// focusMainWindow here: that widened the race where a loading secondary tab's
// transient onLogout destroyed siblings before the view was re-resolved.
await prepareMattermostServerView(electronApp, entry.webContentsId);
await waitForMattermostShellReady(entry.win, {channelItem});
await entry.win.click(channelItem);

await assertServerMapContainsIds(
serverName,
expectedIds,
`Mattermost tabs must remain registered after opening channel on tab ${tabIndex}`,
);

return localServerMap;
return buildServerMap(electronApp);
}

async function navigateToSecondAndThirdTabs(serverName: string) {
async function navigateToSecondAndThirdTabs(serverName: string, expectedIds: number[]) {
await switchToTabAndOpenChannel(
serverName,
2,
'#sidebarItem_off-topic',
expectedIds,
);
return switchToTabAndOpenChannel(
serverName,
3,
'#sidebarItem_town-square',
expectedIds,
);
}

Expand Down Expand Up @@ -376,9 +417,16 @@ test.describe('Menu/window_menu', () => {
});

test.describe('MM-T4385 select tab from menu', () => {
test('should keep secondary tabs registered after creation', {tag: ['@P2', '@all']}, async () => {
const serverName = windowMenuConfig.servers[0].name;
const createdMap = await createExtraTabs();
await assertSecondaryTabsRemainRegistered(serverName, createdMap);
});

test('MM-T4385_1 should show the second tab', {tag: ['@P2', '@all']}, async () => {
await createExtraTabs();
await navigateToSecondAndThirdTabs(windowMenuConfig.servers[0].name);
const serverName = windowMenuConfig.servers[0].name;
const createdMap = await createExtraTabs();
await navigateToSecondAndThirdTabs(serverName, getServerWebContentsIds(createdMap, serverName));

// Tab title updates asynchronously after channel navigation — poll for it.
await expect(mainWindow.locator('.active')).toContainText('Town Square', {timeout: 10_000});
Expand All @@ -388,17 +436,19 @@ test.describe('Menu/window_menu', () => {
});

test('MM-T4385_2 should show the third tab', {tag: ['@P2', '@all']}, async () => {
await createExtraTabs();
await navigateToSecondAndThirdTabs(windowMenuConfig.servers[0].name);
const serverName = windowMenuConfig.servers[0].name;
const createdMap = await createExtraTabs();
await navigateToSecondAndThirdTabs(serverName, getServerWebContentsIds(createdMap, serverName));

await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+2'});
await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+3'});
await expect.poll(() => getActiveTabTitle(electronApp), {timeout: 15_000}).toContain('Town Square');
});

test('MM-T4385_3 should show the first tab', {tag: ['@P2', '@all']}, async () => {
await createExtraTabs();
await navigateToSecondAndThirdTabs(windowMenuConfig.servers[0].name);
const serverName = windowMenuConfig.servers[0].name;
const createdMap = await createExtraTabs();
await navigateToSecondAndThirdTabs(serverName, getServerWebContentsIds(createdMap, serverName));

await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+2'});
await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+1'});
Expand All @@ -411,7 +461,17 @@ test.describe('Menu/window_menu', () => {
await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000});

const serverName = windowMenuConfig.servers[0].name;
await switchToTabAndOpenChannel(serverName, 2, '#sidebarItem_off-topic');
let createdMap = await buildServerMap(electronApp);
await expect.poll(async () => {
createdMap = await buildServerMap(electronApp);
return createdMap[serverName]?.length ?? 0;
}, {timeout: 30_000}).toBeGreaterThanOrEqual(2);
await switchToTabAndOpenChannel(
serverName,
2,
'#sidebarItem_off-topic',
getServerWebContentsIds(createdMap, serverName),
);

await expect.poll(() => getActiveTabTitle(electronApp), {timeout: 15_000}).toContain('Off-Topic');

Expand Down
82 changes: 73 additions & 9 deletions src/app/views/webContentsManager.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {session} from 'electron';
import {ipcMain, session} from 'electron';

import AppState from 'common/appState';
import ServerManager from 'common/servers/serverManager';
import ViewManager from 'common/views/viewManager';
import {flushCookiesStore} from 'main/app/utils';
Expand Down Expand Up @@ -96,6 +97,7 @@ jest.mock('common/views/viewManager', () => ({
getViewLog: jest.fn(),
getView: jest.fn(),
getPrimaryView: jest.fn(),
isPrimaryView: jest.fn(),
}));

jest.mock('main/app/utils', () => ({
Expand Down Expand Up @@ -149,7 +151,10 @@ jest.mock('app/mainWindow/modals/modalManager', () => ({
isModalDisplayed: jest.fn(),
}));
jest.mock('./webContentEvents', () => ({}));
jest.mock('common/appState', () => ({}));
jest.mock('common/appState', () => ({
updateExpired: jest.fn(),
updateUnreadsAndMentionsPerServer: jest.fn(),
}));
jest.mock('app/popoutMenu', () => ({
default: jest.fn(),
}));
Expand Down Expand Up @@ -356,6 +361,7 @@ describe('app/views/webContentsManager', () => {
beforeEach(() => {
webContentsManager.webContentsIdToView = new Map();
ServerManager.setLoggedIn = jest.fn();
ViewManager.isPrimaryView.mockReturnValue(true);
});

afterEach(() => {
Expand All @@ -365,35 +371,93 @@ describe('app/views/webContentsManager', () => {
it('should handle login state change for existing view', () => {
webContentsManager.webContentsIdToView.set(123, mockView);

// Emit the IPC event
const ipcMain = require('electron').ipcMain;
ipcMain.emit('tab-login-changed', mockEvent, true);

expect(ServerManager.setLoggedIn).toHaveBeenCalledWith('server-1', true);
expect(flushCookiesStore).toHaveBeenCalled();
});

it('should handle logout state change for existing view', () => {
it('should handle logout state change for primary view', () => {
webContentsManager.webContentsIdToView.set(123, mockView);
ViewManager.isPrimaryView.mockReturnValue(true);

// Emit the IPC event
const ipcMain = require('electron').ipcMain;
ipcMain.emit('tab-login-changed', mockEvent, false);

expect(ServerManager.setLoggedIn).toHaveBeenCalledWith('server-1', false);
expect(flushCookiesStore).toHaveBeenCalled();
});

it('should ignore logout from non-primary view', () => {
webContentsManager.webContentsIdToView.set(123, mockView);
ViewManager.isPrimaryView.mockReturnValue(false);

ipcMain.emit('tab-login-changed', mockEvent, false);

expect(ServerManager.setLoggedIn).not.toHaveBeenCalled();
expect(flushCookiesStore).not.toHaveBeenCalled();
});

it('should still accept login from non-primary view', () => {
webContentsManager.webContentsIdToView.set(123, mockView);
ViewManager.isPrimaryView.mockReturnValue(false);

ipcMain.emit('tab-login-changed', mockEvent, true);

expect(ServerManager.setLoggedIn).toHaveBeenCalledWith('server-1', true);
expect(flushCookiesStore).toHaveBeenCalled();
});

it('should do nothing when view does not exist', () => {
// Emit the IPC event
const ipcMain = require('electron').ipcMain;
ipcMain.emit('tab-login-changed', mockEvent, true);

expect(ServerManager.setLoggedIn).not.toHaveBeenCalled();
expect(flushCookiesStore).not.toHaveBeenCalled();
});
});

describe('handleSessionExpired', () => {
const webContentsManager = new WebContentsManager();
const mockEvent = {
sender: {id: 123},
};
const mockView = {
id: 'test-view',
serverId: 'server-1',
};

beforeEach(() => {
webContentsManager.webContentsIdToView = new Map();
ServerManager.setLoggedIn = jest.fn();
ViewManager.getViewLog.mockReturnValue({debug: jest.fn()});
ViewManager.isPrimaryView.mockReturnValue(true);
AppState.updateExpired.mockClear();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should mark logged out when primary view session expires', () => {
webContentsManager.webContentsIdToView.set(123, mockView);
ViewManager.isPrimaryView.mockReturnValue(true);

ipcMain.emit('session_expired', mockEvent, true);

expect(ServerManager.setLoggedIn).toHaveBeenCalledWith('server-1', false);
expect(AppState.updateExpired).toHaveBeenCalledWith('server-1', true);
});

it('should ignore session expiry from non-primary view', () => {
webContentsManager.webContentsIdToView.set(123, mockView);
ViewManager.isPrimaryView.mockReturnValue(false);

ipcMain.emit('session_expired', mockEvent, true);

expect(ServerManager.setLoggedIn).not.toHaveBeenCalled();
expect(AppState.updateExpired).not.toHaveBeenCalled();
});
});

describe('clearCacheAndReloadView', () => {
const webContentsManager = new WebContentsManager();
const mockView = {
Expand Down
Loading
Loading