import { mocked } from 'jest-mock'; import registry from '@atlassian/clientside-extensions-registry'; import * as debug from '@atlassian/clientside-extensions-debug'; import type { ExtensionAPI, ExtensionDescriptor } from '@atlassian/clientside-extensions-registry'; import type { ExtensionPointUpdate, Options } from './types'; import { getExtensionPointSubscription, getValidatedExtensions } from './ExtensionsObservable'; import Mock = jest.Mock; jest.mock('@atlassian/clientside-extensions-registry'); debug.setLoggingEnabled(true); debug.setDebugEnabled(true); // avoid spamming the console with error/warn messages // eslint-disable-next-line no-underscore-dangle debug._deregisterDefaultLogger(); type MockSubjectPayload = { descriptors: ExtensionDescriptor[]; loadingState: boolean; }; type MockLocationObserver = (payload: MockSubjectPayload) => void; function createMockLocationObserver(): MockLocationObserver { let locationObserver: MockLocationObserver; mocked(registry.getLocation).mockClear(); mocked(registry.getLocation).mockImplementation(() => ({ subscribe: (observer) => { locationObserver = observer; return { unsubscribe: () => {}, }; }, })); return (payload) => locationObserver(payload); } const supportedExtension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: () => ({ type: 'button' }), }; const unsupportedExtension: ExtensionDescriptor = { key: 'fake-2', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, }; const LOCATION = 'fake-location'; describe('getExtensionPointSubscription', () => { const onDebugSpy = jest.fn(); debug.observeLogger(onDebugSpy); let locationObserver: MockLocationObserver; beforeEach(() => { onDebugSpy.mockClear(); locationObserver = createMockLocationObserver(); }); it('should return an observable', () => { const observable = getExtensionPointSubscription(LOCATION, null); expect(observable.subscribe).toBeInstanceOf(Function); }); it('should allow to subscribe to updates', () => { const observerSpy = jest.fn(); getExtensionPointSubscription(LOCATION, null).subscribe(observerSpy); locationObserver({ descriptors: [], loadingState: true }); expect(observerSpy).toBeCalled(); }); it('should allow to unsubscribe from updates again', () => { const observerSpy = jest.fn(); const { unsubscribe } = getExtensionPointSubscription(LOCATION, null).subscribe(observerSpy); locationObserver({ descriptors: [], loadingState: true }); unsubscribe(); locationObserver({ descriptors: [], loadingState: false }); expect(observerSpy).toBeCalledTimes(1); }); it('should notify the observer of registered extensions', () => { const observerSpy = jest.fn(); getExtensionPointSubscription(LOCATION, null).subscribe(observerSpy); locationObserver({ descriptors: [supportedExtension], loadingState: false }); expect(observerSpy).toBeCalled(); expect(observerSpy.mock.calls[0][0]).toBeTruthy(); const { descriptors, loadingState } = observerSpy.mock.calls[0][0].state; expect(descriptors.length).toBe(1); expect(loadingState).toBe(false); }); it('should only provide descriptors to observer once loading is over', () => { const observerSpy = jest.fn(); getExtensionPointSubscription(LOCATION, null).subscribe(observerSpy); locationObserver({ descriptors: [], loadingState: true }); locationObserver({ descriptors: [supportedExtension, unsupportedExtension], loadingState: true }); locationObserver({ descriptors: [supportedExtension, unsupportedExtension], loadingState: false }); expect(observerSpy).toBeCalledTimes(3); // first call const argFirst = observerSpy.mock.calls[0][0] as ExtensionPointUpdate; const { descriptors: descriptorsFirst, loadingState: loadingFirst } = argFirst.state; expect(descriptorsFirst.length).toBe(0); expect(loadingFirst).toBe(true); // second call const argSecond = observerSpy.mock.calls[1][0] as ExtensionPointUpdate; const { descriptors: descriptorsSecond, loadingState: loadingSecond } = argSecond.state; expect(descriptorsSecond.length).toBe(0); expect(loadingSecond).toBe(true); // third call const argThird = observerSpy.mock.calls[2][0] as ExtensionPointUpdate; const { descriptors: descriptorsThird, loadingState: loadingThird } = argThird.state; expect(descriptorsThird.length).toBe(2); expect(loadingThird).toBe(false); expect(descriptorsThird[0].key).toBe(supportedExtension.key); expect(descriptorsThird[1].key).toBe(unsupportedExtension.key); }); it('should provide registered extensions with the provided context', () => { const someContext = { foo: 'bar' }; const attributeProviderSpy = jest.fn(); getExtensionPointSubscription(LOCATION, someContext); locationObserver({ descriptors: [ { key: 'fake-1', location: LOCATION, weight: 10, attributes: { label: 'a fake label' }, attributesProvider: attributeProviderSpy, }, ], loadingState: false, }); expect(attributeProviderSpy).toBeCalledTimes(1); expect(attributeProviderSpy.mock.calls[0][1]).toBe(someContext); }); it('should update if a new extension is registered', () => { const observerSpy = jest.fn(); getExtensionPointSubscription(LOCATION, null).subscribe(observerSpy); locationObserver({ descriptors: [], loadingState: false }); locationObserver({ descriptors: [supportedExtension], loadingState: false }); locationObserver({ descriptors: [supportedExtension, unsupportedExtension], loadingState: false }); expect(observerSpy).toBeCalledTimes(3); // first call const argFirst = observerSpy.mock.calls[0][0] as ExtensionPointUpdate; const { descriptors: descriptorsFirst } = argFirst.state; expect(descriptorsFirst.length).toBe(0); // second call const argSecond = observerSpy.mock.calls[1][0] as ExtensionPointUpdate; const { descriptors: descriptorsSecond } = argSecond.state; expect(descriptorsSecond.length).toBe(1); // third call const argThird = observerSpy.mock.calls[2][0] as ExtensionPointUpdate; const { descriptors: descriptorsThird } = argThird.state; expect(descriptorsThird.length).toBe(2); }); it('should update if an extension updates its attributes', async () => { expect.assertions(5); const observerSpy = jest.fn(); getExtensionPointSubscription(LOCATION, null).subscribe(observerSpy); let apiReference: ExtensionAPI | undefined; const selfUpdatingExtension = { key: 'updating-extension', location: LOCATION, weight: 230, attributes: { label: 'a fake label', type: 'button' }, attributesProvider: (api: ExtensionAPI) => { apiReference = api; return {}; }, }; locationObserver({ descriptors: [supportedExtension, unsupportedExtension, selfUpdatingExtension], loadingState: false }); expect(observerSpy).toBeCalled(); observerSpy.mockClear(); (apiReference as ExtensionAPI).updateAttributes({ foo: 'bar' }); // wait for the update to go through which is a tick away await new Promise((resolve) => { setTimeout(() => { expect(observerSpy).toBeCalled(); const { update } = observerSpy.mock.calls[0][0]; expect(update.length).toBe(1); expect(update[0].key).toBe('updating-extension'); expect(update[0].attributes.foo).toBe('bar'); resolve(); }, 50); }); }); it('should batch updates together', async () => { expect.assertions(8); const observerSpy = jest.fn(); getExtensionPointSubscription(LOCATION, null).subscribe(observerSpy); let apiReference1: ExtensionAPI | undefined; const selfUpdatingExtension1 = { key: 'updating-extension-1', location: LOCATION, weight: 230, attributes: { label: 'a fake label', type: 'button' }, attributesProvider: (api: ExtensionAPI) => { apiReference1 = api; return {}; }, }; let apiReference2: ExtensionAPI | undefined; const selfUpdatingExtension2 = { key: 'updating-extension-2', location: LOCATION, weight: 230, attributes: { label: 'a fake label', type: 'button' }, attributesProvider: (api: ExtensionAPI) => { apiReference2 = api; return {}; }, }; locationObserver({ descriptors: [selfUpdatingExtension1, selfUpdatingExtension2], loadingState: false }); observerSpy.mockClear(); (apiReference1 as ExtensionAPI).updateAttributes({ foo: 'bar' }); (apiReference2 as ExtensionAPI).updateAttributes({ foo: 'bar' }); (apiReference1 as ExtensionAPI).updateAttributes({ foo: 'foo of 1' }); (apiReference2 as ExtensionAPI).updateAttributes({ foo: 'foo of 2' }); // wait for the update to go through which is a tick away await new Promise((resolve) => { setTimeout(() => { expect(observerSpy).toBeCalledTimes(1); const { update } = observerSpy.mock.calls[0][0]; expect(update.length).toBe(2); expect(update[0].key).toBe('updating-extension-1'); expect(update[0].attributes.foo).toBe('foo of 1'); expect(update[0].attributes.foo).toBe('foo of 1'); expect(update[1].key).toBe('updating-extension-2'); expect(update[1].attributes.foo).toBe('foo of 2'); expect(update[1].attributes.foo).toBe('foo of 2'); resolve(); }, 50); }); }); }); describe('getValidatedExtensions', () => { const onDebugSpy = jest.fn(); const context = { foo: 'bar' }; debug.observeLogger(onDebugSpy); let locationObserver: MockLocationObserver; let contextValidatorSpy: Mock; let attributeValidatorSpy: Mock; beforeEach(() => { onDebugSpy.mockClear(); locationObserver = createMockLocationObserver(); contextValidatorSpy = jest.fn().mockReturnValue({ errors: [], warnings: [] }); attributeValidatorSpy = jest.fn().mockReturnValue({ errors: [], warnings: [] }); }); it('should not validate the context if no context is specified', () => { getValidatedExtensions(LOCATION, null, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy, } as Options); expect(contextValidatorSpy).not.toBeCalled(); }); it('should validate the context if a context is specified', () => { getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy, }); expect(contextValidatorSpy).toBeCalled(); expect(contextValidatorSpy.mock.calls[0][0]).toBe(context); }); it('should throw an error if the context validation fails', () => { expect(() => getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy.mockReturnValue({ errors: ['some error'], warnings: [], }), attributeValidator: attributeValidatorSpy, }), ).toThrow(`The context provided for extension-location "fake-location" does not match the schema`); }); it('should not throw an error if the context validation has warnings', () => { expect(() => getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy.mockReturnValue({ errors: [], warnings: ['some warning'], }), attributeValidator: attributeValidatorSpy, }), ).not.toThrow(); }); it('should return an observable', () => { const observable = getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy, }); expect(observable.subscribe).toBeInstanceOf(Function); }); it('should allow to subscribe to updates', () => { const observerSpy = jest.fn(); getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy, }).subscribe(observerSpy); locationObserver({ descriptors: [], loadingState: true }); expect(observerSpy).toBeCalled(); }); it('should allow to unsubscribe from updates again', () => { const observerSpy = jest.fn(); const { unsubscribe } = getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy, }).subscribe(observerSpy); locationObserver({ descriptors: [], loadingState: true }); locationObserver({ descriptors: [], loadingState: false }); unsubscribe(); locationObserver({ descriptors: [supportedExtension], loadingState: false }); expect(observerSpy).toBeCalledTimes(2); }); it('should call attributeValidatorSpy with extension attributes', () => { const observerSpy = jest.fn(); getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy, }).subscribe(observerSpy); locationObserver({ descriptors: [supportedExtension], loadingState: false }); expect(attributeValidatorSpy).toBeCalledTimes(1); expect(attributeValidatorSpy.mock.calls[0][0]).toStrictEqual({ ...supportedExtension.attributes, ...supportedExtension.attributesProvider!({} as ExtensionAPI, null), }); }); it('should declare all extensions as valid if validator always return "ok"', () => { const observerSpy = jest.fn(); getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy.mockReturnValue({ errors: [], warnings: [] }), }).subscribe(observerSpy); locationObserver({ descriptors: [supportedExtension, unsupportedExtension], loadingState: false }); expect(observerSpy).toBeCalledTimes(1); const [supported, unsupported] = observerSpy.mock.calls[0][0]; expect(supported.length).toBe(2); expect(unsupported.length).toBe(0); }); it('should move extensions into "unsupported" if validation fails', () => { const observerSpy = jest.fn(); getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy .mockReturnValueOnce({ errors: [], warnings: [] }) .mockReturnValueOnce({ errors: ['some error'], warnings: [] }), }).subscribe(observerSpy); locationObserver({ descriptors: [supportedExtension, unsupportedExtension], loadingState: false }); expect(observerSpy).toBeCalledTimes(1); const [supported, unsupported] = observerSpy.mock.calls[0][0]; expect(supported.length).toBe(1); expect(supported[0].key).toBe(supportedExtension.key); expect(unsupported.length).toBe(1); expect(unsupported[0].key).toBe(unsupportedExtension.key); }); it('should declare all extensions as valid if validator has any warnings', () => { const observerSpy = jest.fn(); getValidatedExtensions(LOCATION, context, { contextValidator: contextValidatorSpy, attributeValidator: attributeValidatorSpy.mockReturnValue({ errors: [], warnings: ['some warning'] }), }).subscribe(observerSpy); locationObserver({ descriptors: [supportedExtension, unsupportedExtension], loadingState: false }); expect(observerSpy).toBeCalledTimes(1); const [supported, unsupported] = observerSpy.mock.calls[0][0]; expect(supported.length).toBe(2); expect(unsupported.length).toBe(0); }); });