diff --git a/.changeset/grumpy-swans-taste.md b/.changeset/grumpy-swans-taste.md new file mode 100644 index 0000000000..ed4a745e20 --- /dev/null +++ b/.changeset/grumpy-swans-taste.md @@ -0,0 +1,5 @@ +--- +"@clerk/shared": patch +--- + +Improve internal test coverage and fix small bug inside `callWithRetry` diff --git a/packages/shared/src/__tests__/browser.test.ts b/packages/shared/src/__tests__/browser.test.ts index 5a801e7d9b..1c34776789 100644 --- a/packages/shared/src/__tests__/browser.test.ts +++ b/packages/shared/src/__tests__/browser.test.ts @@ -1,9 +1,66 @@ -import { inBrowser, isValidBrowserOnline, userAgentIsRobot } from '../browser'; +import { inBrowser, isValidBrowser, isValidBrowserOnline, userAgentIsRobot } from '../browser'; describe('inBrowser()', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('returns true if window is defined', () => { expect(inBrowser()).toBe(true); }); + it('returns false if window is undefined', () => { + const windowSpy = jest.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + expect(inBrowser()).toBe(false); + }); +}); + +describe('isValidBrowser', () => { + let userAgentGetter: any; + let webdriverGetter: any; + + beforeEach(() => { + userAgentGetter = jest.spyOn(window.navigator, 'userAgent', 'get'); + webdriverGetter = jest.spyOn(window.navigator, 'webdriver', 'get'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns false if not in browser', () => { + const windowSpy = jest.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + + expect(isValidBrowser()).toBe(false); + }); + + it('returns true if in browser, navigator is not a bot, and webdriver is not enabled', () => { + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0', + ); + webdriverGetter.mockReturnValue(false); + + expect(isValidBrowser()).toBe(true); + }); + + it('returns false if navigator is a bot', () => { + userAgentGetter.mockReturnValue('msnbot-NewsBlogs/2.0b (+http://search.msn.com/msnbot.htm)'); + webdriverGetter.mockReturnValue(false); + + expect(isValidBrowser()).toBe(false); + }); + + it('returns false if webdriver is enabled', () => { + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0', + ); + webdriverGetter.mockReturnValue(true); + + expect(isValidBrowser()).toBe(false); + }); }); describe('detectUserAgentRobot', () => { diff --git a/packages/shared/src/__tests__/callWithRetry.test.ts b/packages/shared/src/__tests__/callWithRetry.test.ts new file mode 100644 index 0000000000..bb276d769c --- /dev/null +++ b/packages/shared/src/__tests__/callWithRetry.test.ts @@ -0,0 +1,23 @@ +import { callWithRetry } from '../callWithRetry'; + +describe('callWithRetry', () => { + test('should return the result of the function if it succeeds', async () => { + const fn = jest.fn().mockResolvedValue('result'); + const result = await callWithRetry(fn); + expect(result).toBe('result'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('should retry the function if it fails', async () => { + const fn = jest.fn().mockRejectedValueOnce(new Error('error')).mockResolvedValueOnce('result'); + const result = await callWithRetry(fn, 1, 2); + expect(result).toBe('result'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + test('should throw an error if the function fails too many times', async () => { + const fn = jest.fn().mockRejectedValue(new Error('error')); + await expect(callWithRetry(fn, 1, 2)).rejects.toThrow('error'); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/shared/src/__tests__/keys.test.ts b/packages/shared/src/__tests__/keys.test.ts index 5867c95e97..5c916a2db2 100644 --- a/packages/shared/src/__tests__/keys.test.ts +++ b/packages/shared/src/__tests__/keys.test.ts @@ -1,7 +1,9 @@ import { buildPublishableKey, createDevOrStagingUrlCache, + isDevelopmentFromApiKey, isLegacyFrontendApiKey, + isProductionFromApiKey, isPublishableKey, parsePublishableKey, } from '../keys'; @@ -90,3 +92,31 @@ describe('isDevOrStagingUrl(url)', () => { expect(isDevOrStagingUrl(a)).toBe(expected); }); }); + +describe('isDevelopmentFromApiKey(key)', () => { + const cases: Array<[string, boolean]> = [ + ['sk_live_Y2xlcmsuY2xlcmsuZGV2JA==', false], + ['sk_test_Y2xlcmsuY2xlcmsuZGV2JA==', true], + ['live_Y2xlcmsuY2xlcmsuZGV2JA==', false], + ['test_Y2xlcmsuY2xlcmsuZGV2JA==', true], + ]; + + test.each(cases)('given %p as a publishable key string, returns %p', (publishableKeyStr, expected) => { + const result = isDevelopmentFromApiKey(publishableKeyStr); + expect(result).toEqual(expected); + }); +}); + +describe('isProductionFromApiKey(key)', () => { + const cases: Array<[string, boolean]> = [ + ['sk_live_Y2xlcmsuY2xlcmsuZGV2JA==', true], + ['sk_test_Y2xlcmsuY2xlcmsuZGV2JA==', false], + ['live_Y2xlcmsuY2xlcmsuZGV2JA==', true], + ['test_Y2xlcmsuY2xlcmsuZGV2JA==', false], + ]; + + test.each(cases)('given %p as a publishable key string, returns %p', (publishableKeyStr, expected) => { + const result = isProductionFromApiKey(publishableKeyStr); + expect(result).toEqual(expected); + }); +}); diff --git a/packages/shared/src/__tests__/url.test.ts b/packages/shared/src/__tests__/url.test.ts index 6410f67c83..7a5f8ce4ef 100644 --- a/packages/shared/src/__tests__/url.test.ts +++ b/packages/shared/src/__tests__/url.test.ts @@ -1,4 +1,4 @@ -import { addClerkPrefix, parseSearchParams, stripScheme } from '../url'; +import { addClerkPrefix, getClerkJsMajorVersionOrTag, getScriptUrl, parseSearchParams, stripScheme } from '../url'; describe('parseSearchParams(queryString)', () => { it('parses query string and returns a URLSearchParams object', () => { @@ -56,3 +56,59 @@ describe('addClerkPrefix(str)', () => { expect(addClerkPrefix(urlInput)).toBe(urlOutput); }); }); + +describe('getClerkJsMajorVersionOrTag', () => { + const stagingFrontendApi = 'foobar.lclstage.dev'; + + it('returns staging if pkgVersion is not provided and frontendApi is staging', () => { + expect(getClerkJsMajorVersionOrTag(stagingFrontendApi)).toBe('staging'); + }); + + it('returns latest if pkgVersion is not provided and frontendApi is not staging', () => { + expect(getClerkJsMajorVersionOrTag('foobar.dev')).toBe('latest'); + }); + + it('returns next if pkgVersion contains next', () => { + expect(getClerkJsMajorVersionOrTag('foobar.dev', '1.2.3-next.4')).toBe('next'); + }); + + it('returns the major version if pkgVersion is provided', () => { + expect(getClerkJsMajorVersionOrTag('foobar.dev', '1.2.3')).toBe('1'); + }); + + it('returns latest if pkgVersion is empty string', () => { + expect(getClerkJsMajorVersionOrTag('foobar.dev', '')).toBe('latest'); + }); +}); + +describe('getScriptUrl', () => { + const frontendApi = 'https://foobar.dev'; + + it('returns URL using the clerkJSVersion if provided', () => { + expect(getScriptUrl(frontendApi, { clerkJSVersion: '1.2.3' })).toBe( + 'https://foobar.dev/npm/@clerk/clerk-js@1.2.3/dist/clerk.browser.js', + ); + }); + + it('returns URL using the latest version if clerkJSVersion & pkgVersion is not provided + frontendApi is not staging', () => { + expect(getScriptUrl(frontendApi, {})).toBe('https://foobar.dev/npm/@clerk/clerk-js@latest/dist/clerk.browser.js'); + }); + + it('returns URL using the major version if only pkgVersion is provided', () => { + expect(getScriptUrl(frontendApi, { pkgVersion: '1.2.3' })).toBe( + 'https://foobar.dev/npm/@clerk/clerk-js@1/dist/clerk.browser.js', + ); + }); + + it('returns URL using the major version if only pkgVersion contains next', () => { + expect(getScriptUrl(frontendApi, { pkgVersion: '1.2.3-next.4' })).toBe( + 'https://foobar.dev/npm/@clerk/clerk-js@next/dist/clerk.browser.js', + ); + }); + + it('returns URL using the staging tag if frontendApi is staging', () => { + expect(getScriptUrl('https://foobar.lclstage.dev', {})).toBe( + 'https://foobar.lclstage.dev/npm/@clerk/clerk-js@staging/dist/clerk.browser.js', + ); + }); +}); diff --git a/packages/shared/src/callWithRetry.ts b/packages/shared/src/callWithRetry.ts index 84050f5659..e2723a3ba5 100644 --- a/packages/shared/src/callWithRetry.ts +++ b/packages/shared/src/callWithRetry.ts @@ -23,6 +23,6 @@ export async function callWithRetry( } await wait(2 ** attempt * 100); - return callWithRetry(fn, attempt + 1); + return callWithRetry(fn, attempt + 1, maxAttempts); } } diff --git a/packages/shared/src/utils/__tests__/createDeferredPromise.test.ts b/packages/shared/src/utils/__tests__/createDeferredPromise.test.ts new file mode 100644 index 0000000000..9cd04a20e9 --- /dev/null +++ b/packages/shared/src/utils/__tests__/createDeferredPromise.test.ts @@ -0,0 +1,22 @@ +import { createDeferredPromise } from '../createDeferredPromise'; + +describe('createDeferredPromise', () => { + test('resolves with correct value', async () => { + const { promise, resolve } = createDeferredPromise(); + const expectedValue = 'hello world'; + resolve(expectedValue); + const result = await promise; + expect(result).toBe(expectedValue); + }); + + test('rejects with correct error', async () => { + const { promise, reject } = createDeferredPromise(); + const expectedError = new Error('something went wrong'); + reject(expectedError); + try { + await promise; + } catch (error) { + expect(error).toBe(expectedError); + } + }); +}); diff --git a/packages/shared/src/utils/__tests__/instance.test.ts b/packages/shared/src/utils/__tests__/instance.test.ts new file mode 100644 index 0000000000..c877a19506 --- /dev/null +++ b/packages/shared/src/utils/__tests__/instance.test.ts @@ -0,0 +1,20 @@ +import { isStaging } from '../instance'; + +describe('isStaging', () => { + it.each([ + ['clerk', false], + ['clerk.com', false], + ['whatever.com', false], + ['clerk.abcef', false], + ['clerk.abcef.12345', false], + ['clerk.abcef.12345.lcl', false], + ['clerk.abcef.12345.lcl.dev', false], + ['clerk.abcef.12345.stg.dev', false], + ['clerk.abcef.12345.lclstage.dev', true], + ['clerk.abcef.12345.stgstage.dev', true], + ['clerk.abcef.12345.clerkstage.dev', true], + ['clerk.abcef.12345.accountsstage.dev', true], + ])('validates the frontendApi format', (str, expected) => { + expect(isStaging(str)).toBe(expected); + }); +}); diff --git a/packages/shared/src/utils/__tests__/runWithExponentialBackOff.test.ts b/packages/shared/src/utils/__tests__/runWithExponentialBackOff.test.ts new file mode 100644 index 0000000000..c80ec1c023 --- /dev/null +++ b/packages/shared/src/utils/__tests__/runWithExponentialBackOff.test.ts @@ -0,0 +1,21 @@ +import { runWithExponentialBackOff } from '../runWithExponentialBackOff'; + +describe('runWithExponentialBackOff', () => { + test('resolves with the result of the callback', async () => { + const result = await runWithExponentialBackOff(() => Promise.resolve('success')); + expect(result).toBe('success'); + }); + + test('retries the callback until it succeeds', async () => { + let attempts = 0; + const result = await runWithExponentialBackOff(() => { + attempts++; + if (attempts < 3) { + throw new Error('failed'); + } + return Promise.resolve('success'); + }); + expect(result).toBe('success'); + expect(attempts).toBe(3); + }); +}); diff --git a/packages/shared/src/utils/instance.ts b/packages/shared/src/utils/instance.ts index 5441634f48..39035fd267 100644 --- a/packages/shared/src/utils/instance.ts +++ b/packages/shared/src/utils/instance.ts @@ -1,3 +1,6 @@ +/** + * Check if the frontendApi ends with a staging domain + */ export function isStaging(frontendApi: string): boolean { return ( frontendApi.endsWith('.lclstage.dev') ||