api-menu-spec.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805
  1. const chai = require('chai')
  2. const dirtyChai = require('dirty-chai')
  3. const { ipcRenderer, remote } = require('electron')
  4. const { BrowserWindow, Menu, MenuItem } = remote
  5. const { sortMenuItems } = require('../lib/browser/api/menu-utils')
  6. const { closeWindow } = require('./window-helpers')
  7. const { expect } = chai
  8. chai.use(dirtyChai)
  9. describe('Menu module', () => {
  10. describe('Menu.buildFromTemplate', () => {
  11. it('should be able to attach extra fields', () => {
  12. const menu = Menu.buildFromTemplate([
  13. {
  14. label: 'text',
  15. extra: 'field'
  16. }
  17. ])
  18. expect(menu.items[0].extra).to.equal('field')
  19. })
  20. it('does not modify the specified template', () => {
  21. const template = [{ label: 'text', submenu: [{ label: 'sub' }] }]
  22. const result = ipcRenderer.sendSync('eval', `const template = [{label: 'text', submenu: [{label: 'sub'}]}]\nrequire('electron').Menu.buildFromTemplate(template)\ntemplate`)
  23. expect(result).to.deep.equal(template)
  24. })
  25. it('does not throw exceptions for undefined/null values', () => {
  26. expect(() => {
  27. Menu.buildFromTemplate([
  28. {
  29. label: 'text',
  30. accelerator: undefined
  31. },
  32. {
  33. label: 'text again',
  34. accelerator: null
  35. }
  36. ])
  37. }).to.not.throw()
  38. })
  39. it('does throw exceptions for empty objects and null values', () => {
  40. expect(() => {
  41. Menu.buildFromTemplate([{}, null])
  42. }).to.throw(/Invalid template for MenuItem: must have at least one of label, role or type/)
  43. })
  44. it('does throw exception for object without role, label, or type attribute', () => {
  45. expect(() => {
  46. Menu.buildFromTemplate([{ 'visible': true }])
  47. }).to.throw(/Invalid template for MenuItem: must have at least one of label, role or type/)
  48. })
  49. it('does throw exception for undefined', () => {
  50. expect(() => {
  51. Menu.buildFromTemplate([undefined])
  52. }).to.throw(/Invalid template for MenuItem: must have at least one of label, role or type/)
  53. })
  54. describe('Menu sorting and building', () => {
  55. describe('sorts groups', () => {
  56. it('does a simple sort', () => {
  57. const items = [
  58. {
  59. label: 'two',
  60. id: '2',
  61. afterGroupContaining: ['1'] },
  62. { type: 'separator' },
  63. {
  64. id: '1',
  65. label: 'one'
  66. }
  67. ]
  68. const expected = [
  69. {
  70. id: '1',
  71. label: 'one'
  72. },
  73. { type: 'separator' },
  74. {
  75. id: '2',
  76. label: 'two',
  77. afterGroupContaining: ['1']
  78. }
  79. ]
  80. expect(sortMenuItems(items)).to.deep.equal(expected)
  81. })
  82. it('resolves cycles by ignoring things that conflict', () => {
  83. const items = [
  84. {
  85. id: '2',
  86. label: 'two',
  87. afterGroupContaining: ['1']
  88. },
  89. { type: 'separator' },
  90. {
  91. id: '1',
  92. label: 'one',
  93. afterGroupContaining: ['2']
  94. }
  95. ]
  96. const expected = [
  97. {
  98. id: '1',
  99. label: 'one',
  100. afterGroupContaining: ['2']
  101. },
  102. { type: 'separator' },
  103. {
  104. id: '2',
  105. label: 'two',
  106. afterGroupContaining: ['1']
  107. }
  108. ]
  109. expect(sortMenuItems(items)).to.deep.equal(expected)
  110. })
  111. it('ignores references to commands that do not exist', () => {
  112. const items = [
  113. {
  114. id: '1',
  115. label: 'one'
  116. },
  117. { type: 'separator' },
  118. {
  119. id: '2',
  120. label: 'two',
  121. afterGroupContaining: ['does-not-exist']
  122. }
  123. ]
  124. const expected = [
  125. {
  126. id: '1',
  127. label: 'one'
  128. },
  129. { type: 'separator' },
  130. {
  131. id: '2',
  132. label: 'two',
  133. afterGroupContaining: ['does-not-exist']
  134. }
  135. ]
  136. expect(sortMenuItems(items)).to.deep.equal(expected)
  137. })
  138. it('only respects the first matching [before|after]GroupContaining rule in a given group', () => {
  139. const items = [
  140. {
  141. id: '1',
  142. label: 'one'
  143. },
  144. { type: 'separator' },
  145. {
  146. id: '3',
  147. label: 'three',
  148. beforeGroupContaining: ['1']
  149. },
  150. {
  151. id: '4',
  152. label: 'four',
  153. afterGroupContaining: ['2']
  154. },
  155. { type: 'separator' },
  156. {
  157. id: '2',
  158. label: 'two'
  159. }
  160. ]
  161. const expected = [
  162. {
  163. id: '3',
  164. label: 'three',
  165. beforeGroupContaining: ['1']
  166. },
  167. {
  168. id: '4',
  169. label: 'four',
  170. afterGroupContaining: ['2']
  171. },
  172. { type: 'separator' },
  173. {
  174. id: '1',
  175. label: 'one'
  176. },
  177. { type: 'separator' },
  178. {
  179. id: '2',
  180. label: 'two'
  181. }
  182. ]
  183. expect(sortMenuItems(items)).to.deep.equal(expected)
  184. })
  185. })
  186. describe('moves an item to a different group by merging groups', () => {
  187. it('can move a group of one item', () => {
  188. const items = [
  189. {
  190. id: '1',
  191. label: 'one'
  192. },
  193. { type: 'separator' },
  194. {
  195. id: '2',
  196. label: 'two'
  197. },
  198. { type: 'separator' },
  199. {
  200. id: '3',
  201. label: 'three',
  202. after: ['1']
  203. },
  204. { type: 'separator' }
  205. ]
  206. const expected = [
  207. {
  208. id: '1',
  209. label: 'one'
  210. },
  211. {
  212. id: '3',
  213. label: 'three',
  214. after: ['1']
  215. },
  216. { type: 'separator' },
  217. {
  218. id: '2',
  219. label: 'two'
  220. }
  221. ]
  222. expect(sortMenuItems(items)).to.deep.equal(expected)
  223. })
  224. it("moves all items in the moving item's group", () => {
  225. const items = [
  226. {
  227. id: '1',
  228. label: 'one'
  229. },
  230. { type: 'separator' },
  231. {
  232. id: '2',
  233. label: 'two'
  234. },
  235. { type: 'separator' },
  236. {
  237. id: '3',
  238. label: 'three',
  239. after: ['1']
  240. },
  241. {
  242. id: '4',
  243. label: 'four'
  244. },
  245. { type: 'separator' }
  246. ]
  247. const expected = [
  248. {
  249. id: '1',
  250. label: 'one'
  251. },
  252. {
  253. id: '3',
  254. label: 'three',
  255. after: ['1']
  256. },
  257. {
  258. id: '4',
  259. label: 'four'
  260. },
  261. { type: 'separator' },
  262. {
  263. id: '2',
  264. label: 'two'
  265. }
  266. ]
  267. expect(sortMenuItems(items)).to.deep.equal(expected)
  268. })
  269. it("ignores positions relative to commands that don't exist", () => {
  270. const items = [
  271. {
  272. id: '1',
  273. label: 'one'
  274. },
  275. { type: 'separator' },
  276. {
  277. id: '2',
  278. label: 'two'
  279. },
  280. { type: 'separator' },
  281. {
  282. id: '3',
  283. label: 'three',
  284. after: ['does-not-exist']
  285. },
  286. {
  287. id: '4',
  288. label: 'four',
  289. after: ['1']
  290. },
  291. { type: 'separator' }
  292. ]
  293. const expected = [
  294. {
  295. id: '1',
  296. label: 'one'
  297. },
  298. {
  299. id: '3',
  300. label: 'three',
  301. after: ['does-not-exist']
  302. },
  303. {
  304. id: '4',
  305. label: 'four',
  306. after: ['1']
  307. },
  308. { type: 'separator' },
  309. {
  310. id: '2',
  311. label: 'two'
  312. }
  313. ]
  314. expect(sortMenuItems(items)).to.deep.equal(expected)
  315. })
  316. it('can handle recursive group merging', () => {
  317. const items = [
  318. {
  319. id: '1',
  320. label: 'one',
  321. after: ['3']
  322. },
  323. {
  324. id: '2',
  325. label: 'two',
  326. before: ['1']
  327. },
  328. {
  329. id: '3',
  330. label: 'three'
  331. }
  332. ]
  333. const expected = [
  334. {
  335. id: '3',
  336. label: 'three'
  337. },
  338. {
  339. id: '2',
  340. label: 'two',
  341. before: ['1']
  342. },
  343. {
  344. id: '1',
  345. label: 'one',
  346. after: ['3']
  347. }
  348. ]
  349. expect(sortMenuItems(items)).to.deep.equal(expected)
  350. })
  351. it('can merge multiple groups when given a list of before/after commands', () => {
  352. const items = [
  353. {
  354. id: '1',
  355. label: 'one'
  356. },
  357. { type: 'separator' },
  358. {
  359. id: '2',
  360. label: 'two'
  361. },
  362. { type: 'separator' },
  363. {
  364. id: '3',
  365. label: 'three',
  366. after: ['1', '2']
  367. }
  368. ]
  369. const expected = [
  370. {
  371. id: '2',
  372. label: 'two'
  373. },
  374. {
  375. id: '1',
  376. label: 'one'
  377. },
  378. {
  379. id: '3',
  380. label: 'three',
  381. after: ['1', '2']
  382. }
  383. ]
  384. expect(sortMenuItems(items)).to.deep.equal(expected)
  385. })
  386. it('can merge multiple groups based on both before/after commands', () => {
  387. const items = [
  388. {
  389. id: '1',
  390. label: 'one'
  391. },
  392. { type: 'separator' },
  393. {
  394. id: '2',
  395. label: 'two'
  396. },
  397. { type: 'separator' },
  398. {
  399. id: '3',
  400. label: 'three',
  401. after: ['1'],
  402. before: ['2']
  403. }
  404. ]
  405. const expected = [
  406. {
  407. id: '1',
  408. label: 'one'
  409. },
  410. {
  411. id: '3',
  412. label: 'three',
  413. after: ['1'],
  414. before: ['2']
  415. },
  416. {
  417. id: '2',
  418. label: 'two'
  419. }
  420. ]
  421. expect(sortMenuItems(items)).to.deep.equal(expected)
  422. })
  423. })
  424. it('should position before existing item', () => {
  425. const menu = Menu.buildFromTemplate([
  426. {
  427. id: '2',
  428. label: 'two'
  429. }, {
  430. id: '3',
  431. label: 'three'
  432. }, {
  433. id: '1',
  434. label: 'one',
  435. before: ['2']
  436. }
  437. ])
  438. expect(menu.items[0].label).to.equal('one')
  439. expect(menu.items[1].label).to.equal('two')
  440. expect(menu.items[2].label).to.equal('three')
  441. })
  442. it('should position after existing item', () => {
  443. const menu = Menu.buildFromTemplate([
  444. {
  445. id: '2',
  446. label: 'two',
  447. after: ['1']
  448. },
  449. {
  450. id: '1',
  451. label: 'one'
  452. }, {
  453. id: '3',
  454. label: 'three'
  455. }
  456. ])
  457. expect(menu.items[0].label).to.equal('one')
  458. expect(menu.items[1].label).to.equal('two')
  459. expect(menu.items[2].label).to.equal('three')
  460. })
  461. it('should filter excess menu separators', () => {
  462. const menuOne = Menu.buildFromTemplate([
  463. {
  464. type: 'separator'
  465. }, {
  466. label: 'a'
  467. }, {
  468. label: 'b'
  469. }, {
  470. label: 'c'
  471. }, {
  472. type: 'separator'
  473. }
  474. ])
  475. expect(menuOne.items).to.have.length(3)
  476. expect(menuOne.items[0].label).to.equal('a')
  477. expect(menuOne.items[1].label).to.equal('b')
  478. expect(menuOne.items[2].label).to.equal('c')
  479. const menuTwo = Menu.buildFromTemplate([
  480. {
  481. type: 'separator'
  482. }, {
  483. type: 'separator'
  484. }, {
  485. label: 'a'
  486. }, {
  487. label: 'b'
  488. }, {
  489. label: 'c'
  490. }, {
  491. type: 'separator'
  492. }, {
  493. type: 'separator'
  494. }
  495. ])
  496. expect(menuTwo.items).to.have.length(3)
  497. expect(menuTwo.items[0].label).to.equal('a')
  498. expect(menuTwo.items[1].label).to.equal('b')
  499. expect(menuTwo.items[2].label).to.equal('c')
  500. })
  501. it('should continue inserting items at next index when no specifier is present', () => {
  502. const menu = Menu.buildFromTemplate([
  503. {
  504. id: '2',
  505. label: 'two'
  506. }, {
  507. id: '3',
  508. label: 'three'
  509. }, {
  510. id: '4',
  511. label: 'four'
  512. }, {
  513. id: '5',
  514. label: 'five'
  515. }, {
  516. id: '1',
  517. label: 'one',
  518. before: ['2']
  519. }
  520. ])
  521. expect(menu.items[0].label).to.equal('one')
  522. expect(menu.items[1].label).to.equal('two')
  523. expect(menu.items[2].label).to.equal('three')
  524. expect(menu.items[3].label).to.equal('four')
  525. expect(menu.items[4].label).to.equal('five')
  526. })
  527. })
  528. })
  529. describe('Menu.getMenuItemById', () => {
  530. it('should return the item with the given id', () => {
  531. const menu = Menu.buildFromTemplate([
  532. {
  533. label: 'View',
  534. submenu: [
  535. {
  536. label: 'Enter Fullscreen',
  537. accelerator: 'ControlCommandF',
  538. id: 'fullScreen'
  539. }
  540. ]
  541. }
  542. ])
  543. const fsc = menu.getMenuItemById('fullScreen')
  544. expect(menu.items[0].submenu.items[0]).to.equal(fsc)
  545. })
  546. it('should return the separator with the given id', () => {
  547. const menu = Menu.buildFromTemplate([
  548. {
  549. label: 'Item 1',
  550. id: 'item_1'
  551. },
  552. {
  553. id: 'separator',
  554. type: 'separator'
  555. },
  556. {
  557. label: 'Item 2',
  558. id: 'item_2'
  559. }
  560. ])
  561. const separator = menu.getMenuItemById('separator')
  562. expect(separator).to.be.an('object')
  563. expect(separator).to.equal(menu.items[1])
  564. })
  565. })
  566. describe('Menu.insert', () => {
  567. it('should throw when attempting to insert at out-of-range indices', () => {
  568. const menu = Menu.buildFromTemplate([
  569. { label: '1' },
  570. { label: '2' },
  571. { label: '3' }
  572. ])
  573. const item = new MenuItem({ label: 'badInsert' })
  574. expect(() => {
  575. menu.insert(9999, item)
  576. }).to.throw(/Position 9999 cannot be greater than the total MenuItem count/)
  577. expect(() => {
  578. menu.insert(-9999, item)
  579. }).to.throw(/Position -9999 cannot be less than 0/)
  580. })
  581. it('should store item in @items by its index', () => {
  582. const menu = Menu.buildFromTemplate([
  583. { label: '1' },
  584. { label: '2' },
  585. { label: '3' }
  586. ])
  587. const item = new MenuItem({ label: 'inserted' })
  588. menu.insert(1, item)
  589. expect(menu.items[0].label).to.equal('1')
  590. expect(menu.items[1].label).to.equal('inserted')
  591. expect(menu.items[2].label).to.equal('2')
  592. expect(menu.items[3].label).to.equal('3')
  593. })
  594. })
  595. describe('Menu.append', () => {
  596. it('should add the item to the end of the menu', () => {
  597. const menu = Menu.buildFromTemplate([
  598. { label: '1' },
  599. { label: '2' },
  600. { label: '3' }
  601. ])
  602. const item = new MenuItem({ label: 'inserted' })
  603. menu.append(item)
  604. expect(menu.items[0].label).to.equal('1')
  605. expect(menu.items[1].label).to.equal('2')
  606. expect(menu.items[2].label).to.equal('3')
  607. expect(menu.items[3].label).to.equal('inserted')
  608. })
  609. })
  610. describe('Menu.popup', () => {
  611. let w = null
  612. let menu
  613. beforeEach(() => {
  614. w = new BrowserWindow({ show: false, width: 200, height: 200 })
  615. menu = Menu.buildFromTemplate([
  616. { label: '1' },
  617. { label: '2' },
  618. { label: '3' }
  619. ])
  620. })
  621. afterEach(() => {
  622. menu.closePopup()
  623. menu.closePopup(w)
  624. return closeWindow(w).then(() => { w = null })
  625. })
  626. it('throws an error if options is not an object', () => {
  627. expect(() => {
  628. menu.popup('this is a string, not an object')
  629. }).to.throw(/Options must be an object/)
  630. })
  631. it('allows for options to be optional', () => {
  632. expect(() => {
  633. menu.popup({})
  634. }).to.not.throw()
  635. })
  636. it('should emit menu-will-show event', (done) => {
  637. menu.on('menu-will-show', () => { done() })
  638. menu.popup({ window: w })
  639. })
  640. it('should emit menu-will-close event', (done) => {
  641. menu.on('menu-will-close', () => { done() })
  642. menu.popup({ window: w })
  643. menu.closePopup()
  644. })
  645. it('returns immediately', () => {
  646. const input = { window: w, x: 100, y: 101 }
  647. const output = menu.popup(input)
  648. expect(output.x).to.equal(input.x)
  649. expect(output.y).to.equal(input.y)
  650. expect(output.browserWindow).to.equal(input.window)
  651. })
  652. it('works without a given BrowserWindow and options', () => {
  653. const { browserWindow, x, y } = menu.popup({ x: 100, y: 101 })
  654. expect(browserWindow.constructor.name).to.equal('BrowserWindow')
  655. expect(x).to.equal(100)
  656. expect(y).to.equal(101)
  657. })
  658. it('works with a given BrowserWindow, options and callback', (done) => {
  659. const { x, y } = menu.popup({
  660. window: w,
  661. x: 100,
  662. y: 101,
  663. callback: () => done()
  664. })
  665. expect(x).to.equal(100)
  666. expect(y).to.equal(101)
  667. menu.closePopup()
  668. })
  669. it('works with a given BrowserWindow, no options, and a callback', (done) => {
  670. menu.popup({ window: w, callback: () => done() })
  671. menu.closePopup()
  672. })
  673. })
  674. describe('Menu.setApplicationMenu', () => {
  675. it('sets a menu', () => {
  676. const menu = Menu.buildFromTemplate([
  677. { label: '1' },
  678. { label: '2' }
  679. ])
  680. Menu.setApplicationMenu(menu)
  681. expect(Menu.getApplicationMenu()).to.not.be.null()
  682. })
  683. it('unsets a menu with null', () => {
  684. Menu.setApplicationMenu(null)
  685. expect(Menu.getApplicationMenu()).to.be.null()
  686. })
  687. })
  688. describe('menu accelerators', () => {
  689. let testFn = it
  690. try {
  691. // We have other tests that check if native modules work, if we fail to require
  692. // robotjs let's skip this test to avoid false negatives
  693. require('robotjs')
  694. } catch (err) {
  695. testFn = it.skip
  696. }
  697. const sendRobotjsKey = (key, modifiers = [], delay = 500) => {
  698. return new Promise((resolve, reject) => {
  699. require('robotjs').keyTap(key, modifiers)
  700. setTimeout(() => {
  701. resolve()
  702. }, delay)
  703. })
  704. }
  705. testFn('menu accelerators perform the specified action', async () => {
  706. const menu = Menu.buildFromTemplate([
  707. {
  708. label: 'Test',
  709. submenu: [
  710. {
  711. label: 'Test Item',
  712. accelerator: 'Ctrl+T',
  713. click: () => {
  714. // Test will succeed, only when the menu accelerator action
  715. // is triggered
  716. Promise.resolve()
  717. },
  718. id: 'test'
  719. }
  720. ]
  721. }
  722. ])
  723. Menu.setApplicationMenu(menu)
  724. expect(Menu.getApplicationMenu()).to.not.be.null()
  725. await sendRobotjsKey('t', 'control')
  726. })
  727. })
  728. })