api-menu-item-spec.ts 18 KB

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