const CommandRegistry = require('../src/command-registry'); const _ = require('underscore-plus'); describe('CommandRegistry', () => { let registry, parent, child, grandchild; beforeEach(() => { parent = document.createElement('div'); child = document.createElement('div'); grandchild = document.createElement('div'); parent.classList.add('parent'); child.classList.add('child'); grandchild.classList.add('grandchild'); child.appendChild(grandchild); parent.appendChild(child); document.querySelector('#jasmine-content').appendChild(parent); registry = new CommandRegistry(); registry.attach(parent); }); afterEach(() => registry.destroy()); describe('when a command event is dispatched on an element', () => { it('invokes callbacks with selectors matching the target', () => { let called = false; registry.add('.grandchild', 'command', function(event) { expect(this).toBe(grandchild); expect(event.type).toBe('command'); expect(event.eventPhase).toBe(Event.BUBBLING_PHASE); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(grandchild); called = true; }); grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(called).toBe(true); }); it('invokes callbacks with selectors matching ancestors of the target', () => { const calls = []; registry.add('.child', 'command', function(event) { expect(this).toBe(child); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(child); calls.push('child'); }); registry.add('.parent', 'command', function(event) { expect(this).toBe(parent); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(parent); calls.push('parent'); }); grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(calls).toEqual(['child', 'parent']); }); it('invokes inline listeners prior to listeners applied via selectors', () => { const calls = []; registry.add('.grandchild', 'command', () => calls.push('grandchild')); registry.add(child, 'command', () => calls.push('child-inline')); registry.add('.child', 'command', () => calls.push('child')); registry.add('.parent', 'command', () => calls.push('parent')); grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(calls).toEqual(['grandchild', 'child-inline', 'child', 'parent']); }); it('orders multiple matching listeners for an element by selector specificity', () => { child.classList.add('foo', 'bar'); const calls = []; registry.add('.foo.bar', 'command', () => calls.push('.foo.bar')); registry.add('.foo', 'command', () => calls.push('.foo')); registry.add('.bar', 'command', () => calls.push('.bar')); // specificity ties favor commands added later, like CSS grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(calls).toEqual(['.foo.bar', '.bar', '.foo']); }); it('orders inline listeners by reverse registration order', () => { const calls = []; registry.add(child, 'command', () => calls.push('child1')); registry.add(child, 'command', () => calls.push('child2')); child.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(calls).toEqual(['child2', 'child1']); }); it('stops bubbling through ancestors when .stopPropagation() is called on the event', () => { const calls = []; registry.add('.parent', 'command', () => calls.push('parent')); registry.add('.child', 'command', () => calls.push('child-2')); registry.add('.child', 'command', event => { calls.push('child-1'); event.stopPropagation(); }); const dispatchedEvent = new CustomEvent('command', { bubbles: true }); spyOn(dispatchedEvent, 'stopPropagation'); grandchild.dispatchEvent(dispatchedEvent); expect(calls).toEqual(['child-1', 'child-2']); expect(dispatchedEvent.stopPropagation).toHaveBeenCalled(); }); it('stops invoking callbacks when .stopImmediatePropagation() is called on the event', () => { const calls = []; registry.add('.parent', 'command', () => calls.push('parent')); registry.add('.child', 'command', () => calls.push('child-2')); registry.add('.child', 'command', event => { calls.push('child-1'); event.stopImmediatePropagation(); }); const dispatchedEvent = new CustomEvent('command', { bubbles: true }); spyOn(dispatchedEvent, 'stopImmediatePropagation'); grandchild.dispatchEvent(dispatchedEvent); expect(calls).toEqual(['child-1']); expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled(); }); it('forwards .preventDefault() calls from the synthetic event to the original', () => { registry.add('.child', 'command', event => event.preventDefault()); const dispatchedEvent = new CustomEvent('command', { bubbles: true }); spyOn(dispatchedEvent, 'preventDefault'); grandchild.dispatchEvent(dispatchedEvent); expect(dispatchedEvent.preventDefault).toHaveBeenCalled(); }); it('forwards .abortKeyBinding() calls from the synthetic event to the original', () => { registry.add('.child', 'command', event => event.abortKeyBinding()); const dispatchedEvent = new CustomEvent('command', { bubbles: true }); dispatchedEvent.abortKeyBinding = jasmine.createSpy('abortKeyBinding'); grandchild.dispatchEvent(dispatchedEvent); expect(dispatchedEvent.abortKeyBinding).toHaveBeenCalled(); }); it('copies non-standard properties from the original event to the synthetic event', () => { let syntheticEvent = null; registry.add('.child', 'command', event => (syntheticEvent = event)); const dispatchedEvent = new CustomEvent('command', { bubbles: true }); dispatchedEvent.nonStandardProperty = 'testing'; grandchild.dispatchEvent(dispatchedEvent); expect(syntheticEvent.nonStandardProperty).toBe('testing'); }); it('allows listeners to be removed via a disposable returned by ::add', () => { let calls = []; const disposable1 = registry.add('.parent', 'command', () => calls.push('parent') ); const disposable2 = registry.add('.child', 'command', () => calls.push('child') ); disposable1.dispose(); grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(calls).toEqual(['child']); calls = []; disposable2.dispose(); grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(calls).toEqual([]); }); it('allows multiple commands to be registered under one selector when called with an object', () => { let calls = []; const disposable = registry.add('.child', { 'command-1'() { calls.push('command-1'); }, 'command-2'() { calls.push('command-2'); } }); grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true })); grandchild.dispatchEvent(new CustomEvent('command-2', { bubbles: true })); expect(calls).toEqual(['command-1', 'command-2']); calls = []; disposable.dispose(); grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true })); grandchild.dispatchEvent(new CustomEvent('command-2', { bubbles: true })); expect(calls).toEqual([]); }); it('invokes callbacks registered with ::onWillDispatch and ::onDidDispatch', () => { const sequence = []; registry.onDidDispatch(event => sequence.push(['onDidDispatch', event])); registry.add('.grandchild', 'command', event => sequence.push(['listener', event]) ); registry.onWillDispatch(event => sequence.push(['onWillDispatch', event]) ); grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true })); expect(sequence[0][0]).toBe('onWillDispatch'); expect(sequence[1][0]).toBe('listener'); expect(sequence[2][0]).toBe('onDidDispatch'); expect( sequence[0][1] === sequence[1][1] && sequence[1][1] === sequence[2][1] ).toBe(true); expect(sequence[0][1].constructor).toBe(CustomEvent); expect(sequence[0][1].target).toBe(grandchild); }); }); describe('::add(selector, commandName, callback)', () => { it('throws an error when called with an invalid selector', () => { const badSelector = '<>'; let addError = null; try { registry.add(badSelector, 'foo:bar', () => {}); } catch (error) { addError = error; } expect(addError.message).toContain(badSelector); }); it('throws an error when called with a null callback and selector target', () => { const badCallback = null; expect(() => { registry.add('.selector', 'foo:bar', badCallback); }).toThrow(new Error('Cannot register a command with a null listener.')); }); it('throws an error when called with a null callback and object target', () => { const badCallback = null; expect(() => { registry.add(document.body, 'foo:bar', badCallback); }).toThrow(new Error('Cannot register a command with a null listener.')); }); it('throws an error when called with an object listener without a didDispatch method', () => { const badListener = { title: 'a listener without a didDispatch callback', description: 'this should throw an error' }; expect(() => { registry.add(document.body, 'foo:bar', badListener); }).toThrow( new Error( 'Listener must be a callback function or an object with a didDispatch method.' ) ); }); }); describe('::findCommands({target})', () => { it('returns command descriptors that can be invoked on the target or its ancestors', () => { registry.add('.parent', 'namespace:command-1', () => {}); registry.add('.child', 'namespace:command-2', () => {}); registry.add('.grandchild', 'namespace:command-3', () => {}); registry.add('.grandchild.no-match', 'namespace:command-4', () => {}); registry.add(grandchild, 'namespace:inline-command-1', () => {}); registry.add(child, 'namespace:inline-command-2', () => {}); const commands = registry.findCommands({ target: grandchild }); const nonJqueryCommands = _.reject(commands, cmd => cmd.jQuery); expect(nonJqueryCommands).toEqual([ { name: 'namespace:inline-command-1', displayName: 'Namespace: Inline Command 1' }, { name: 'namespace:command-3', displayName: 'Namespace: Command 3' }, { name: 'namespace:inline-command-2', displayName: 'Namespace: Inline Command 2' }, { name: 'namespace:command-2', displayName: 'Namespace: Command 2' }, { name: 'namespace:command-1', displayName: 'Namespace: Command 1' } ]); }); it('returns command descriptors with arbitrary metadata if set in a listener object', () => { registry.add('.grandchild', 'namespace:command-1', () => {}); registry.add('.grandchild', 'namespace:command-2', { displayName: 'Custom Command 2', metadata: { some: 'other', object: 'data' }, didDispatch() {} }); registry.add('.grandchild', 'namespace:command-3', { name: 'some:other:incorrect:commandname', displayName: 'Custom Command 3', metadata: { some: 'other', object: 'data' }, didDispatch() {} }); const commands = registry.findCommands({ target: grandchild }); expect(commands).toEqual([ { displayName: 'Namespace: Command 1', name: 'namespace:command-1' }, { displayName: 'Custom Command 2', metadata: { some: 'other', object: 'data' }, name: 'namespace:command-2' }, { displayName: 'Custom Command 3', metadata: { some: 'other', object: 'data' }, name: 'namespace:command-3' } ]); }); it('returns command descriptors with arbitrary metadata if set on a listener function', () => { function listener() {} listener.displayName = 'Custom Command 2'; listener.metadata = { some: 'other', object: 'data' }; registry.add('.grandchild', 'namespace:command-2', listener); const commands = registry.findCommands({ target: grandchild }); expect(commands).toEqual([ { displayName: 'Custom Command 2', metadata: { some: 'other', object: 'data' }, name: 'namespace:command-2' } ]); }); }); describe('::dispatch(target, commandName)', () => { it('simulates invocation of the given command ', () => { let called = false; registry.add('.grandchild', 'command', function(event) { expect(this).toBe(grandchild); expect(event.type).toBe('command'); expect(event.eventPhase).toBe(Event.BUBBLING_PHASE); expect(event.target).toBe(grandchild); expect(event.currentTarget).toBe(grandchild); called = true; }); registry.dispatch(grandchild, 'command'); expect(called).toBe(true); }); it('returns a promise if any listeners matched the command', () => { registry.add('.grandchild', 'command', () => {}); expect(registry.dispatch(grandchild, 'command').constructor.name).toBe( 'Promise' ); expect(registry.dispatch(grandchild, 'bogus')).toBe(null); expect(registry.dispatch(parent, 'command')).toBe(null); }); it('returns a promise that resolves when the listeners resolve', async () => { jasmine.useRealClock(); registry.add('.grandchild', 'command', () => 1); registry.add('.grandchild', 'command', () => Promise.resolve(2)); registry.add( '.grandchild', 'command', () => new Promise(resolve => { setTimeout(() => { resolve(3); }, 1); }) ); const values = await registry.dispatch(grandchild, 'command'); expect(values).toEqual([3, 2, 1]); }); it('returns a promise that rejects when a listener is rejected', async () => { jasmine.useRealClock(); registry.add('.grandchild', 'command', () => 1); registry.add('.grandchild', 'command', () => Promise.resolve(2)); registry.add( '.grandchild', 'command', () => new Promise((resolve, reject) => { setTimeout(() => { reject(3); // eslint-disable-line prefer-promise-reject-errors }, 1); }) ); let value; try { value = await registry.dispatch(grandchild, 'command'); } catch (err) { value = err; } expect(value).toBe(3); }); }); describe('::getSnapshot and ::restoreSnapshot', () => it('removes all command handlers except for those in the snapshot', () => { registry.add('.parent', 'namespace:command-1', () => {}); registry.add('.child', 'namespace:command-2', () => {}); const snapshot = registry.getSnapshot(); registry.add('.grandchild', 'namespace:command-3', () => {}); expect(registry.findCommands({ target: grandchild }).slice(0, 3)).toEqual( [ { name: 'namespace:command-3', displayName: 'Namespace: Command 3' }, { name: 'namespace:command-2', displayName: 'Namespace: Command 2' }, { name: 'namespace:command-1', displayName: 'Namespace: Command 1' } ] ); registry.restoreSnapshot(snapshot); expect(registry.findCommands({ target: grandchild }).slice(0, 2)).toEqual( [ { name: 'namespace:command-2', displayName: 'Namespace: Command 2' }, { name: 'namespace:command-1', displayName: 'Namespace: Command 1' } ] ); registry.add('.grandchild', 'namespace:command-3', () => {}); registry.restoreSnapshot(snapshot); expect(registry.findCommands({ target: grandchild }).slice(0, 2)).toEqual( [ { name: 'namespace:command-2', displayName: 'Namespace: Command 2' }, { name: 'namespace:command-1', displayName: 'Namespace: Command 1' } ] ); })); describe('::attach(rootNode)', () => it('adds event listeners for any previously-added commands', () => { const registry2 = new CommandRegistry(); const commandSpy = jasmine.createSpy('command-callback'); registry2.add('.grandchild', 'command-1', commandSpy); grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true })); expect(commandSpy).not.toHaveBeenCalled(); registry2.attach(parent); grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true })); expect(commandSpy).toHaveBeenCalled(); })); });