api-menu-item-spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import { BrowserWindow, app, Menu, MenuItem, MenuItemConstructorOptions } from 'electron/main';
  2. import { expect } from 'chai';
  3. import { closeAllWindows } from './window-helpers';
  4. const { roleList, execute } = require('../lib/browser/api/menu-item-roles');
  5. describe('MenuItems', () => {
  6. describe('MenuItem instance properties', () => {
  7. it('should have default MenuItem properties', () => {
  8. const item = new MenuItem({
  9. id: '1',
  10. label: 'hello',
  11. role: 'close',
  12. sublabel: 'goodbye',
  13. accelerator: 'CmdOrControl+Q',
  14. click: () => { },
  15. enabled: true,
  16. visible: true,
  17. checked: false,
  18. type: 'normal',
  19. registerAccelerator: true,
  20. submenu: [{ role: 'about' }]
  21. });
  22. expect(item).to.have.property('id').that.is.a('string');
  23. expect(item).to.have.property('label').that.is.a('string').equal('hello');
  24. expect(item).to.have.property('sublabel').that.is.a('string').equal('goodbye');
  25. expect(item).to.have.property('accelerator').that.is.a('string').equal('CmdOrControl+Q');
  26. expect(item).to.have.property('click').that.is.a('function');
  27. expect(item).to.have.property('enabled').that.is.a('boolean').and.is.true('item is enabled');
  28. expect(item).to.have.property('visible').that.is.a('boolean').and.is.true('item is visible');
  29. expect(item).to.have.property('checked').that.is.a('boolean').and.is.false('item is not checked');
  30. expect(item).to.have.property('registerAccelerator').that.is.a('boolean').and.is.true('item can register accelerator');
  31. expect(item).to.have.property('type').that.is.a('string').equal('normal');
  32. expect(item).to.have.property('commandId').that.is.a('number');
  33. expect(item).to.have.property('toolTip').that.is.a('string');
  34. expect(item).to.have.property('role').that.is.a('string');
  35. expect(item).to.have.property('icon');
  36. });
  37. });
  38. describe('MenuItem.click', () => {
  39. it('should be called with the item object passed', done => {
  40. const menu = Menu.buildFromTemplate([{
  41. label: 'text',
  42. click: (item) => {
  43. try {
  44. expect(item.constructor.name).to.equal('MenuItem');
  45. expect(item.label).to.equal('text');
  46. done();
  47. } catch (e) {
  48. done(e);
  49. }
  50. }
  51. }]);
  52. menu._executeCommand({}, menu.items[0].commandId);
  53. });
  54. });
  55. describe('MenuItem with checked/radio property', () => {
  56. it('clicking an checkbox item should flip the checked property', () => {
  57. const menu = Menu.buildFromTemplate([{
  58. label: 'text',
  59. type: 'checkbox'
  60. }]);
  61. expect(menu.items[0].checked).to.be.false('menu item checked');
  62. menu._executeCommand({}, menu.items[0].commandId);
  63. expect(menu.items[0].checked).to.be.true('menu item checked');
  64. });
  65. it('clicking an radio item should always make checked property true', () => {
  66. const menu = Menu.buildFromTemplate([{
  67. label: 'text',
  68. type: 'radio'
  69. }]);
  70. menu._executeCommand({}, menu.items[0].commandId);
  71. expect(menu.items[0].checked).to.be.true('menu item checked');
  72. menu._executeCommand({}, menu.items[0].commandId);
  73. expect(menu.items[0].checked).to.be.true('menu item checked');
  74. });
  75. describe('MenuItem group properties', () => {
  76. const template: MenuItemConstructorOptions[] = [];
  77. const findRadioGroups = (template: MenuItemConstructorOptions[]) => {
  78. const groups = [];
  79. let cur: { begin?: number, end?: number } | null = null;
  80. for (let i = 0; i <= template.length; i++) {
  81. if (cur && ((i === template.length) || (template[i].type !== 'radio'))) {
  82. cur.end = i;
  83. groups.push(cur);
  84. cur = null;
  85. } else if (!cur && i < template.length && template[i].type === 'radio') {
  86. cur = { begin: i };
  87. }
  88. }
  89. return groups;
  90. };
  91. // returns array of checked menuitems in [begin,end)
  92. const findChecked = (menuItems: MenuItem[], begin: number, end: number) => {
  93. const checked = [];
  94. for (let i = begin; i < end; i++) {
  95. if (menuItems[i].checked) checked.push(i);
  96. }
  97. return checked;
  98. };
  99. beforeEach(() => {
  100. for (let i = 0; i <= 10; i++) {
  101. template.push({
  102. label: `${i}`,
  103. type: 'radio'
  104. });
  105. }
  106. template.push({ type: 'separator' });
  107. for (let i = 12; i <= 20; i++) {
  108. template.push({
  109. label: `${i}`,
  110. type: 'radio'
  111. });
  112. }
  113. });
  114. it('at least have one item checked in each group', () => {
  115. const menu = Menu.buildFromTemplate(template);
  116. menu._menuWillShow();
  117. const groups = findRadioGroups(template);
  118. groups.forEach(g => {
  119. expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([g.begin]);
  120. });
  121. });
  122. it('should assign groupId automatically', () => {
  123. const menu = Menu.buildFromTemplate(template);
  124. const usedGroupIds = new Set();
  125. const groups = findRadioGroups(template);
  126. groups.forEach(g => {
  127. const groupId = (menu.items[g.begin!] as any).groupId;
  128. // groupId should be previously unused
  129. // expect(usedGroupIds.has(groupId)).to.be.false('group id present')
  130. expect(usedGroupIds).not.to.contain(groupId);
  131. usedGroupIds.add(groupId);
  132. // everything in the group should have the same id
  133. for (let i = g.begin!; i < g.end!; ++i) {
  134. expect((menu.items[i] as any).groupId).to.equal(groupId);
  135. }
  136. });
  137. });
  138. it("setting 'checked' should flip other items' 'checked' property", () => {
  139. const menu = Menu.buildFromTemplate(template);
  140. const groups = findRadioGroups(template);
  141. groups.forEach(g => {
  142. expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([]);
  143. menu.items[g.begin!].checked = true;
  144. expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([g.begin!]);
  145. menu.items[g.end! - 1].checked = true;
  146. expect(findChecked(menu.items, g.begin!, g.end!)).to.deep.equal([g.end! - 1]);
  147. });
  148. });
  149. });
  150. });
  151. describe('MenuItem role execution', () => {
  152. afterEach(closeAllWindows);
  153. it('does not try to execute roles without a valid role property', () => {
  154. const win = new BrowserWindow({ show: false, width: 200, height: 200 });
  155. const item = new MenuItem({ role: 'asdfghjkl' as any });
  156. const canExecute = execute(item.role, win, win.webContents);
  157. expect(canExecute).to.be.false('can execute');
  158. });
  159. it('executes roles with native role functions', () => {
  160. const win = new BrowserWindow({ show: false, width: 200, height: 200 });
  161. const item = new MenuItem({ role: 'reload' });
  162. const canExecute = execute(item.role, win, win.webContents);
  163. expect(canExecute).to.be.true('can execute');
  164. });
  165. it('execute roles with non-native role functions', () => {
  166. const win = new BrowserWindow({ show: false, width: 200, height: 200 });
  167. const item = new MenuItem({ role: 'resetZoom' });
  168. const canExecute = execute(item.role, win, win.webContents);
  169. expect(canExecute).to.be.true('can execute');
  170. });
  171. });
  172. describe('MenuItem command id', () => {
  173. it('cannot be overwritten', () => {
  174. const item = new MenuItem({ label: 'item' });
  175. const commandId = item.commandId;
  176. expect(commandId).to.not.be.undefined('command id');
  177. expect(() => {
  178. item.commandId = `${commandId}-modified` as any;
  179. }).to.throw(/Cannot assign to read only property/);
  180. expect(item.commandId).to.equal(commandId);
  181. });
  182. });
  183. describe('MenuItem with invalid type', () => {
  184. it('throws an exception', () => {
  185. expect(() => {
  186. Menu.buildFromTemplate([{
  187. label: 'text',
  188. type: 'not-a-type' as any
  189. }]);
  190. }).to.throw(/Unknown menu item type: not-a-type/);
  191. });
  192. });
  193. describe('MenuItem with submenu type and missing submenu', () => {
  194. it('throws an exception', () => {
  195. expect(() => {
  196. Menu.buildFromTemplate([{
  197. label: 'text',
  198. type: 'submenu'
  199. }]);
  200. }).to.throw(/Invalid submenu/);
  201. });
  202. });
  203. describe('MenuItem role', () => {
  204. it('returns undefined for items without default accelerator', () => {
  205. const list = Object.keys(roleList).filter(key => !roleList[key].accelerator);
  206. for (const role of list) {
  207. const item = new MenuItem({ role: role as any });
  208. expect(item.getDefaultRoleAccelerator()).to.be.undefined('default accelerator');
  209. }
  210. });
  211. it('returns the correct default label', () => {
  212. for (const role of Object.keys(roleList)) {
  213. const item = new MenuItem({ role: role as any });
  214. const label: string = roleList[role].label;
  215. expect(item.label).to.equal(label);
  216. }
  217. });
  218. it('returns the correct default accelerator', () => {
  219. const list = Object.keys(roleList).filter(key => roleList[key].accelerator);
  220. for (const role of list) {
  221. const item = new MenuItem({ role: role as any });
  222. const accelerator: string = roleList[role].accelerator;
  223. expect(item.getDefaultRoleAccelerator()).to.equal(accelerator);
  224. }
  225. });
  226. it('allows a custom accelerator and label to be set', () => {
  227. const item = new MenuItem({
  228. role: 'close',
  229. label: 'Custom Close!',
  230. accelerator: 'D'
  231. });
  232. expect(item.label).to.equal('Custom Close!');
  233. expect(item.accelerator).to.equal('D');
  234. expect(item.getDefaultRoleAccelerator()).to.equal('CommandOrControl+W');
  235. });
  236. });
  237. describe('MenuItem appMenu', () => {
  238. before(function () {
  239. if (process.platform !== 'darwin') {
  240. this.skip();
  241. }
  242. });
  243. it('includes a default submenu layout when submenu is empty', () => {
  244. const item = new MenuItem({ role: 'appMenu' });
  245. expect(item.label).to.equal(app.name);
  246. expect(item.submenu!.items[0].role).to.equal('about');
  247. expect(item.submenu!.items[1].type).to.equal('separator');
  248. expect(item.submenu!.items[2].role).to.equal('services');
  249. expect(item.submenu!.items[3].type).to.equal('separator');
  250. expect(item.submenu!.items[4].role).to.equal('hide');
  251. expect(item.submenu!.items[5].role).to.equal('hideothers');
  252. expect(item.submenu!.items[6].role).to.equal('unhide');
  253. expect(item.submenu!.items[7].type).to.equal('separator');
  254. expect(item.submenu!.items[8].role).to.equal('quit');
  255. });
  256. it('overrides default layout when submenu is specified', () => {
  257. const item = new MenuItem({
  258. role: 'appMenu',
  259. submenu: [{
  260. role: 'close'
  261. }]
  262. });
  263. expect(item.label).to.equal(app.name);
  264. expect(item.submenu!.items[0].role).to.equal('close');
  265. });
  266. });
  267. describe('MenuItem fileMenu', () => {
  268. it('includes a default submenu layout when submenu is empty', () => {
  269. const item = new MenuItem({ role: 'fileMenu' });
  270. expect(item.label).to.equal('File');
  271. if (process.platform === 'darwin') {
  272. expect(item.submenu!.items[0].role).to.equal('close');
  273. } else {
  274. expect(item.submenu!.items[0].role).to.equal('quit');
  275. }
  276. });
  277. it('overrides default layout when submenu is specified', () => {
  278. const item = new MenuItem({
  279. role: 'fileMenu',
  280. submenu: [{
  281. role: 'about'
  282. }]
  283. });
  284. expect(item.label).to.equal('File');
  285. expect(item.submenu!.items[0].role).to.equal('about');
  286. });
  287. });
  288. describe('MenuItem editMenu', () => {
  289. it('includes a default submenu layout when submenu is empty', () => {
  290. const item = new MenuItem({ role: 'editMenu' });
  291. expect(item.label).to.equal('Edit');
  292. expect(item.submenu!.items[0].role).to.equal('undo');
  293. expect(item.submenu!.items[1].role).to.equal('redo');
  294. expect(item.submenu!.items[2].type).to.equal('separator');
  295. expect(item.submenu!.items[3].role).to.equal('cut');
  296. expect(item.submenu!.items[4].role).to.equal('copy');
  297. expect(item.submenu!.items[5].role).to.equal('paste');
  298. if (process.platform === 'darwin') {
  299. expect(item.submenu!.items[6].role).to.equal('pasteandmatchstyle');
  300. expect(item.submenu!.items[7].role).to.equal('delete');
  301. expect(item.submenu!.items[8].role).to.equal('selectall');
  302. expect(item.submenu!.items[9].type).to.equal('separator');
  303. expect(item.submenu!.items[10].label).to.equal('Speech');
  304. expect(item.submenu!.items[10].submenu!.items[0].role).to.equal('startspeaking');
  305. expect(item.submenu!.items[10].submenu!.items[1].role).to.equal('stopspeaking');
  306. } else {
  307. expect(item.submenu!.items[6].role).to.equal('delete');
  308. expect(item.submenu!.items[7].type).to.equal('separator');
  309. expect(item.submenu!.items[8].role).to.equal('selectall');
  310. }
  311. });
  312. it('overrides default layout when submenu is specified', () => {
  313. const item = new MenuItem({
  314. role: 'editMenu',
  315. submenu: [{
  316. role: 'close'
  317. }]
  318. });
  319. expect(item.label).to.equal('Edit');
  320. expect(item.submenu!.items[0].role).to.equal('close');
  321. });
  322. });
  323. describe('MenuItem viewMenu', () => {
  324. it('includes a default submenu layout when submenu is empty', () => {
  325. const item = new MenuItem({ role: 'viewMenu' });
  326. expect(item.label).to.equal('View');
  327. expect(item.submenu!.items[0].role).to.equal('reload');
  328. expect(item.submenu!.items[1].role).to.equal('forcereload');
  329. expect(item.submenu!.items[2].role).to.equal('toggledevtools');
  330. expect(item.submenu!.items[3].type).to.equal('separator');
  331. expect(item.submenu!.items[4].role).to.equal('resetzoom');
  332. expect(item.submenu!.items[5].role).to.equal('zoomin');
  333. expect(item.submenu!.items[6].role).to.equal('zoomout');
  334. expect(item.submenu!.items[7].type).to.equal('separator');
  335. expect(item.submenu!.items[8].role).to.equal('togglefullscreen');
  336. });
  337. it('overrides default layout when submenu is specified', () => {
  338. const item = new MenuItem({
  339. role: 'viewMenu',
  340. submenu: [{
  341. role: 'close'
  342. }]
  343. });
  344. expect(item.label).to.equal('View');
  345. expect(item.submenu!.items[0].role).to.equal('close');
  346. });
  347. });
  348. describe('MenuItem windowMenu', () => {
  349. it('includes a default submenu layout when submenu is empty', () => {
  350. const item = new MenuItem({ role: 'windowMenu' });
  351. expect(item.label).to.equal('Window');
  352. expect(item.submenu!.items[0].role).to.equal('minimize');
  353. expect(item.submenu!.items[1].role).to.equal('zoom');
  354. if (process.platform === 'darwin') {
  355. expect(item.submenu!.items[2].type).to.equal('separator');
  356. expect(item.submenu!.items[3].role).to.equal('front');
  357. } else {
  358. expect(item.submenu!.items[2].role).to.equal('close');
  359. }
  360. });
  361. it('overrides default layout when submenu is specified', () => {
  362. const item = new MenuItem({
  363. role: 'windowMenu',
  364. submenu: [{ role: 'copy' }]
  365. });
  366. expect(item.label).to.equal('Window');
  367. expect(item.submenu!.items[0].role).to.equal('copy');
  368. });
  369. });
  370. describe('MenuItem with custom properties in constructor', () => {
  371. it('preserves the custom properties', () => {
  372. const template = [{
  373. label: 'menu 1',
  374. customProp: 'foo',
  375. submenu: []
  376. }];
  377. const menu = Menu.buildFromTemplate(template);
  378. menu.items[0].submenu!.append(new MenuItem({
  379. label: 'item 1',
  380. customProp: 'bar',
  381. overrideProperty: 'oops not allowed'
  382. } as any));
  383. expect((menu.items[0] as any).customProp).to.equal('foo');
  384. expect(menu.items[0].submenu!.items[0].label).to.equal('item 1');
  385. expect((menu.items[0].submenu!.items[0] as any).customProp).to.equal('bar');
  386. expect((menu.items[0].submenu!.items[0] as any).overrideProperty).to.be.a('function');
  387. });
  388. });
  389. describe('MenuItem accelerators', () => {
  390. const isDarwin = () => {
  391. return (process.platform === 'darwin');
  392. };
  393. it('should display modifiers correctly for simple keys', () => {
  394. const menu = Menu.buildFromTemplate([
  395. { label: 'text', accelerator: 'CmdOrCtrl+A' },
  396. { label: 'text', accelerator: 'Shift+A' },
  397. { label: 'text', accelerator: 'Alt+A' }
  398. ]);
  399. expect(menu.getAcceleratorTextAt(0)).to.equal(isDarwin() ? '⌘A' : 'Ctrl+A');
  400. expect(menu.getAcceleratorTextAt(1)).to.equal(isDarwin() ? '⇧A' : 'Shift+A');
  401. expect(menu.getAcceleratorTextAt(2)).to.equal(isDarwin() ? '⌥A' : 'Alt+A');
  402. });
  403. it('should display modifiers correctly for special keys', () => {
  404. const menu = Menu.buildFromTemplate([
  405. { label: 'text', accelerator: 'CmdOrCtrl+Tab' },
  406. { label: 'text', accelerator: 'Shift+Tab' },
  407. { label: 'text', accelerator: 'Alt+Tab' }
  408. ]);
  409. expect(menu.getAcceleratorTextAt(0)).to.equal(isDarwin() ? '⌘⇥' : 'Ctrl+Tab');
  410. expect(menu.getAcceleratorTextAt(1)).to.equal(isDarwin() ? '⇧⇥' : 'Shift+Tab');
  411. expect(menu.getAcceleratorTextAt(2)).to.equal(isDarwin() ? '⌥⇥' : 'Alt+Tab');
  412. });
  413. it('should not display modifiers twice', () => {
  414. const menu = Menu.buildFromTemplate([
  415. { label: 'text', accelerator: 'Shift+Shift+A' },
  416. { label: 'text', accelerator: 'Shift+Shift+Tab' }
  417. ]);
  418. expect(menu.getAcceleratorTextAt(0)).to.equal(isDarwin() ? '⇧A' : 'Shift+A');
  419. expect(menu.getAcceleratorTextAt(1)).to.equal(isDarwin() ? '⇧⇥' : 'Shift+Tab');
  420. });
  421. it('should display correctly for edge cases', () => {
  422. const menu = Menu.buildFromTemplate([
  423. { label: 'text', accelerator: 'Control+Shift+=' },
  424. { label: 'text', accelerator: 'Control+Plus' }
  425. ]);
  426. expect(menu.getAcceleratorTextAt(0)).to.equal(isDarwin() ? '⌃⇧=' : 'Ctrl+Shift+=');
  427. expect(menu.getAcceleratorTextAt(1)).to.equal(isDarwin() ? '⌃⇧=' : 'Ctrl+Shift+=');
  428. });
  429. });
  430. });