command-registry-spec.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. const CommandRegistry = require('../src/command-registry');
  2. const _ = require('underscore-plus');
  3. describe('CommandRegistry', () => {
  4. let registry, parent, child, grandchild;
  5. beforeEach(() => {
  6. parent = document.createElement('div');
  7. child = document.createElement('div');
  8. grandchild = document.createElement('div');
  9. parent.classList.add('parent');
  10. child.classList.add('child');
  11. grandchild.classList.add('grandchild');
  12. child.appendChild(grandchild);
  13. parent.appendChild(child);
  14. document.querySelector('#jasmine-content').appendChild(parent);
  15. registry = new CommandRegistry();
  16. registry.attach(parent);
  17. });
  18. afterEach(() => registry.destroy());
  19. describe('when a command event is dispatched on an element', () => {
  20. it('invokes callbacks with selectors matching the target', () => {
  21. let called = false;
  22. registry.add('.grandchild', 'command', function(event) {
  23. expect(this).toBe(grandchild);
  24. expect(event.type).toBe('command');
  25. expect(event.eventPhase).toBe(Event.BUBBLING_PHASE);
  26. expect(event.target).toBe(grandchild);
  27. expect(event.currentTarget).toBe(grandchild);
  28. called = true;
  29. });
  30. grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  31. expect(called).toBe(true);
  32. });
  33. it('invokes callbacks with selectors matching ancestors of the target', () => {
  34. const calls = [];
  35. registry.add('.child', 'command', function(event) {
  36. expect(this).toBe(child);
  37. expect(event.target).toBe(grandchild);
  38. expect(event.currentTarget).toBe(child);
  39. calls.push('child');
  40. });
  41. registry.add('.parent', 'command', function(event) {
  42. expect(this).toBe(parent);
  43. expect(event.target).toBe(grandchild);
  44. expect(event.currentTarget).toBe(parent);
  45. calls.push('parent');
  46. });
  47. grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  48. expect(calls).toEqual(['child', 'parent']);
  49. });
  50. it('invokes inline listeners prior to listeners applied via selectors', () => {
  51. const calls = [];
  52. registry.add('.grandchild', 'command', () => calls.push('grandchild'));
  53. registry.add(child, 'command', () => calls.push('child-inline'));
  54. registry.add('.child', 'command', () => calls.push('child'));
  55. registry.add('.parent', 'command', () => calls.push('parent'));
  56. grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  57. expect(calls).toEqual(['grandchild', 'child-inline', 'child', 'parent']);
  58. });
  59. it('orders multiple matching listeners for an element by selector specificity', () => {
  60. child.classList.add('foo', 'bar');
  61. const calls = [];
  62. registry.add('.foo.bar', 'command', () => calls.push('.foo.bar'));
  63. registry.add('.foo', 'command', () => calls.push('.foo'));
  64. registry.add('.bar', 'command', () => calls.push('.bar')); // specificity ties favor commands added later, like CSS
  65. grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  66. expect(calls).toEqual(['.foo.bar', '.bar', '.foo']);
  67. });
  68. it('orders inline listeners by reverse registration order', () => {
  69. const calls = [];
  70. registry.add(child, 'command', () => calls.push('child1'));
  71. registry.add(child, 'command', () => calls.push('child2'));
  72. child.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  73. expect(calls).toEqual(['child2', 'child1']);
  74. });
  75. it('stops bubbling through ancestors when .stopPropagation() is called on the event', () => {
  76. const calls = [];
  77. registry.add('.parent', 'command', () => calls.push('parent'));
  78. registry.add('.child', 'command', () => calls.push('child-2'));
  79. registry.add('.child', 'command', event => {
  80. calls.push('child-1');
  81. event.stopPropagation();
  82. });
  83. const dispatchedEvent = new CustomEvent('command', { bubbles: true });
  84. spyOn(dispatchedEvent, 'stopPropagation');
  85. grandchild.dispatchEvent(dispatchedEvent);
  86. expect(calls).toEqual(['child-1', 'child-2']);
  87. expect(dispatchedEvent.stopPropagation).toHaveBeenCalled();
  88. });
  89. it('stops invoking callbacks when .stopImmediatePropagation() is called on the event', () => {
  90. const calls = [];
  91. registry.add('.parent', 'command', () => calls.push('parent'));
  92. registry.add('.child', 'command', () => calls.push('child-2'));
  93. registry.add('.child', 'command', event => {
  94. calls.push('child-1');
  95. event.stopImmediatePropagation();
  96. });
  97. const dispatchedEvent = new CustomEvent('command', { bubbles: true });
  98. spyOn(dispatchedEvent, 'stopImmediatePropagation');
  99. grandchild.dispatchEvent(dispatchedEvent);
  100. expect(calls).toEqual(['child-1']);
  101. expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled();
  102. });
  103. it('forwards .preventDefault() calls from the synthetic event to the original', () => {
  104. registry.add('.child', 'command', event => event.preventDefault());
  105. const dispatchedEvent = new CustomEvent('command', { bubbles: true });
  106. spyOn(dispatchedEvent, 'preventDefault');
  107. grandchild.dispatchEvent(dispatchedEvent);
  108. expect(dispatchedEvent.preventDefault).toHaveBeenCalled();
  109. });
  110. it('forwards .abortKeyBinding() calls from the synthetic event to the original', () => {
  111. registry.add('.child', 'command', event => event.abortKeyBinding());
  112. const dispatchedEvent = new CustomEvent('command', { bubbles: true });
  113. dispatchedEvent.abortKeyBinding = jasmine.createSpy('abortKeyBinding');
  114. grandchild.dispatchEvent(dispatchedEvent);
  115. expect(dispatchedEvent.abortKeyBinding).toHaveBeenCalled();
  116. });
  117. it('copies non-standard properties from the original event to the synthetic event', () => {
  118. let syntheticEvent = null;
  119. registry.add('.child', 'command', event => (syntheticEvent = event));
  120. const dispatchedEvent = new CustomEvent('command', { bubbles: true });
  121. dispatchedEvent.nonStandardProperty = 'testing';
  122. grandchild.dispatchEvent(dispatchedEvent);
  123. expect(syntheticEvent.nonStandardProperty).toBe('testing');
  124. });
  125. it('allows listeners to be removed via a disposable returned by ::add', () => {
  126. let calls = [];
  127. const disposable1 = registry.add('.parent', 'command', () =>
  128. calls.push('parent')
  129. );
  130. const disposable2 = registry.add('.child', 'command', () =>
  131. calls.push('child')
  132. );
  133. disposable1.dispose();
  134. grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  135. expect(calls).toEqual(['child']);
  136. calls = [];
  137. disposable2.dispose();
  138. grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  139. expect(calls).toEqual([]);
  140. });
  141. it('allows multiple commands to be registered under one selector when called with an object', () => {
  142. let calls = [];
  143. const disposable = registry.add('.child', {
  144. 'command-1'() {
  145. calls.push('command-1');
  146. },
  147. 'command-2'() {
  148. calls.push('command-2');
  149. }
  150. });
  151. grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }));
  152. grandchild.dispatchEvent(new CustomEvent('command-2', { bubbles: true }));
  153. expect(calls).toEqual(['command-1', 'command-2']);
  154. calls = [];
  155. disposable.dispose();
  156. grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }));
  157. grandchild.dispatchEvent(new CustomEvent('command-2', { bubbles: true }));
  158. expect(calls).toEqual([]);
  159. });
  160. it('invokes callbacks registered with ::onWillDispatch and ::onDidDispatch', () => {
  161. const sequence = [];
  162. registry.onDidDispatch(event => sequence.push(['onDidDispatch', event]));
  163. registry.add('.grandchild', 'command', event =>
  164. sequence.push(['listener', event])
  165. );
  166. registry.onWillDispatch(event =>
  167. sequence.push(['onWillDispatch', event])
  168. );
  169. grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }));
  170. expect(sequence[0][0]).toBe('onWillDispatch');
  171. expect(sequence[1][0]).toBe('listener');
  172. expect(sequence[2][0]).toBe('onDidDispatch');
  173. expect(
  174. sequence[0][1] === sequence[1][1] && sequence[1][1] === sequence[2][1]
  175. ).toBe(true);
  176. expect(sequence[0][1].constructor).toBe(CustomEvent);
  177. expect(sequence[0][1].target).toBe(grandchild);
  178. });
  179. });
  180. describe('::add(selector, commandName, callback)', () => {
  181. it('throws an error when called with an invalid selector', () => {
  182. const badSelector = '<>';
  183. let addError = null;
  184. try {
  185. registry.add(badSelector, 'foo:bar', () => {});
  186. } catch (error) {
  187. addError = error;
  188. }
  189. expect(addError.message).toContain(badSelector);
  190. });
  191. it('throws an error when called with a null callback and selector target', () => {
  192. const badCallback = null;
  193. expect(() => {
  194. registry.add('.selector', 'foo:bar', badCallback);
  195. }).toThrow(new Error('Cannot register a command with a null listener.'));
  196. });
  197. it('throws an error when called with a null callback and object target', () => {
  198. const badCallback = null;
  199. expect(() => {
  200. registry.add(document.body, 'foo:bar', badCallback);
  201. }).toThrow(new Error('Cannot register a command with a null listener.'));
  202. });
  203. it('throws an error when called with an object listener without a didDispatch method', () => {
  204. const badListener = {
  205. title: 'a listener without a didDispatch callback',
  206. description: 'this should throw an error'
  207. };
  208. expect(() => {
  209. registry.add(document.body, 'foo:bar', badListener);
  210. }).toThrow(
  211. new Error(
  212. 'Listener must be a callback function or an object with a didDispatch method.'
  213. )
  214. );
  215. });
  216. });
  217. describe('::findCommands({target})', () => {
  218. it('returns command descriptors that can be invoked on the target or its ancestors', () => {
  219. registry.add('.parent', 'namespace:command-1', () => {});
  220. registry.add('.child', 'namespace:command-2', () => {});
  221. registry.add('.grandchild', 'namespace:command-3', () => {});
  222. registry.add('.grandchild.no-match', 'namespace:command-4', () => {});
  223. registry.add(grandchild, 'namespace:inline-command-1', () => {});
  224. registry.add(child, 'namespace:inline-command-2', () => {});
  225. const commands = registry.findCommands({ target: grandchild });
  226. const nonJqueryCommands = _.reject(commands, cmd => cmd.jQuery);
  227. expect(nonJqueryCommands).toEqual([
  228. {
  229. name: 'namespace:inline-command-1',
  230. displayName: 'Namespace: Inline Command 1'
  231. },
  232. { name: 'namespace:command-3', displayName: 'Namespace: Command 3' },
  233. {
  234. name: 'namespace:inline-command-2',
  235. displayName: 'Namespace: Inline Command 2'
  236. },
  237. { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
  238. { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
  239. ]);
  240. });
  241. it('returns command descriptors with arbitrary metadata if set in a listener object', () => {
  242. registry.add('.grandchild', 'namespace:command-1', () => {});
  243. registry.add('.grandchild', 'namespace:command-2', {
  244. displayName: 'Custom Command 2',
  245. metadata: {
  246. some: 'other',
  247. object: 'data'
  248. },
  249. didDispatch() {}
  250. });
  251. registry.add('.grandchild', 'namespace:command-3', {
  252. name: 'some:other:incorrect:commandname',
  253. displayName: 'Custom Command 3',
  254. metadata: {
  255. some: 'other',
  256. object: 'data'
  257. },
  258. didDispatch() {}
  259. });
  260. const commands = registry.findCommands({ target: grandchild });
  261. expect(commands).toEqual([
  262. {
  263. displayName: 'Namespace: Command 1',
  264. name: 'namespace:command-1'
  265. },
  266. {
  267. displayName: 'Custom Command 2',
  268. metadata: {
  269. some: 'other',
  270. object: 'data'
  271. },
  272. name: 'namespace:command-2'
  273. },
  274. {
  275. displayName: 'Custom Command 3',
  276. metadata: {
  277. some: 'other',
  278. object: 'data'
  279. },
  280. name: 'namespace:command-3'
  281. }
  282. ]);
  283. });
  284. it('returns command descriptors with arbitrary metadata if set on a listener function', () => {
  285. function listener() {}
  286. listener.displayName = 'Custom Command 2';
  287. listener.metadata = {
  288. some: 'other',
  289. object: 'data'
  290. };
  291. registry.add('.grandchild', 'namespace:command-2', listener);
  292. const commands = registry.findCommands({ target: grandchild });
  293. expect(commands).toEqual([
  294. {
  295. displayName: 'Custom Command 2',
  296. metadata: {
  297. some: 'other',
  298. object: 'data'
  299. },
  300. name: 'namespace:command-2'
  301. }
  302. ]);
  303. });
  304. });
  305. describe('::dispatch(target, commandName)', () => {
  306. it('simulates invocation of the given command ', () => {
  307. let called = false;
  308. registry.add('.grandchild', 'command', function(event) {
  309. expect(this).toBe(grandchild);
  310. expect(event.type).toBe('command');
  311. expect(event.eventPhase).toBe(Event.BUBBLING_PHASE);
  312. expect(event.target).toBe(grandchild);
  313. expect(event.currentTarget).toBe(grandchild);
  314. called = true;
  315. });
  316. registry.dispatch(grandchild, 'command');
  317. expect(called).toBe(true);
  318. });
  319. it('returns a promise if any listeners matched the command', () => {
  320. registry.add('.grandchild', 'command', () => {});
  321. expect(registry.dispatch(grandchild, 'command').constructor.name).toBe(
  322. 'Promise'
  323. );
  324. expect(registry.dispatch(grandchild, 'bogus')).toBe(null);
  325. expect(registry.dispatch(parent, 'command')).toBe(null);
  326. });
  327. it('returns a promise that resolves when the listeners resolve', async () => {
  328. jasmine.useRealClock();
  329. registry.add('.grandchild', 'command', () => 1);
  330. registry.add('.grandchild', 'command', () => Promise.resolve(2));
  331. registry.add(
  332. '.grandchild',
  333. 'command',
  334. () =>
  335. new Promise(resolve => {
  336. setTimeout(() => {
  337. resolve(3);
  338. }, 1);
  339. })
  340. );
  341. const values = await registry.dispatch(grandchild, 'command');
  342. expect(values).toEqual([3, 2, 1]);
  343. });
  344. it('returns a promise that rejects when a listener is rejected', async () => {
  345. jasmine.useRealClock();
  346. registry.add('.grandchild', 'command', () => 1);
  347. registry.add('.grandchild', 'command', () => Promise.resolve(2));
  348. registry.add(
  349. '.grandchild',
  350. 'command',
  351. () =>
  352. new Promise((resolve, reject) => {
  353. setTimeout(() => {
  354. reject(3); // eslint-disable-line prefer-promise-reject-errors
  355. }, 1);
  356. })
  357. );
  358. let value;
  359. try {
  360. value = await registry.dispatch(grandchild, 'command');
  361. } catch (err) {
  362. value = err;
  363. }
  364. expect(value).toBe(3);
  365. });
  366. });
  367. describe('::getSnapshot and ::restoreSnapshot', () =>
  368. it('removes all command handlers except for those in the snapshot', () => {
  369. registry.add('.parent', 'namespace:command-1', () => {});
  370. registry.add('.child', 'namespace:command-2', () => {});
  371. const snapshot = registry.getSnapshot();
  372. registry.add('.grandchild', 'namespace:command-3', () => {});
  373. expect(registry.findCommands({ target: grandchild }).slice(0, 3)).toEqual(
  374. [
  375. { name: 'namespace:command-3', displayName: 'Namespace: Command 3' },
  376. { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
  377. { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
  378. ]
  379. );
  380. registry.restoreSnapshot(snapshot);
  381. expect(registry.findCommands({ target: grandchild }).slice(0, 2)).toEqual(
  382. [
  383. { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
  384. { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
  385. ]
  386. );
  387. registry.add('.grandchild', 'namespace:command-3', () => {});
  388. registry.restoreSnapshot(snapshot);
  389. expect(registry.findCommands({ target: grandchild }).slice(0, 2)).toEqual(
  390. [
  391. { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
  392. { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
  393. ]
  394. );
  395. }));
  396. describe('::attach(rootNode)', () =>
  397. it('adds event listeners for any previously-added commands', () => {
  398. const registry2 = new CommandRegistry();
  399. const commandSpy = jasmine.createSpy('command-callback');
  400. registry2.add('.grandchild', 'command-1', commandSpy);
  401. grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }));
  402. expect(commandSpy).not.toHaveBeenCalled();
  403. registry2.attach(parent);
  404. grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }));
  405. expect(commandSpy).toHaveBeenCalled();
  406. }));
  407. });