import type { LoggerPayload } from '@atlassian/clientside-extensions-debug'; import { onDebug, LogLevel } from '@atlassian/clientside-extensions-debug'; import { mocked } from 'jest-mock'; import type { Observable, Subscription } from '@atlassian/clientside-extensions-base'; import type LocationSubject from './LocationsSubject'; import ClientExtensionsRegistry from './registry'; import { getLocationExtensions, getLocationResources } from './httpUtils'; import type { ExtensionDescriptor, ExtensionAttributesProvider } from './types'; jest.mock('@atlassian/clientside-extensions-debug'); jest.mock('./httpUtils'); type LocationSubjectObserver = Parameters[0]; // Needed in order to resolve/reject promises outside their executor class Deferred { private resolveCallback!: (value: T) => void; private rejectCallback!: (reason: unknown) => void; private deferredPromise: Promise = new Promise((resolve, reject) => { this.rejectCallback = reject; this.resolveCallback = resolve; }); public get promise(): Promise { return this.deferredPromise; } public resolve(value: T): Promise { this.resolveCallback(value); return this.promise; } public reject(reason?: unknown): Promise { this.rejectCallback(reason); return this.promise; } } type UnpackPayloadType = ObservableType extends Observable ? U : never; async function subscribeOnce, PayloadType = UnpackPayloadType>( observable: ObservableType, ): Promise { let subscription: Subscription; const payload = await new Promise((resolve) => { subscription = observable.subscribe((data) => { resolve(data); }); // We use the DeferredSubject so we need to flush it jest.runOnlyPendingTimers(); }); subscription!.unsubscribe(); return payload; } /** * Wrapping all tests in promises to avoid jest/no-test-callback * https://github.com/jest-community/eslint-plugin-jest/blob/master/docs/rules/no-test-callback.md */ describe('registry', () => { // eslint-disable-next-line sonarjs/cognitive-complexity describe('registry location management', () => { const testDescriptors: ExtensionDescriptor[] = [ { key: 'test-extension-1', location: 'test-location', weight: 10, attributes: {} }, { key: 'test-extension-2', location: 'test-location', weight: 10, attributes: {} }, ]; let registry: ClientExtensionsRegistry; let deferredExtensions: Deferred; beforeEach(() => { registry = new ClientExtensionsRegistry(); deferredExtensions = new Deferred(); jest.resetAllMocks(); mocked(getLocationExtensions).mockImplementation(() => deferredExtensions.promise); mocked(getLocationResources).mockImplementation(() => Promise.resolve()); }); it('should warn and return empty list when trying to get a location with an empty name', (done) => { expect.assertions(3); const invalidLocationCase: LocationSubjectObserver = ({ descriptors, loadingState }) => { expect(onDebug).toHaveBeenCalled(); expect(descriptors).toEqual([]); expect(loadingState).toBe(false); done(); }; const cases = [invalidLocationCase]; registry.getLocation('').subscribe((payload) => { const caseResult = cases.shift(); if (caseResult) { caseResult(payload); } }); }); it('should be in loading state while it loads the descriptors and resources', (done) => { expect.assertions(2); const loadingResolveCase: LocationSubjectObserver = ({ loadingState }) => { expect(loadingState).toBe(true); deferredExtensions.resolve([]); }; const loadingFinishedCase: LocationSubjectObserver = ({ loadingState }) => { expect(loadingState).toBe(false); done(); }; const cases = [loadingResolveCase, loadingFinishedCase]; registry.getLocation('test-location').subscribe((payload) => { const caseResult = cases.shift(); if (caseResult) { caseResult(payload); } }); }); it('should notify locations with the list of descriptors even when none of them have resources (legacy web-items)', (done) => { expect.assertions(3); const loadingResolveCase: LocationSubjectObserver = ({ loadingState }) => { expect(loadingState).toBe(true); deferredExtensions.resolve(testDescriptors); }; const descriptorsWithoutResourcesCase: LocationSubjectObserver = ({ descriptors, loadingState }) => { expect(descriptors).toMatchObject(testDescriptors); expect(loadingState).toBe(false); done(); }; const cases = [loadingResolveCase, descriptorsWithoutResourcesCase]; registry.getLocation('test-location').subscribe((payload) => { const caseResult = cases.shift(); if (caseResult) { caseResult(payload); } }); }); it('should warn but not explode if something went wrong getting the extension descriptors/resources', (done) => { expect.assertions(4); const loadingRejectCase: LocationSubjectObserver = ({ loadingState }) => { expect(loadingState).toBe(true); deferredExtensions.reject(); }; const warnCase: LocationSubjectObserver = ({ descriptors, loadingState }) => { expect(onDebug).toHaveBeenCalled(); expect(descriptors).toEqual([]); expect(loadingState).toBe(false); done(); }; const cases = [loadingRejectCase, warnCase]; registry.getLocation('test-location').subscribe((payload) => { const caseResult = cases.shift(); if (caseResult) { caseResult(payload); } }); }); it('should only initialize a location once', (done) => { expect.assertions(10); const loadingCase: LocationSubjectObserver = ({ loadingState }) => { expect(loadingState).toBe(true); }; const loadingResolveCase: LocationSubjectObserver = (payload) => { loadingCase(payload); deferredExtensions.resolve(testDescriptors); }; const receiveDescriptorsCase: LocationSubjectObserver = ({ descriptors, loadingState }) => { expect(loadingState).toBe(false); expect(descriptors).toMatchObject(testDescriptors); expect(getLocationExtensions).toHaveBeenCalledTimes(1); expect(getLocationResources).toHaveBeenCalledTimes(1); }; const receiveDescriptorsDoneCase: LocationSubjectObserver = (payload) => { receiveDescriptorsCase(payload); done(); }; const observer1Cases = [loadingCase, receiveDescriptorsCase]; registry.getLocation('test-location').subscribe((payload) => { const caseResult = observer1Cases.shift(); if (caseResult) { caseResult(payload); } }); // Observer asserts the same, but is the one responsible for executing mock side effects const observer2Cases = [loadingResolveCase, receiveDescriptorsDoneCase]; registry.getLocation('test-location').subscribe((payload) => { const caseResult = observer2Cases.shift(); if (caseResult) { caseResult(payload); } }); }); }); describe('registry entry-points management', () => { const testDescriptors: ExtensionDescriptor[] = [ { key: 'test-extension-1', location: 'test-location', weight: 10, attributes: {} }, { key: 'test-extension-2', location: 'test-location', weight: 10, attributes: {} }, ]; let registry: ClientExtensionsRegistry; let deferredResources: Deferred; const loggerPayload: LoggerPayload[] = []; beforeEach(() => { loggerPayload.length = 0; registry = new ClientExtensionsRegistry(); deferredResources = new Deferred(); jest.resetAllMocks(); mocked(getLocationExtensions).mockImplementation(() => Promise.resolve(testDescriptors)); mocked(getLocationResources).mockImplementation(() => deferredResources.promise); mocked(onDebug).mockImplementation((cb) => { loggerPayload.push(cb(LogLevel)); }); }); it('should issue deprecation warning if trying to register an attribute provider without initial attributes', (done) => { expect.assertions(5); const loadingCase: LocationSubjectObserver = ({ loadingState }) => { expect(loadingState).toBe(true); registry.registerExtension('test-extension-1', () => ({ type: 'link' })); deferredResources.resolve(); }; const registerExtensionWarnCase: LocationSubjectObserver = ({ descriptors, loadingState }) => { expect(loadingState).toBe(false); expect(descriptors).toMatchObject(testDescriptors); expect(onDebug).toHaveBeenCalled(); expect(loggerPayload).toContainEqual( expect.objectContaining({ level: LogLevel.deprecation, }), ); done(); }; const cases = [loadingCase, registerExtensionWarnCase]; registry.getLocation('test-location').subscribe((descriptors) => { const caseResult = cases.shift(); if (caseResult) { caseResult(descriptors); } }); }); it('should issue an error if an extension tries to register without initial attributes or a web-fragment representation', (done) => { expect.assertions(5); const loadingCase: LocationSubjectObserver = ({ loadingState }) => { expect(loadingState).toBe(true); registry.registerExtension('test-fail-extension', () => ({ type: 'link' })); deferredResources.resolve(); }; const registerExtensionWarnCase: LocationSubjectObserver = ({ descriptors, loadingState }) => { expect(loadingState).toBe(false); expect(descriptors).toMatchObject(testDescriptors); expect(onDebug).toHaveBeenCalled(); expect(loggerPayload).toContainEqual( expect.objectContaining({ level: LogLevel.error, }), ); done(); }; const cases = [loadingCase, registerExtensionWarnCase]; registry.getLocation('test-location').subscribe((descriptors) => { const caseResult = cases.shift(); if (caseResult) { caseResult(descriptors); } }); }); it('should add the initial attributes and the attributes provider to the extension-descriptor', (done) => { expect.assertions(3); const EXTENSION_KEY = 'test-extension-3'; const attributesProvider: ExtensionAttributesProvider = () => ({ type: 'link', link: 'https://fake-link', }); const initialDescriptor = { weight: 100, label: 'foo', location: 'test-location-2' }; registry.registerExtension(EXTENSION_KEY, attributesProvider, initialDescriptor); registry.getLocation('test-location-2').subscribe(({ descriptors, loadingState }) => { expect(loadingState).toBe(true); expect(descriptors.length).toBe(1); expect(descriptors[0]).toMatchObject({ key: EXTENSION_KEY, location: initialDescriptor.location, attributes: { label: initialDescriptor.label, weight: initialDescriptor.weight, }, attributesProvider, }); done(); }); }); }); describe('registration flow', () => { const extensionLocation = 'test-location-foo'; const extensionKeyA = 'test-extension-a'; const attributesProviderA: ExtensionAttributesProvider = () => ({ value: extensionKeyA }); const extensionKeyB = 'test-extension-b'; const attributesProviderB: ExtensionAttributesProvider = () => ({ value: extensionKeyB }); let registry: ClientExtensionsRegistry; let deferredLocations: Deferred; let deferredResources: Deferred; beforeEach(() => { registry = new ClientExtensionsRegistry(); deferredLocations = new Deferred(); deferredResources = new Deferred(); jest.useFakeTimers(); mocked(getLocationExtensions).mockImplementation(() => deferredLocations.promise); mocked(getLocationResources).mockImplementation(() => deferredResources.promise); }); afterEach(() => { jest.resetAllMocks(); jest.useRealTimers(); }); it('should register extensions point, register two extensions, load the extensions, and finally mark the extension point as loaded', async () => { expect.assertions(4); const observable = registry.getLocation(extensionLocation); // Register extension point await expect(subscribeOnce(observable)).resolves.toEqual(expect.objectContaining({ loadingState: true })); // Register extensions A and B and assert extension point is not fully loaded yet registry.registerExtension(extensionKeyA, attributesProviderA, { location: extensionLocation, }); registry.registerExtension(extensionKeyB, attributesProviderB, { location: extensionLocation, }); await expect(subscribeOnce(observable)).resolves.toEqual(expect.objectContaining({ loadingState: true })); // Load the legacy web items and assert extension point is not fully loaded yet await deferredLocations.resolve([]); await expect(subscribeOnce(observable)).resolves.toEqual(expect.objectContaining({ loadingState: true })); // Load the extension resources and assert extension point is not fully loaded yet await deferredResources.resolve(); // At this point extension point was loaded await expect(subscribeOnce(observable)).resolves.toEqual(expect.objectContaining({ loadingState: false })); }); }); });