Skip to content

Commit 73496d6

Browse files
e2e(hooks): expose tray menu actions for Playwright and fix history navigation. (#3886)
1 parent e5244b8 commit 73496d6

5 files changed

Lines changed: 143 additions & 25 deletions

File tree

src/app/menus/appMenu/history.test.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// See LICENSE.txt for license information.
33

44
import WebContentsManager from 'app/views/webContentsManager';
5+
import TabManager from 'app/tabs/tabManager';
56
import {localizeMessage} from 'main/i18nManager';
67

78
import createHistoryMenu from './history';
@@ -14,13 +15,18 @@ jest.mock('app/views/webContentsManager', () => ({
1415
getFocusedView: jest.fn(),
1516
}));
1617

18+
jest.mock('app/tabs/tabManager', () => ({
19+
getCurrentActiveTabView: jest.fn(),
20+
}));
21+
1722
describe('app/menus/appMenu/history', () => {
1823
const mockView = {
1924
goToOffset: jest.fn(),
2025
};
2126

2227
beforeEach(() => {
2328
WebContentsManager.getFocusedView.mockReturnValue(mockView);
29+
TabManager.getCurrentActiveTabView.mockReturnValue(undefined);
2430
localizeMessage.mockImplementation((id) => {
2531
const translations = {
2632
'main.menus.app.history': '&History',
@@ -119,19 +125,39 @@ describe('app/menus/appMenu/history', () => {
119125

120126
it('should handle back click when no focused view is available', () => {
121127
WebContentsManager.getFocusedView.mockReturnValue(null);
128+
TabManager.getCurrentActiveTabView.mockReturnValue(mockView);
122129
const menu = createHistoryMenu();
123130
const backItem = menu.submenu.find((item) => item.label === 'Back');
124131

125-
// Should not throw an error when no view is available
126132
expect(() => backItem.click()).not.toThrow();
133+
expect(mockView.goToOffset).toHaveBeenCalledWith(-1);
127134
});
128135

129136
it('should handle forward click when no focused view is available', () => {
130137
WebContentsManager.getFocusedView.mockReturnValue(null);
138+
TabManager.getCurrentActiveTabView.mockReturnValue(mockView);
139+
const menu = createHistoryMenu();
140+
const forwardItem = menu.submenu.find((item) => item.label === 'Forward');
141+
142+
expect(() => forwardItem.click()).not.toThrow();
143+
expect(mockView.goToOffset).toHaveBeenCalledWith(1);
144+
});
145+
146+
it('should handle back click when no view is available', () => {
147+
WebContentsManager.getFocusedView.mockReturnValue(null);
148+
TabManager.getCurrentActiveTabView.mockReturnValue(null);
149+
const menu = createHistoryMenu();
150+
const backItem = menu.submenu.find((item) => item.label === 'Back');
151+
152+
expect(() => backItem.click()).not.toThrow();
153+
});
154+
155+
it('should handle forward click when no view is available', () => {
156+
WebContentsManager.getFocusedView.mockReturnValue(null);
157+
TabManager.getCurrentActiveTabView.mockReturnValue(null);
131158
const menu = createHistoryMenu();
132159
const forwardItem = menu.submenu.find((item) => item.label === 'Forward');
133160

134-
// Should not throw an error when no view is available
135161
expect(() => forwardItem.click()).not.toThrow();
136162
});
137163

src/app/menus/appMenu/history.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
22
// See LICENSE.txt for license information.
33

4+
import TabManager from 'app/tabs/tabManager';
45
import WebContentsManager from 'app/views/webContentsManager';
56
import {localizeMessage} from 'main/i18nManager';
67

8+
function getHistoryNavigationView() {
9+
return WebContentsManager.getFocusedView() ?? TabManager.getCurrentActiveTabView();
10+
}
11+
712
export default function createHistoryMenu() {
813
return {
914
id: 'history',
@@ -12,13 +17,13 @@ export default function createHistoryMenu() {
1217
label: localizeMessage('main.menus.app.history.back', 'Back'),
1318
accelerator: process.platform === 'darwin' ? 'Cmd+[' : 'Alt+Left',
1419
click: () => {
15-
WebContentsManager.getFocusedView()?.goToOffset(-1);
20+
getHistoryNavigationView()?.goToOffset(-1);
1621
},
1722
}, {
1823
label: localizeMessage('main.menus.app.history.forward', 'Forward'),
1924
accelerator: process.platform === 'darwin' ? 'Cmd+]' : 'Alt+Right',
2025
click: () => {
21-
WebContentsManager.getFocusedView()?.goToOffset(1);
26+
getHistoryNavigationView()?.goToOffset(1);
2227
},
2328
}],
2429
};

src/app/menus/appMenu/view.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ describe('app/menus/appMenu/view', () => {
280280

281281
it('should handle reload when no focused view is available', () => {
282282
WebContentsManager.getFocusedView.mockReturnValue(null);
283+
TabManager.getCurrentActiveTabView.mockReturnValue(null);
283284

284285
localizeMessage.mockImplementation((id) => {
285286
if (id === 'main.menus.app.view.reload') {
@@ -296,6 +297,23 @@ describe('app/menus/appMenu/view', () => {
296297
expect(() => reloadMenuItem.click()).not.toThrow();
297298
});
298299

300+
it('should reload active tab when menu open clears focused view', () => {
301+
WebContentsManager.getFocusedView.mockReturnValue(null);
302+
TabManager.getCurrentActiveTabView.mockReturnValue(mockView);
303+
304+
localizeMessage.mockImplementation((id) => {
305+
if (id === 'main.menus.app.view.reload') {
306+
return 'Reload';
307+
}
308+
return id;
309+
});
310+
311+
const menu = createViewMenu();
312+
const reloadMenuItem = menu.submenu.find((item) => item.label === 'Reload');
313+
reloadMenuItem.click();
314+
expect(mockView.reload).toHaveBeenCalledWith('https://example.com/current-page');
315+
});
316+
299317
it('should show developer mode options when developer mode is enabled', () => {
300318
DeveloperMode.enabled.mockReturnValue(true);
301319
DeveloperMode.get.mockImplementation((key) => {

src/app/menus/appMenu/view.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export default function createViewMenu() {
111111
label: localizeMessage('main.menus.app.view.reload', 'Reload'),
112112
accelerator: 'CmdOrCtrl+R',
113113
click() {
114-
const view = WebContentsManager.getFocusedView();
114+
const view = WebContentsManager.getFocusedView() ?? TabManager.getCurrentActiveTabView();
115115
if (view) {
116116
view.reload(view.currentURL);
117117
}
@@ -120,7 +120,7 @@ export default function createViewMenu() {
120120
label: localizeMessage('main.menus.app.view.clearCacheAndReload', 'Clear Cache and Reload'),
121121
accelerator: 'Shift+CmdOrCtrl+R',
122122
click() {
123-
const view = WebContentsManager.getFocusedView();
123+
const view = WebContentsManager.getFocusedView() ?? TabManager.getCurrentActiveTabView();
124124
if (view) {
125125
WebContentsManager.clearCacheAndReloadView(view.id);
126126
}

src/main/e2e/trayMenu.ts

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,101 @@
11
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
22
// See LICENSE.txt for license information.
33

4-
import type {Menu} from 'electron';
4+
import type {Menu, MenuItem} from 'electron';
5+
6+
function normalizeTrayMenuLabel(value: string): string {
7+
return value.toLowerCase().replace(/&/g, '').replace(/[.\u2026]+$/u, '').trim();
8+
}
9+
10+
function trayMenuLabelMatches(itemLabel: string, label: string, truncated: string): boolean {
11+
const normalizedItem = normalizeTrayMenuLabel(itemLabel);
12+
const normalizedTarget = normalizeTrayMenuLabel(label);
13+
return itemLabel === label ||
14+
itemLabel === truncated ||
15+
normalizedItem === normalizedTarget;
16+
}
17+
18+
function clickTrayMenuItemsByRole(items: MenuItem[], role: string): boolean {
19+
for (const item of items) {
20+
if (item.role === role && typeof item.click === 'function') {
21+
item.click();
22+
return true;
23+
}
24+
if (item.submenu?.items && clickTrayMenuItemsByRole(item.submenu.items, role)) {
25+
return true;
26+
}
27+
}
28+
return false;
29+
}
30+
31+
function findTrayMenuItemByLabelPredicate(
32+
items: MenuItem[],
33+
predicate: (normalizedLabel: string, itemLabel: string) => boolean,
34+
): MenuItem | null {
35+
for (const item of items) {
36+
const itemLabel = typeof item.label === 'string' ? item.label : '';
37+
if (
38+
predicate(normalizeTrayMenuLabel(itemLabel), itemLabel) &&
39+
item.enabled !== false &&
40+
item.visible !== false &&
41+
typeof item.click === 'function'
42+
) {
43+
return item;
44+
}
45+
if (item.submenu?.items) {
46+
const nested = findTrayMenuItemByLabelPredicate(item.submenu.items, predicate);
47+
if (nested) {
48+
return nested;
49+
}
50+
}
51+
}
52+
return null;
53+
}
54+
55+
function clickTrayMenuItems(items: MenuItem[], label: string, truncated: string): boolean {
56+
const item = findTrayMenuItemByLabelPredicate(items, (normalized, raw) =>
57+
trayMenuLabelMatches(raw, label, truncated) || normalized === normalizeTrayMenuLabel(label),
58+
);
59+
if (!item) {
60+
return false;
61+
}
62+
item.click();
63+
return true;
64+
}
65+
66+
function clickTraySettingsMenuItem(items: MenuItem[]): boolean {
67+
const item = findTrayMenuItemByLabelPredicate(items, (normalized) =>
68+
normalized === 'settings' ||
69+
normalized === 'preferences' ||
70+
normalized.startsWith('settings') ||
71+
normalized.startsWith('preferences'),
72+
);
73+
if (!item) {
74+
return false;
75+
}
76+
item.click();
77+
return true;
78+
}
579

680
export function createClickTrayMenuItem(getTrayMenu: () => Menu) {
781
return (label: string) => {
8-
const truncated = label.length > 50 ? `${label.slice(0, 50)}...` : label;
82+
if (label.startsWith('role:')) {
83+
const role = label.slice('role:'.length);
84+
if (!clickTrayMenuItemsByRole(getTrayMenu().items, role)) {
85+
throw new Error(`Tray menu item with role not found: ${role}`);
86+
}
87+
return;
88+
}
989

10-
function clickItem(items: Electron.MenuItem[]): boolean {
11-
for (const item of items) {
12-
const itemLabel = typeof item.label === 'string' ? item.label : '';
13-
if (
14-
(itemLabel === label || itemLabel === truncated) &&
15-
item.enabled !== false &&
16-
item.visible !== false &&
17-
typeof item.click === 'function'
18-
) {
19-
item.click();
20-
return true;
21-
}
22-
if (item.submenu?.items && clickItem(item.submenu.items)) {
23-
return true;
24-
}
90+
if (label === 'tray:settings') {
91+
if (!clickTraySettingsMenuItem(getTrayMenu().items)) {
92+
throw new Error('Tray settings menu item not found');
2593
}
26-
return false;
94+
return;
2795
}
2896

29-
if (!clickItem(getTrayMenu().items)) {
97+
const truncated = label.length > 50 ? `${label.slice(0, 50)}...` : label;
98+
if (!clickTrayMenuItems(getTrayMenu().items, label, truncated)) {
3099
throw new Error(`Tray menu item not found: ${label}`);
31100
}
32101
};

0 commit comments

Comments
 (0)