import { Log } from '@servicetitan/log-service'; import { Provider } from '@servicetitan/react-ioc'; import { renderHook } from '@testing-library/react'; import { FC, PropsWithChildren } from 'react'; import { EXPOSED_DEPENDENCIES_TOKEN, EXPOSED_INSTANCE_DEPENDENCIES_TOKEN, ExposedDependencies, ExposedInstanceDependencies, } from '../common'; import { usePrefetch } from '../use-prefetch'; import { getBundleInfo, supportsPrefetch } from '../utils'; jest.mock('../utils', () => ({ ...jest.requireActual('../utils'), getBundleInfo: jest.fn(), supportsPrefetch: jest.fn(), })); describe(`[web-components] ${usePrefetch.name}`, () => { const src = 'https://example.com'; let singletons: Parameters[0]['singletons']; let urls: Awaited>['urls']; let options: Parameters['prefetch']>[1]; beforeAll(() => { Object.assign(globalThis, { // eslint-disable-next-line @typescript-eslint/naming-convention EXPOSED_DEPENDENCIES: { foo: { version: '1.0.0', variable: 'bar' } }, // eslint-disable-next-line @typescript-eslint/naming-convention EXPOSED_INSTANCE_DEPENDENCIES: { launchDarkly: { version: '1.0.0' } }, }); }); beforeEach(() => { jest.clearAllMocks(); singletons = undefined; urls = { css: [], js: [] }; options = undefined; jest.mocked(getBundleInfo).mockResolvedValue({ urls } as any); jest.mocked(supportsPrefetch).mockReturnValue(true); }); const subject = async () => { const Wrapper: FC = ({ children }) => ( {children} ); const { prefetch } = renderHook(() => usePrefetch(), { wrapper: Wrapper }).result.current; return prefetch(src, options); }; test('retrieves bundle info', async () => { await subject(); expect(getBundleInfo).toHaveBeenCalledWith({ exposedDependencies: EXPOSED_DEPENDENCIES, exposedInstanceDependencies: EXPOSED_INSTANCE_DEPENDENCIES, mainPackageUrl: src, cache: -1, retries: 0, }); }); describe('with EXPOSED_DEPENDENCIES_TOKEN', () => { const exposedDependencies: ExposedDependencies = { baz: { version: '2.0.0', variable: 'qux' }, }; beforeEach(() => { singletons = [ { provide: EXPOSED_DEPENDENCIES_TOKEN, useValue: exposedDependencies, }, ]; }); test('passes exposed dependencies to getBundleInfo', async () => { await subject(); expect(getBundleInfo).toHaveBeenCalledWith( expect.objectContaining({ exposedDependencies }) ); }); }); describe('with EXPOSED_INSTANCE_DEPENDENCIES_TOKEN', () => { const exposedInstanceDependencies: ExposedInstanceDependencies = { launchDarkly: { version: '2.0.0', ldService: {} as any }, }; beforeEach(() => { singletons = [ { provide: EXPOSED_INSTANCE_DEPENDENCIES_TOKEN, useValue: exposedInstanceDependencies, }, ]; }); test('passes exposed instance dependencies to getBundleInfo', async () => { await subject(); expect(getBundleInfo).toHaveBeenCalledWith( expect.objectContaining({ exposedInstanceDependencies, }) ); }); }); describe('with cache option', () => { beforeEach(() => (options = { cache: 1000 })); test('passes cache option to getBundleInto', async () => { await subject(); expect(getBundleInfo).toHaveBeenCalledWith( expect.objectContaining({ cache: options!.cache }) ); }); }); describe('when bundle contains urls', () => { const css = ['foo', 'bar'].map(name => `${src}/${name}.bundle.css`); const js = ['foo', 'bar'].map(name => `${src}/${name}.bundle.js`); beforeEach(() => { Object.assign(urls, { css, js }); Array.from(document.head.getElementsByTagName('link')).forEach(link => link.remove()); }); function getLinks() { return Array.from(document.head.getElementsByTagName('link')).map( ({ as, crossOrigin, href, rel }) => ({ as, crossOrigin, href, rel }) ); } function itDoesNotAddLinks() { test('does not add links', async () => { const originalLinks = getLinks(); await subject(); expect(getLinks()).toEqual(originalLinks); }); } test('adds prefetch links to document head', async () => { await subject(); expect(getLinks()).toEqual([ ...css.map(href => ({ as: 'style', crossOrigin: 'anonymous', href, rel: 'prefetch', })), ...js.map(href => ({ as: 'script', crossOrigin: 'anonymous', href, rel: 'prefetch', })), ]); }); describe('when bundle is already cached', () => { beforeEach(() => { jest.mocked(getBundleInfo).mockResolvedValue({ urls, cacheHit: true } as any); }); itDoesNotAddLinks(); }); describe('when links already exist', () => { function addLink(props: Record) { const link = document.createElement('link'); Object.assign(link, props); document.head.append(link); } beforeEach(() => { css.forEach(href => addLink({ href })); js.forEach(href => addLink({ href })); }); itDoesNotAddLinks(); }); describe('when rel=prefetch is not supported', () => { beforeEach(() => jest.mocked(supportsPrefetch).mockReturnValue(false)); test('manually fetches urls with low priority', async () => { const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({} as any); await subject(); [...css, ...js].forEach(url => expect(fetchSpy).toHaveBeenCalledWith(url, { priority: 'low' }) ); }); }); }); describe('when getBundleInfo throws an error', () => { const error = 'Oops!'; beforeEach(() => jest.mocked(getBundleInfo).mockRejectedValue(error)); test('suppresses the error', async () => { await expect(subject()).resolves.toBe(undefined); }); describe('when Log is provided', () => { const log = { warning: jest.fn() }; beforeEach(() => { singletons = [{ provide: Log, useValue: log }]; }); test('logs warning', async () => { await subject(); expect(log.warning).toHaveBeenCalledWith({ category: 'Microfrontends.Prefetch', message: expect.stringMatching(/failed to prefetch/i), data: error, }); }); }); }); });