import { mocked } from 'jest-mock'; import { act, renderHook, waitFor } from '@testing-library/react'; import type { Context, ExtensionAPI, ExtensionAttributes, ExtensionDescriptor } from '@atlassian/clientside-extensions-registry'; import registry from '@atlassian/clientside-extensions-registry'; import * as debug from '@atlassian/clientside-extensions-debug'; import type { LoggerPayload } from '@atlassian/clientside-extensions-debug'; import { LogLevel } from '@atlassian/clientside-extensions-debug'; import type { Validator } from '@atlassian/clientside-extensions-schema'; import { ErrorLevels } from '@atlassian/clientside-extensions-schema'; import type { Options } from './types'; import { useExtensions, useExtensionsAll, useExtensionsLoadingState, useExtensionsUnsupported } from './useExtensions'; 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; type ExtensionDescriptorWithContextInAttributes = ExtensionDescriptor }>; function applyAttributesProvider(descriptor: ExtensionDescriptor) { const { attributesProvider, ...restDescriptor } = descriptor; return { ...restDescriptor, attributes: { ...restDescriptor.attributes, ...(attributesProvider ? attributesProvider({ updateAttributes: () => {}, onCleanup: () => {} }, null) : {}), }, }; } 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 attributeValidator: Validator = () => ({ warnings: [], errors: [] }); const contextValidator: Validator = () => ({ warnings: [], errors: [] }); const options: Options = { attributeValidator, contextValidator, }; 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 wrongTypeExtension: ExtensionDescriptor = { key: 'fake-3', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: () => ({ type: 'oopps' }), }; const brokenExtension: ExtensionDescriptor = { key: 'fake-4', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: () => { throw new Error('fake error'); }, }; const brokenOnActionExtension: ExtensionDescriptor = { key: 'fake-button', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: () => ({ type: 'button', onAction: () => { throw new Error('error from onAction'); }, }), }; const toEvaluatedDecorator = (descriptor: ExtensionDescriptor) => { const { attributesProvider, attributes, ...rest } = descriptor; const ensureAP = attributesProvider ?? (() => ({})); const evaluatedAttributes = { ...attributes, ...ensureAP({} as ExtensionAPI, null) }; return { ...rest, attributes: evaluatedAttributes, }; }; describe('useExtensionsLoadingState hook', () => { let locationObserver: MockLocationObserver; beforeEach(() => { locationObserver = createMockLocationObserver(); }); it('should notify when the loading state changes', () => { let loading; const { result } = renderHook(() => useExtensionsLoadingState('fake-location', null, options)); loading = result.current; expect(loading).toBe(true); act(() => { locationObserver({ descriptors: [], loadingState: false }); }); loading = result.current; expect(loading).toBe(false); }); }); describe('useExtensions hook', () => { let locationObserver: MockLocationObserver; beforeEach(() => { locationObserver = createMockLocationObserver(); }); it('should return a list with only the supported extensions', () => { const localValidate: Validator = (descriptor) => { return JSON.stringify(descriptor) === '{"label":"a fake label","type":"button"}' ? { errors: [], warnings: [] } : { errors: [ { severity: ErrorLevels.ERROR, error: 'asd', }, ], warnings: [], }; }; const { result } = renderHook(() => useExtensions('fake-location', null, { ...options, attributeValidator: localValidate })); act(() => { locationObserver({ descriptors: [supportedExtension, unsupportedExtension, wrongTypeExtension], loadingState: false }); }); const extensions = result.current; expect(extensions).toEqual([ { ...applyAttributesProvider(supportedExtension), }, ]); }); }); describe('useExtensionsUnsupported hook', () => { let locationObserver: MockLocationObserver; beforeEach(() => { locationObserver = createMockLocationObserver(); }); it('should return a list with only the unsupported extensions', () => { const localValidate: Validator = (descriptor) => { const { type } = descriptor as { type: string }; return type === 'button' ? { errors: [], warnings: [] } : { errors: [ { severity: ErrorLevels.ERROR, error: 'asd', }, ], warnings: [], }; }; const { result } = renderHook(() => useExtensionsUnsupported('fake-location', null, { ...options, attributeValidator: localValidate }), ); act(() => { locationObserver({ descriptors: [supportedExtension, unsupportedExtension, wrongTypeExtension], loadingState: false }); }); const extensions = result.current; expect(extensions).toEqual([toEvaluatedDecorator(unsupportedExtension), toEvaluatedDecorator(wrongTypeExtension)]); }); }); describe('useExtensionsAll hook', () => { const loggerLogs: LoggerPayload[] = []; const onDebugSpy = jest.fn().mockImplementation((payload) => { loggerLogs.push(payload); }); debug.observeLogger(onDebugSpy); let locationObserver: MockLocationObserver; beforeEach(() => { locationObserver = createMockLocationObserver(); }); afterEach(() => { loggerLogs.length = 0; onDebugSpy.mockClear(); }); it('should return an empty array if no extensions are registered for an extension point', () => { const { result } = renderHook(() => useExtensionsAll('fake-location', null, options)); act(() => { locationObserver({ descriptors: [], loadingState: false }); }); const [extensions, unsupportedExtensions, loading] = result.current; expect(extensions).toEqual([]); expect(unsupportedExtensions).toEqual([]); expect(loading).toBe(false); }); it('should only return extensions of a supported type and log validation type error', () => { const localValidate: Validator = (descriptor) => { const { type } = descriptor as { type: string }; return type !== 'oopps' ? { warnings: [], errors: [] } : { errors: [ { severity: ErrorLevels.ERROR, error: 'asd', }, ], warnings: [], }; }; const { result } = renderHook(() => useExtensionsAll('fake-location', null, { ...options, attributeValidator: localValidate })); act(() => { locationObserver({ descriptors: [supportedExtension, wrongTypeExtension], loadingState: false }); }); const [extensions] = result.current; expect(extensions).toEqual([{ ...applyAttributesProvider(supportedExtension) }]); expect(loggerLogs).toContainEqual( expect.objectContaining({ level: LogLevel.error, message: expect.stringContaining('Schema validation for extension "fake-3" returned errors'), }), ); }); it('should update the attributes when using the ”updateAttributes” function', async () => { const changeAttributesExtension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: ({ updateAttributes }) => ({ type: 'button', onAction: () => updateAttributes({ label: 'a new fake label' }), }), }; const { result } = renderHook(() => useExtensionsAll('fake-location', null, options)); act(() => { locationObserver({ descriptors: [changeAttributesExtension], loadingState: false }); }); act(() => { const [extensions] = result.current; const [testExtension] = extensions as ExtensionDescriptor void }>[]; testExtension.attributes.onAction(); }); await waitFor(() => { const [extensionsUpdated] = result.current; const [testExtensionChanged] = extensionsUpdated; expect(testExtensionChanged.attributes.label).toBe('a new fake label'); }); }); it('should catch errors when calling update attributes API', () => { const brokenUpdateAttributesExtension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: ({ updateAttributes }) => ({ type: 'button', onAction: () => updateAttributes(() => { throw new Error('update attributes error'); }), }), }; const { result } = renderHook(() => useExtensionsAll('fake-location', null, options)); act(() => { locationObserver({ descriptors: [brokenUpdateAttributesExtension], loadingState: false }); }); expect(loggerLogs).not.toContainEqual( expect.objectContaining({ level: LogLevel.error, }), ); act(() => { const [extensions] = result.current; const [testExtension] = extensions as ExtensionDescriptor void }>[]; testExtension.attributes.onAction(); }); expect(loggerLogs).toContainEqual( expect.objectContaining({ level: LogLevel.error, message: expect.stringContaining('Updating attributes for extension fake-1 failed'), }), ); // The hook should not throw an error, so we just verify it renders successfully expect(result.current).toBeDefined(); }); it('should add new attributes when using `updateAttributes` function', async () => { let localExtensionApi: ExtensionAPI; const extension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: {}, attributesProvider: (extensionApi) => { localExtensionApi = extensionApi; return { type: 'button', }; }, }; const { result } = renderHook(() => useExtensions('fake-location', null, options)); // First render act(() => { locationObserver({ descriptors: [extension], loadingState: false }); }); const [extensionFirstRender] = result.current; expect(extensionFirstRender.attributes).not.toHaveProperty('beforeIcon'); // Add new attributes act(() => localExtensionApi.updateAttributes({ beforeIcon: 'my-icon', }), ); await waitFor(() => { const [extensionSecondRender] = result.current; expect(extensionSecondRender.attributes).toHaveProperty('beforeIcon', 'my-icon'); }); }); it('should extend the existing attributes when using `updateAttributes` function with a functional update', async () => { interface LocalExtensionAttributes extends ExtensionAttributes { type?: string; label?: string; counter?: number; } let localExtensionApi: ExtensionAPI; const extension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: {}, attributesProvider(extensionApi) { localExtensionApi = extensionApi; return { type: 'button', label: 'foo', counter: 0, }; }, }; const { result } = renderHook(() => useExtensions('fake-location', null, options)); // First render act(() => { locationObserver({ descriptors: [extension], loadingState: false }); }); expect(result.current[0].attributes).toEqual({ type: 'button', label: 'foo', counter: 0, }); // First update act(() => localExtensionApi.updateAttributes({ label: 'bar', counter: 1, }), ); await waitFor(() => { expect(result.current[0].attributes).toEqual({ type: 'button', label: 'bar', counter: 1, }); }); // Second update with manual attributes merge act(() => localExtensionApi.updateAttributes((prevAttributes) => { return { ...prevAttributes, label: 'baz', counter: prevAttributes.counter ? prevAttributes.counter + 1 : 1, }; }), ); await waitFor(() => { expect(result.current[0].attributes).toEqual({ type: 'button', label: 'baz', counter: 2, }); }); // Third update with auto-merge act(() => localExtensionApi.updateAttributes((prevAttributes: LocalExtensionAttributes) => ({ counter: prevAttributes.counter ? prevAttributes.counter * 2 : 2, })), ); await waitFor(() => { expect(result.current[0].attributes).toEqual({ type: 'button', label: 'baz', counter: 4, }); }); }); it('should provide a clean up API that gets called when the extension point updates', async () => { const cleanupSpy = jest.fn(); const cleanupExtension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: ({ onCleanup }) => { onCleanup(cleanupSpy); return { type: 'button', }; }, }; // eslint-disable-next-line @typescript-eslint/no-shadow const { rerender } = renderHook(({ name, context, options }) => useExtensionsAll(name, context, options), { initialProps: { name: 'fake-location', context: { value: 1, }, options, }, }); act(() => { locationObserver({ descriptors: [cleanupExtension], loadingState: false }); }); rerender({ name: 'fake-location', context: { value: 3, }, options, }); expect(cleanupSpy).toBeCalledTimes(1); // TODO: unmounting the hook should call the cleanup callbacks, but it isn't. // unmount(); // expect(cleanupSpy).toBeCalledTimes(2); }); it('should catch errors when calling onCleanup API', () => { const cleanupSpy = jest.fn(() => { throw new Error('clean up error'); }); const cleanupExtension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: { label: 'a fake label' }, attributesProvider: ({ onCleanup }) => { onCleanup(cleanupSpy); return { type: 'button', }; }, }; // eslint-disable-next-line @typescript-eslint/no-shadow const { rerender } = renderHook(({ name, context, options }) => useExtensionsAll(name, context, options), { initialProps: { name: 'fake-location', context: { value: 1, }, options, }, }); act(() => { locationObserver({ descriptors: [cleanupExtension], loadingState: false }); }); rerender({ name: 'fake-location', context: { value: 3, }, options, }); expect(cleanupSpy).toBeCalledTimes(1); expect(loggerLogs).toContainEqual( expect.objectContaining({ level: LogLevel.error, message: expect.stringContaining('Failed to execute cleanup callback'), }), ); }); it('should catch error when calling the attributes provider', () => { const { result } = renderHook(() => useExtensionsAll('fake-location', null, options)); act(() => { locationObserver({ descriptors: [supportedExtension, brokenExtension], loadingState: false }); }); const [extensions] = result.current; expect(extensions).toEqual([{ ...applyAttributesProvider(supportedExtension) }]); expect(loggerLogs).toContainEqual( expect.objectContaining({ level: LogLevel.error, message: expect.stringContaining('Calling the attributes provider for extension fake-4 failed'), }), ); }); it('should safe guard onAction attributes to prevent them from breaking the app when executed', () => { const { result } = renderHook(() => useExtensionsAll('fake-location', null, options)); act(() => { locationObserver({ descriptors: [brokenOnActionExtension], loadingState: false }); }); const [extensions] = result.current; const [buttonExtension] = extensions; expect(buttonExtension.attributes.onAction).not.toThrow(); }); it('should throw an error if no context schema is provided when providing context', () => { expect(() => { renderHook(() => useExtensionsAll('fake-location', { info: 'test-info' }, { attributeValidator })); }).toThrow('No context validator specified for extension point "fake-location"'); }); it('should call the attributes provider only once when passed context did not changed', () => { const stableContext = { foo: 'bar', }; const attributesExtension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: { type: 'button', }, attributesProvider: (_, context) => ({ context, }), }; const { result, rerender } = renderHook(({ context }) => useExtensionsAll('fake-location', context, options), { initialProps: { context: stableContext, }, }); // First call rerender({ context: stableContext, }); act(() => { locationObserver({ descriptors: [attributesExtension], loadingState: false }); }); const [supportedDescriptorsFirstUpdate] = result.current; const [{ attributes: attributesFirstUpdate }] = supportedDescriptorsFirstUpdate as ExtensionDescriptorWithContextInAttributes[]; expect(attributesFirstUpdate.context).toBe(stableContext); // Second call rerender({ context: stableContext, }); act(() => { locationObserver({ descriptors: [attributesExtension], loadingState: false }); }); const [supportedDescriptorsSecondUpdate] = result.current; const [{ attributes: attributesSecondUpdate }] = supportedDescriptorsSecondUpdate as ExtensionDescriptorWithContextInAttributes[]; expect(attributesSecondUpdate.context).toBe(stableContext); expect(mocked(registry.getLocation)).toHaveBeenCalledTimes(1); }); it('should call the attributes provider twice when passed context did changed', () => { const firstContext: Context<{}> = { foo: 'bar', }; const secondContext: Context<{}> = { biz: 'baz', }; const attributesExtension: ExtensionDescriptor = { key: 'fake-1', location: 'fake-location', weight: 10, attributes: { type: 'button', }, attributesProvider: (_, context) => ({ context, }), }; const { result, rerender } = renderHook(({ context }) => useExtensionsAll('fake-location', context, options), { initialProps: { context: firstContext, }, }); // First update rerender({ context: firstContext, }); act(() => { locationObserver({ descriptors: [attributesExtension], loadingState: false }); }); const [supportedDescriptorsFirstUpdate] = result.current; const [{ attributes: attributesFirstUpdate }] = supportedDescriptorsFirstUpdate as ExtensionDescriptorWithContextInAttributes[]; expect(attributesFirstUpdate.context).toBe(firstContext); // Second update rerender({ context: secondContext, }); act(() => { locationObserver({ descriptors: [attributesExtension], loadingState: false }); }); const [supportedDescriptorsSecondUpdate] = result.current; const [{ attributes: attributesSecondUpdate }] = supportedDescriptorsSecondUpdate as ExtensionDescriptorWithContextInAttributes[]; expect(attributesSecondUpdate.context).toBe(secondContext); expect(mocked(registry.getLocation)).toHaveBeenCalledTimes(2); }); it('should log an error when attributes provider did return a non-supported value', () => { const { result } = renderHook(() => useExtensionsAll('fake-location', null, options)); const firstExtension: ExtensionDescriptor = { key: 'first', location: 'fake-location', weight: 0, attributes: {}, // @ts-expect-error - Ignore the type since we don't know what extension developer might return in the runtime attributesProvider() { return null; }, }; const secondExtension: ExtensionDescriptor = { key: 'second', location: 'fake-location', weight: 0, attributes: {}, // @ts-expect-error - Ignore the type since we don't know what extension developer might return in the runtime attributesProvider() { return false; }, }; const thirdExtension: ExtensionDescriptor = { key: 'third', location: 'fake-location', weight: 0, attributes: {}, // @ts-expect-error - Ignore the type since we don't know what extension developer might return in the runtime attributesProvider() { return -Infinity; }, }; const fourthExtension: ExtensionDescriptor = { key: 'fourth', location: 'fake-location', weight: 0, attributes: {}, // @ts-expect-error - Ignore the type since we don't know what extension developer might return in the runtime attributesProvider() { return () => {}; }, }; const fifthExtension: ExtensionDescriptor = { key: 'fifth', location: 'fake-location', weight: 0, attributes: {}, // @ts-expect-error - Ignore the type since we don't know what extension developer might return in the runtime attributesProvider() { return []; }, }; const sixthExtension: ExtensionDescriptor = { key: 'sixth', location: 'fake-location', weight: 0, attributes: {}, // @ts-expect-error - Ignore the type since we don't know what extension developer might return in the runtime attributesProvider() { return ''; }, }; const seventhExtension: ExtensionDescriptor = { key: 'seventh', location: 'fake-location', weight: 0, attributes: {}, // @ts-expect-error - Ignore the type since we don't know what extension developer might return in the runtime attributesProvider() { return undefined; }, }; const descriptors = [ firstExtension, secondExtension, thirdExtension, fourthExtension, fifthExtension, sixthExtension, seventhExtension, ]; act(() => { locationObserver({ descriptors, loadingState: false, }); }); const [extensions] = result.current; expect(extensions).toHaveLength(0); const errors = loggerLogs.filter((log) => log.level === LogLevel.error); expect(errors).toHaveLength(7); }); });