diff --git a/.eslintrc b/.eslintrc index 9d6370270..1a4770ae1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -45,7 +45,12 @@ "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-floating-promises": [ + "error", + { + "ignoreVoid": true + } + ], "@typescript-eslint/require-await": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/ban-types": "off" // TODO: turn on diff --git a/jest.config.js b/jest.config.js index bdb31ab6d..a82d94e28 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,9 +4,8 @@ module.exports = { '/dist/', '/e2e-tests', '/qa', - '/src/__tests__/test-writekeys', - '/src/__tests__/stats-writekey', ], + testMatch: ["**/?(*.)+(test).[jt]s?(x)"], clearMocks: true, testEnvironmentOptions: { resources: 'usable', diff --git a/package.json b/package.json index a79fcc5f2..6901a9cf5 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,14 @@ "cjs": "tsc -p tsconfig.cjs.json", "run-example": "cd example && yarn && yarn dev", "clean": "rm -rf dist", - "lint": "eslint '**/*.{js,jsx,ts,tsx}'", + "lint": "tsc --noEmit && eslint '**/*.{js,jsx,ts,tsx}'", "prepare": "yarn pkg && husky install", "test": "jest" }, "size-limit": [ { "path": "dist/umd/index.js", - "limit": "25.13 KB" + "limit": "25.9 KB" } ], "lint-staged": { diff --git a/src/__tests__/analytics-pre-init.integration.test.ts b/src/__tests__/analytics-pre-init.integration.test.ts new file mode 100644 index 000000000..a6d250d1c --- /dev/null +++ b/src/__tests__/analytics-pre-init.integration.test.ts @@ -0,0 +1,356 @@ +import { AnalyticsBrowser } from '..' +import unfetch from 'unfetch' +import { mocked } from 'ts-jest/utils' +import { Analytics } from '../analytics' +import { AnalyticsBuffered } from '../core/buffer' +import { Context } from '../core/context' +import * as Factory from '../test-helpers/factories' +import { sleep } from '../test-helpers/sleep' +import { setGlobalCDNUrl } from '../lib/parse-cdn' + +jest.mock('unfetch') + +const mockFetchSettingsResponse = () => { + mocked(unfetch).mockImplementation(() => + Factory.createSuccess({ integrations: {} }) + ) +} + +const writeKey = 'foo' + +const errMsg = 'errMsg' + +describe('Pre-initialization', () => { + const trackSpy = jest.spyOn(Analytics.prototype, 'track') + const identifySpy = jest.spyOn(Analytics.prototype, 'identify') + const onSpy = jest.spyOn(Analytics.prototype, 'on') + const readySpy = jest.spyOn(Analytics.prototype, 'ready') + const browserLoadSpy = jest.spyOn(AnalyticsBrowser, 'load') + const consoleErrorSpy = jest.spyOn(console, 'error') + + beforeEach(() => { + setGlobalCDNUrl(undefined as any) + mockFetchSettingsResponse() + ;(window as any).analytics = undefined + }) + + describe('Smoke', () => { + test('load should instantiate an ajsBuffered object that resolves into an Analytics object', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + expect(ajsBuffered).toBeInstanceOf( + AnalyticsBuffered + ) + expect(ajsBuffered.instance).toBeUndefined() + const [ajs, ctx] = await ajsBuffered + expect(ajsBuffered.instance).toBeInstanceOf(Analytics) + expect(ajsBuffered.ctx).toBeInstanceOf(Context) + expect(ajs).toBeInstanceOf(Analytics) + expect(ctx).toBeInstanceOf(Context) + }) + + test('If a user sends a single pre-initialized track event, that event gets flushed', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + const trackCtxPromise = ajsBuffered.track('foo', { name: 'john' }) + const result = await trackCtxPromise + expect(result).toBeInstanceOf(Context) + expect(trackSpy).toBeCalledWith('foo', { name: 'john' }) + expect(trackSpy).toBeCalledTimes(1) + }) + + test('"return types should not change over the lifecycle for ordinary methods', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + + const trackCtxPromise1 = ajsBuffered.track('foo', { name: 'john' }) + expect(trackCtxPromise1).toBeInstanceOf(Promise) + const ctx1 = await trackCtxPromise1 + expect(ctx1).toBeInstanceOf(Context) + + // loaded + const trackCtxPromise2 = ajsBuffered.track('foo', { name: 'john' }) + expect(trackCtxPromise2).toBeInstanceOf(Promise) + const ctx2 = await trackCtxPromise2 + expect(ctx2).toBeInstanceOf(Context) + }) + + test('If a user sends multiple events, all of those event gets flushed', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + const trackCtxPromise = ajsBuffered.track('foo', { name: 'john' }) + const trackCtxPromise2 = ajsBuffered.track('bar', { age: 123 }) + const identifyCtxPromise = ajsBuffered.identify('hello') + + await Promise.all([trackCtxPromise, trackCtxPromise2, identifyCtxPromise]) + + expect(trackSpy).toBeCalledWith('foo', { name: 'john' }) + expect(trackSpy).toBeCalledWith('bar', { age: 123 }) + expect(trackSpy).toBeCalledTimes(2) + + expect(identifySpy).toBeCalledWith('hello') + expect(identifySpy).toBeCalledTimes(1) + }) + }) + + describe('Promise API', () => { + describe('.then', () => { + test('.then should be called on success', (done) => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey: 'abc' }) + const newPromise = ajsBuffered.then(([analytics, context]) => { + expect(analytics).toBeInstanceOf(Analytics) + expect(context).toBeInstanceOf(Context) + done() + }) + expect(newPromise).toBeInstanceOf(Promise) + }) + + it('.then should pass to the next .then', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey: 'abc' }) + const obj = ajsBuffered.then(() => ({ foo: 123 } as const)) + expect(obj).toBeInstanceOf(Promise) + await obj.then((el) => expect(el.foo).toBe(123)) + }) + }) + + describe('.catch', () => { + it('should be capable of handling errors if using promise syntax', () => { + browserLoadSpy.mockImplementationOnce((): any => Promise.reject(errMsg)) + + const ajsBuffered = AnalyticsBrowser.load({ writeKey: 'abc' }) + const newPromise = ajsBuffered.catch((reason) => { + expect(reason).toBe(errMsg) + }) + expect(newPromise).toBeInstanceOf(Promise) + expect.assertions(2) + }) + }) + + describe('.finally', () => { + test('success', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey: 'abc' }) + const thenCb = jest.fn() + const finallyCb = jest.fn() + const catchCb = jest.fn() + await ajsBuffered.then(thenCb).catch(catchCb).finally(finallyCb) + expect(catchCb).not.toBeCalled() + expect(finallyCb).toBeCalledTimes(1) + expect(thenCb).toBeCalledTimes(1) + }) + test('rejection', async () => { + browserLoadSpy.mockImplementationOnce((): any => Promise.reject(errMsg)) + const ajsBuffered = AnalyticsBrowser.load({ writeKey: 'abc' }) + const onFinallyCb = jest.fn() + await ajsBuffered + .catch((reason) => { + expect(reason).toBe(errMsg) + }) + .finally(() => { + onFinallyCb() + }) + expect(onFinallyCb).toBeCalledTimes(1) + expect.assertions(2) + }) + }) + }) + + describe('Load failures', () => { + test('rejected promise should work as expected for buffered analytics instances', async () => { + trackSpy.mockImplementationOnce(() => Promise.reject(errMsg)) + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + try { + await ajsBuffered.track('foo', { name: 'john' }) + } catch (err) { + expect(err).toBe(errMsg) + } + expect.assertions(1) + }) + + test('rejected promise should work as expected for initialized analytics instances', async () => { + trackSpy.mockImplementationOnce(() => Promise.reject(errMsg)) + const [analytics] = await AnalyticsBrowser.load({ writeKey }) + try { + await analytics.track('foo', { name: 'john' }) + } catch (err) { + expect(err).toBe(errMsg) + } + expect.assertions(1) + }) + }) + + describe('Snippet / standalone', () => { + test('If a snippet user sends multiple events, all of those event gets flushed', async () => { + const onTrackCb = jest.fn() + const onTrack = ['on', 'track', onTrackCb] + const track = ['track', 'foo'] + const track2 = ['track', 'bar'] + const identify = ['identify'] + + ;(window as any).analytics = [onTrack, track, track2, identify] + + await AnalyticsBrowser.standalone(writeKey) + + await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument. + expect(trackSpy).toBeCalledWith('foo') + expect(trackSpy).toBeCalledWith('bar') + expect(trackSpy).toBeCalledTimes(2) + + expect(identifySpy).toBeCalledWith() + expect(identifySpy).toBeCalledTimes(1) + + expect(onSpy).toBeCalledTimes(1) + + expect(onTrackCb).toBeCalledTimes(2) // gets called once for each track event + expect(onTrackCb).toBeCalledWith('foo', {}, undefined) + expect(onTrackCb).toBeCalledWith('bar', {}, undefined) + }) + test('If a snippet user has an event "fail", it will not create a promise rejection or effect other method calls', async () => { + identifySpy.mockImplementationOnce(() => { + return Promise.reject('identity rejection') + }) + consoleErrorSpy.mockImplementationOnce(() => null) + + const onTrackCb = jest.fn() + const onTrack = ['on', 'track', onTrackCb] + const track = ['track', 'foo'] + const track2 = ['track', 'bar'] + const identify = ['identify'] + + ;(window as any).analytics = [identify, onTrack, track, track2] + + await AnalyticsBrowser.standalone(writeKey) + + await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument. + expect(trackSpy).toBeCalledWith('foo') + expect(trackSpy).toBeCalledWith('bar') + expect(trackSpy).toBeCalledTimes(2) + + expect(identifySpy).toBeCalledWith() + expect(identifySpy).toBeCalledTimes(1) + expect(consoleErrorSpy).toBeCalledTimes(1) + expect(consoleErrorSpy).toBeCalledWith('identity rejection') + + expect(onSpy).toBeCalledTimes(1) + + expect(onTrackCb).toBeCalledTimes(2) // gets called once for each track event + expect(onTrackCb).toBeCalledWith('foo', {}, undefined) + expect(onTrackCb).toBeCalledWith('bar', {}, undefined) + }) + }) + + describe('Emitter methods', () => { + test('If, before initialization, .on("track") is called, the .on method should be called after analytics load', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + const args = ['track', jest.fn()] as const + ajsBuffered.on(...args) + expect(onSpy).not.toHaveBeenCalledWith(...args) + + await ajsBuffered + expect(onSpy).toBeCalledWith(...args) + expect(onSpy).toHaveBeenCalledTimes(1) + }) + + test('If, before initialization .on("track") is called and then .track is called, the callback method should be called after analytics loads', async () => { + const onFnCb = jest.fn() + const analytics = AnalyticsBrowser.load({ writeKey }) + analytics.on('track', onFnCb) + const trackCtxPromise = analytics.track('foo', { name: 123 }) + + expect(onFnCb).not.toHaveBeenCalled() + + await Promise.all([analytics, trackCtxPromise]) + + expect(onSpy).toBeCalledWith('track', onFnCb) + expect(onSpy).toHaveBeenCalledTimes(1) + + expect(onFnCb).toHaveBeenCalledWith('foo', { name: 123 }, undefined) + expect(onFnCb).toHaveBeenCalledTimes(1) + }) + + test('If, before initialization, .ready is called, the callback method should be called after analytics loads', async () => { + const onReadyCb = jest.fn() + const analytics = AnalyticsBrowser.load({ writeKey }) + const onReadyPromise = analytics.ready(onReadyCb) + expect(onReadyCb).not.toHaveBeenCalled() + await onReadyPromise + expect(readySpy).toHaveBeenCalledTimes(1) + expect(onReadyCb).toHaveBeenCalledTimes(1) + expect(readySpy).toHaveBeenCalledWith(expect.any(Function)) + }) + + test('Should work with "on" events if a track event is called after load is complete', async () => { + const onTrackCb = jest.fn() + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + ajsBuffered.on('track', onTrackCb) + await ajsBuffered + await ajsBuffered.track('foo', { name: 123 }) + + expect(onTrackCb).toHaveBeenCalledTimes(1) + expect(onTrackCb).toHaveBeenCalledWith('foo', { name: 123 }, undefined) + }) + test('"on, off, once" should return ajsBuffered', () => { + const analytics = AnalyticsBrowser.load({ writeKey }) + expect( + [ + analytics.on('track', jest.fn), + analytics.off('track', jest.fn), + analytics.once('track', jest.fn), + ].map((el) => el instanceof AnalyticsBuffered) + ).toEqual([true, true, true]) + }) + + test('"emitted" events should be chainable', async () => { + const onTrackCb = jest.fn() + const onIdentifyCb = jest.fn() + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + const identifyResult = ajsBuffered.identify('bar') + const result = ajsBuffered + .on('track', onTrackCb) + .on('identify', onIdentifyCb) + .once('group', jest.fn) + .off('alias', jest.fn) + + expect(result instanceof AnalyticsBuffered).toBeTruthy() + await ajsBuffered.track('foo', { name: 123 }) + expect(onTrackCb).toHaveBeenCalledTimes(1) + expect(onTrackCb).toHaveBeenCalledWith('foo', { name: 123 }, undefined) + + await identifyResult + expect(onIdentifyCb).toHaveBeenCalledTimes(1) + expect(onIdentifyCb).toHaveBeenCalledWith('bar', {}, undefined) + }) + + test('the "this" value of "emitted" event callbacks should be Analytics', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + ajsBuffered.on('track', function onTrackCb(this: any) { + expect(this).toBeInstanceOf(Analytics) + }) + ajsBuffered.once('group', function trackOnceCb(this: any) { + expect(this).toBeInstanceOf(Analytics) + }) + + await Promise.all([ + ajsBuffered.track('foo', { name: 123 }), + ajsBuffered.group('foo'), + ]) + }) + + test('"return types should not change over the lifecycle for chainable methods', async () => { + const ajsBuffered = AnalyticsBrowser.load({ writeKey }) + + const result1 = ajsBuffered.on('track', jest.fn) + expect(result1).toBeInstanceOf(AnalyticsBuffered) + await result1 + // loaded + const result2 = ajsBuffered.on('track', jest.fn) + expect(result2).toBeInstanceOf(AnalyticsBuffered) + }) + }) + + describe('Multi-instance', () => { + it('should not throw an error', async () => { + const ajsBuffered1 = AnalyticsBrowser.load({ writeKey: 'foo' }) + const ajsBuffered2 = AnalyticsBrowser.load({ writeKey: 'abc' }) + expect(ajsBuffered1).toBeInstanceOf(AnalyticsBuffered) + expect(ajsBuffered2).toBeInstanceOf(AnalyticsBuffered) + await ajsBuffered1 + await ajsBuffered2 + }) + }) +}) diff --git a/src/__tests__/cdn.test.ts b/src/__tests__/cdn.test.ts index fafab70b5..8012b22db 100644 --- a/src/__tests__/cdn.test.ts +++ b/src/__tests__/cdn.test.ts @@ -1,6 +1,7 @@ import { AnalyticsBrowser } from '..' import { mocked } from 'ts-jest/utils' import unfetch from 'unfetch' +import { createSuccess } from '../test-helpers/factories' import { setGlobalCDNUrl } from '../lib/parse-cdn' jest.mock('unfetch', () => { @@ -9,18 +10,11 @@ jest.mock('unfetch', () => { const writeKey = 'foo' -const settingsResponse = Promise.resolve({ - json: () => - Promise.resolve({ - integrations: {}, - }), -}) as Promise - -afterEach(() => { +beforeEach(() => { setGlobalCDNUrl(undefined as any) }) -mocked(unfetch).mockImplementation(() => settingsResponse) +mocked(unfetch).mockImplementation(() => createSuccess({ integrations: {} })) it('supports overriding the CDN', async () => { const mockCdn = 'https://cdn.foobar.com' diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index c989c889a..dd9ad396a 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ import { Context } from '@/core/context' import { Plugin } from '@/core/plugin' import { JSDOM } from 'jsdom' @@ -13,7 +14,7 @@ import * as SegmentPlugin from '../plugins/segmentio' import jar from 'js-cookie' import { AMPLITUDE_WRITEKEY, TEST_WRITEKEY } from './test-writekeys' import { PriorityQueue } from '../lib/priority-queue' -import { getCDN } from '../lib/parse-cdn' +import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn' // eslint-disable-next-line @typescript-eslint/no-explicit-any let fetchCalls: Array[] = [] @@ -83,6 +84,10 @@ const enrichBilling: Plugin = { const writeKey = TEST_WRITEKEY +beforeEach(() => { + setGlobalCDNUrl(undefined as any) +}) + describe('Initialization', () => { beforeEach(async () => { jest.resetAllMocks() @@ -177,6 +182,7 @@ describe('Initialization', () => { await sleep(200) expect(ready).toHaveBeenCalled() }) + describe('cdn', () => { it('should get the correct CDN in plugins if the CDN overridden', async () => { const overriddenCDNUrl = 'http://cdn.segment.com' // http instead of https diff --git a/src/__tests__/query-string.integration.test.ts b/src/__tests__/query-string.integration.test.ts index ac3a05824..94eabfd9d 100644 --- a/src/__tests__/query-string.integration.test.ts +++ b/src/__tests__/query-string.integration.test.ts @@ -2,6 +2,7 @@ import { JSDOM } from 'jsdom' import { Analytics } from '../analytics' // @ts-ignore loadLegacySettings mocked dependency is accused as unused import { AnalyticsBrowser } from '../browser' +import { setGlobalCDNUrl } from '../lib/parse-cdn' import { TEST_WRITEKEY } from './test-writekeys' const writeKey = TEST_WRITEKEY @@ -33,6 +34,7 @@ describe('queryString', () => { windowSpy.mockImplementation( () => jsd.window as unknown as Window & typeof globalThis ) + setGlobalCDNUrl(undefined as any) }) it('applies query string logic before analytics is finished initializing', async () => { diff --git a/src/__tests__/standalone-analytics.test.ts b/src/__tests__/standalone-analytics.test.ts index facb3ccac..aa83c81a3 100644 --- a/src/__tests__/standalone-analytics.test.ts +++ b/src/__tests__/standalone-analytics.test.ts @@ -47,8 +47,7 @@ describe('standalone bundle', () => { const segmentDotCom = `foo` beforeEach(async () => { - jest.restoreAllMocks() - jest.resetAllMocks() + ;(window as any).analytics = undefined const html = ` @@ -220,26 +219,23 @@ describe('standalone bundle', () => { }, 0) }) it('sets buffered event emitters before loading destinations', async (done) => { - // @ts-ignore ignore Response required fields - mocked(unfetch).mockImplementation((): Promise => fetchSettings) + mocked(unfetch).mockImplementation(() => fetchSettings as Promise) const operations: string[] = [] track.mockImplementationOnce(() => operations.push('track')) - on.mockImplementationOnce(() => operations.push('on', 'on')) + on.mockImplementationOnce(() => operations.push('on')) register.mockImplementationOnce(() => operations.push('register')) await install() setTimeout(() => { - expect(on).toHaveBeenCalledTimes(2) - expect(on).toHaveBeenCalledWith('initialize', expect.any(Function)) + expect(on).toHaveBeenCalledTimes(1) expect(on).toHaveBeenCalledWith('initialize', expect.any(Function)) expect(operations).toEqual([ // should run before any plugin is registered 'on', - 'on', // should run before any events are sent downstream 'register', // should run after all plugins have been registered diff --git a/src/__tests__/typedef-tests/analytics.ts b/src/__tests__/typedef-tests/analytics.ts new file mode 100644 index 000000000..fd6f08805 --- /dev/null +++ b/src/__tests__/typedef-tests/analytics.ts @@ -0,0 +1,65 @@ +import { Analytics } from '@/analytics' +import { AnalyticsBuffered } from '@/core/buffer' +import { Context } from '@/core/context' +import { AnalyticsBrowser } from '@/browser' +import { assertNotAny, assertIs } from '@/test-helpers/type-assertions' + +/** + * These are general typescript definition tests; + * They aren't meant to be run by anything but the typescript compiler. + */ +export default { + 'Analytics should return AnalyticsBuffered': () => { + const result = AnalyticsBrowser.load({ writeKey: 'abc' }) + assertNotAny(result) + assertIs(result) + }, + 'AnalyticsBuffered should return Promise<[Analytics, Context]> if awaited on.': + async () => { + // @ts-expect-error + await new AnalyticsBuffered(() => null) + + const [analytics, context] = await new AnalyticsBuffered( + () => undefined as unknown as Promise<[Analytics, Context]> + ) + + assertNotAny(analytics) + assertIs(analytics) + + assertNotAny(context) + assertIs(context) + }, + 'Promise API should work': () => { + void new AnalyticsBuffered( + () => undefined as unknown as Promise<[Analytics, Context]> + ) + .then(([analytics, context]) => { + assertNotAny(analytics) + assertIs(analytics) + + assertNotAny(context) + assertIs(context) + }) + .then(() => { + return 'a String!' as const + }) + .then((str) => { + assertNotAny(str) + assertIs<'a String!'>(str) + }) + }, + 'If catch is before "then" in the middleware chain, .then should take into account the catch clause': + () => { + void new AnalyticsBuffered( + () => undefined as unknown as Promise<[Analytics, Context]> + ) + .catch((err: string) => { + assertIs(err) + return 123 + }) + .then((response) => { + assertNotAny(response) + assertIs(response) + }) + }, +} diff --git a/src/browser.ts b/src/browser.ts index d4585d8f1..7d1e1bb6b 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -13,6 +13,15 @@ import { remoteLoader, RemotePlugin } from './plugins/remote-loader' import type { RoutingRule } from './plugins/routing-middleware' import { segmentio, SegmentioSettings } from './plugins/segmentio' import { validation } from './plugins/validation' +import { + AnalyticsBuffered, + PreInitMethodCallBuffer, + flushAnalyticsCallsInNewTask, + flushAddSourceMiddleware, + flushSetAnonymousID, + flushOn, +} from './core/buffer' +import { getSnippetWindowBuffer } from './core/buffer/snippet' export interface LegacyIntegrationConfiguration { /* @deprecated - This does not indicate browser types anymore */ @@ -88,33 +97,6 @@ function hasLegacyDestinations(settings: LegacySettings): boolean { ) } -async function flushBuffered(analytics: Analytics): Promise { - const wa = window.analytics - const buffered = - // @ts-expect-error - wa && wa[0] ? [...wa] : [] - - for (const [operation, ...args] of buffered) { - if ( - // @ts-expect-error - analytics[operation] && - // @ts-expect-error - typeof analytics[operation] === 'function' - ) { - if (operation === 'addSourceMiddleware') { - // @ts-expect-error - await analytics[operation].call(analytics, ...args) - } else { - // flush each individual event as its own task, so not to block initial page loads - setTimeout(() => { - // @ts-expect-error - analytics[operation].call(analytics, ...args) - }, 0) - } - } - } -} - /** * With AJS classic, we allow users to call setAnonymousId before the library initialization. * This is important because some of the destinations will use the anonymousId during the initialization, @@ -123,27 +105,26 @@ async function flushBuffered(analytics: Analytics): Promise { * Also Ensures events can be registered before library initialization. * This is important so users can register to 'initialize' and any events that may fire early during setup. */ -function flushPreBuffer(analytics: Analytics): void { - const wa = window.analytics - const buffered = - // @ts-expect-error - wa && wa[0] ? [...wa] : [] - - const anon = buffered.find(([op]) => op === 'setAnonymousId') - if (anon) { - const [, id] = anon - analytics.setAnonymousId(id) - } +function flushPreBuffer( + analytics: Analytics, + buffer: PreInitMethodCallBuffer +): void { + buffer.push(...getSnippetWindowBuffer()) + flushSetAnonymousID(analytics, buffer) + flushOn(analytics, buffer) +} - const onHandlers = buffered.filter( - ([operation]: [string]) => operation === 'on' - ) - if (onHandlers.length) { - onHandlers.forEach(([operation, ...args]) => { - // @ts-expect-error - analytics[operation].call(analytics, ...args) - }) - } +/** + * Finish flushing buffer and cleanup. + */ +async function flushFinalBuffer( + analytics: Analytics, + buffer: PreInitMethodCallBuffer +): Promise { + await flushAddSourceMiddleware(analytics, buffer) + flushAnalyticsCallsInNewTask(analytics, buffer) + // Clear buffer, just in case analytics is loaded twice; we don't want to fire events off again. + buffer.clear() } async function registerPlugins( @@ -235,56 +216,67 @@ async function registerPlugins( return ctx } -export class AnalyticsBrowser { - static async load( - settings: AnalyticsBrowserSettings, - options: InitOptions = {} - ): Promise<[Analytics, Context]> { - // this is an ugly side-effect, but it's for the benefits of the plugins that get their cdn via getCDN() - if (settings.cdnURL) setGlobalCDNUrl(settings.cdnURL) - const legacySettings = - settings.cdnSettings ?? - (await loadLegacySettings(settings.writeKey, settings.cdnURL)) - - const retryQueue: boolean = - legacySettings.integrations['Segment.io']?.retryQueue ?? true - - const opts: InitOptions = { retryQueue, ...options } - const analytics = new Analytics(settings, opts) - - const plugins = settings.plugins ?? [] - Context.initMetrics(legacySettings.metrics) - - // needs to be flushed before plugins are registered - flushPreBuffer(analytics) - - const ctx = await registerPlugins( - legacySettings, - analytics, - opts, - options, - plugins - ) +async function loadAnalytics( + settings: AnalyticsBrowserSettings, + options: InitOptions = {}, + preInitBuffer: PreInitMethodCallBuffer +): Promise<[Analytics, Context]> { + // this is an ugly side-effect, but it's for the benefits of the plugins that get their cdn via getCDN() + if (settings.cdnURL) setGlobalCDNUrl(settings.cdnURL) - const search = window.location.search ?? '' - const hash = window.location.hash ?? '' + const legacySettings = + settings.cdnSettings ?? + (await loadLegacySettings(settings.writeKey, settings.cdnURL)) - const term = search.length ? search : hash.replace(/(?=#).*(?=\?)/, '') + const retryQueue: boolean = + legacySettings.integrations['Segment.io']?.retryQueue ?? true - if (term.includes('ajs_')) { - await analytics.queryString(term).catch(console.error) - } + const opts: InitOptions = { retryQueue, ...options } + const analytics = new Analytics(settings, opts) - analytics.initialized = true - analytics.emit('initialize', settings, options) + const plugins = settings.plugins ?? [] + Context.initMetrics(legacySettings.metrics) - if (options.initialPageview) { - analytics.page().catch(console.error) - } + // needs to be flushed before plugins are registered + flushPreBuffer(analytics, preInitBuffer) - await flushBuffered(analytics) + const ctx = await registerPlugins( + legacySettings, + analytics, + opts, + options, + plugins + ) + + const search = window.location.search ?? '' + const hash = window.location.hash ?? '' + + const term = search.length ? search : hash.replace(/(?=#).*(?=\?)/, '') + + if (term.includes('ajs_')) { + await analytics.queryString(term).catch(console.error) + } + + analytics.initialized = true + analytics.emit('initialize', settings, options) + + if (options.initialPageview) { + analytics.page().catch(console.error) + } + + await flushFinalBuffer(analytics, preInitBuffer) + + return [analytics, ctx] +} - return [analytics, ctx] +export class AnalyticsBrowser { + static load( + settings: AnalyticsBrowserSettings, + options: InitOptions = {} + ): AnalyticsBuffered { + return new AnalyticsBuffered((preInitBuffer) => + loadAnalytics(settings, options, preInitBuffer) + ) } static standalone( diff --git a/src/core/buffer/__tests__/index.test.ts b/src/core/buffer/__tests__/index.test.ts new file mode 100644 index 000000000..08fbd0feb --- /dev/null +++ b/src/core/buffer/__tests__/index.test.ts @@ -0,0 +1,320 @@ +import { + AnalyticsBuffered, + callAnalyticsMethod, + PreInitMethodCall, + flushAnalyticsCallsInNewTask, + PreInitMethodCallBuffer, +} from '..' +import { Analytics } from '../../../analytics' +import { Context } from '../../context' +import { sleep } from '@/test-helpers/sleep' + +describe('PreInitMethodCallBuffer', () => { + describe('push', () => { + it('should return this', async () => { + const buffer = new PreInitMethodCallBuffer() + const result = buffer.push({} as any) + expect(result).toBeInstanceOf(PreInitMethodCallBuffer) + }) + }) + describe('toArray should return an array', () => { + it('toArray() should convert the map back to an array', async () => { + const buffer = new PreInitMethodCallBuffer() + const method1 = { method: 'foo' } as any + const method2 = { method: 'foo', args: [1] } as any + const method3 = { method: 'bar' } as any + buffer.push(method1, method2, method3) + expect(buffer.toArray()).toEqual([method1, method2, method3]) + }) + }) + + describe('clear', () => { + it('should return this', async () => { + const buffer = new PreInitMethodCallBuffer() + const result = buffer.push({} as any) + expect(result).toBeInstanceOf(PreInitMethodCallBuffer) + }) + }) + describe('getCalls', () => { + it('should return calls', async () => { + const buffer = new PreInitMethodCallBuffer() + + const fooCall1 = { + method: 'foo', + args: ['bar'], + } as any + + const barCall = { + method: 'bar', + args: ['foobar'], + } as any + + const fooCall2 = { + method: 'foo', + args: ['baz'], + } as any + + const calls: PreInitMethodCall[] = [fooCall1, fooCall2, barCall] + const result = buffer.push(...calls) + expect(result.getCalls('foo' as any)).toEqual([fooCall1, fooCall2]) + }) + }) +}) + +describe('AnalyticsBuffered', () => { + describe('Happy path', () => { + it('should return a promise-like object', async () => { + const ajs = new Analytics({ writeKey: 'foo' }) + const ctx = new Context({ type: 'track' }) + const buffered = new AnalyticsBuffered(() => + Promise.resolve<[Analytics, Context]>([ajs, ctx]) + ) + expect(buffered).toBeInstanceOf(AnalyticsBuffered) + expect(typeof buffered.then).toBe('function') + expect(typeof buffered.catch).toBe('function') + expect(typeof buffered.finally).toBe('function') + }) + + it('should have instance and ctx properties defined when loader is done', async () => { + const ajs = new Analytics({ writeKey: 'foo' }) + const ctx = new Context({ type: 'track' }) + const buffered = new AnalyticsBuffered(() => + Promise.resolve<[Analytics, Context]>([ajs, ctx]) + ) + expect(buffered.instance).not.toBeDefined() + expect(buffered.ctx).not.toBeDefined() + await buffered // finish loading + expect(buffered.instance).toBe(ajs) + expect(buffered.ctx).toBe(ctx) + }) + + it('should convert to a promise on await', async () => { + const ajs = new Analytics({ writeKey: 'foo' }) + const ctx = new Context({ type: 'track' }) + const [analytics, context] = await new AnalyticsBuffered(() => { + return Promise.resolve<[Analytics, Context]>([ajs, ctx]) + }) + + expect(analytics).toEqual(ajs) + expect(context).toEqual(ctx) + }) + + it('should set the "this" value of any proxied analytics methods to the analytics instance', async () => { + const ajs = new Analytics({ writeKey: 'foo' }) + jest.spyOn(ajs, 'track').mockImplementation(function (this: Analytics) { + expect(this).toBe(ajs) + return Promise.resolve(ctx) + }) + const ctx = new Context({ type: 'track' }) + const result: [Analytics, Context] = [ajs, ctx] + const buffered = new AnalyticsBuffered(() => Promise.resolve(result)) + await buffered // finish loading + void buffered.track('foo', {}) + expect.assertions(1) + }) + }) + + describe('Unhappy path', () => { + test('Will throw an error if the callback throws', async () => { + try { + new AnalyticsBuffered(() => { + throw 'oops!' + }) + } catch (err) { + expect(err).toBe('oops!') + } + expect.assertions(1) + }) + test('Will throw if a promise rejection', async () => { + try { + await new AnalyticsBuffered(() => Promise.reject('oops!')) + } catch (err) { + expect(err).toBe('oops!') + } + expect.assertions(1) + }) + + test('Will ignore the .then if there is a catch block', () => { + const thenCb = jest.fn() + new AnalyticsBuffered(() => { + return Promise.reject('nope') as any + }) + .then(() => { + thenCb() + }) + .catch((err) => { + expect(err).toBe('nope') + }) + expect(thenCb).not.toBeCalled() + expect.assertions(2) + }) + }) +}) + +describe('callAnalyticsMethod', () => { + let ajs!: Analytics + let resolveSpy!: jest.Mock + let rejectSpy!: jest.Mock + let methodCall!: PreInitMethodCall + beforeEach(() => { + resolveSpy = jest.fn().mockImplementation((el) => `resolved: ${el}`) + rejectSpy = jest.fn().mockImplementation((el) => `rejected: ${el}`) + methodCall = { + args: ['foo', {}], + called: false, + method: 'track', + resolve: resolveSpy, + reject: rejectSpy, + } as PreInitMethodCall + + ajs = new Analytics({ + writeKey: 'abc', + }) + }) + it('should change called to true', async () => { + methodCall.called = false + await callAnalyticsMethod(ajs, methodCall) + expect(methodCall.called).toBe(true) + }) + it('should resolve if an async method is called, like track', async () => { + await callAnalyticsMethod(ajs, methodCall) + expect(resolveSpy).toBeCalled() + }) + + it('should not defer if a synchronous method is called, like "on"', () => { + void callAnalyticsMethod(ajs, { + ...methodCall, + method: 'on', + args: ['foo', jest.fn], + }) + expect(resolveSpy).toBeCalled() + }) + describe('error handling', () => { + it('will catch a promise rejection for async functions', async () => { + const genericError = new Error('An Error') + jest.spyOn(ajs, 'track').mockImplementationOnce(() => { + return Promise.reject(genericError) + }) + await callAnalyticsMethod(ajs, { + ...methodCall, + method: 'track', + } as PreInitMethodCall<'track'>) + + expect(methodCall.resolve).not.toHaveBeenCalled() + expect(methodCall.reject).toBeCalledWith(genericError) + }) + + it('will catch any thrown errors for a non-async functions', () => { + const genericError = new Error('An Error') + jest.spyOn(ajs, 'on').mockImplementationOnce(() => { + throw genericError + }) + void callAnalyticsMethod(ajs, { + ...methodCall, + method: 'on', + args: ['foo', jest.fn()], + } as PreInitMethodCall<'on'>) + + expect(methodCall.resolve).not.toHaveBeenCalled() + expect(methodCall.reject).toBeCalledWith(genericError) + }) + }) + + it('should not resolve and return undefined if previously called', async () => { + methodCall.called = true + const result = await callAnalyticsMethod(ajs, methodCall) + expect(resolveSpy).not.toBeCalled() + expect(result).toBeUndefined() + }) +}) + +describe('flushAnalyticsCallsInNewTask', () => { + test('should defer buffered method calls, regardless of whether or not they are async', async () => { + // @ts-ignore + Analytics.prototype['synchronousMethod'] = () => 123 + + // @ts-ignore + Analytics.prototype['asyncMethod'] = () => Promise.resolve(123) + + const synchronousMethod = { + method: 'synchronousMethod' as any, + args: ['foo'], + called: false, + resolve: jest.fn(), + reject: jest.fn(), + } as PreInitMethodCall + + const asyncMethod = { + method: 'asyncMethod' as any, + args: ['foo'], + called: false, + resolve: jest.fn(), + reject: jest.fn(), + } as PreInitMethodCall + + const buffer = new PreInitMethodCallBuffer().push( + synchronousMethod, + asyncMethod + ) + + flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer) + expect(synchronousMethod.resolve).not.toBeCalled() + expect(asyncMethod.resolve).not.toBeCalled() + await sleep(0) + expect(synchronousMethod.resolve).toBeCalled() + expect(asyncMethod.resolve).toBeCalled() + }) + + test('should handle promise rejections', async () => { + // @ts-ignore + Analytics.prototype['asyncMethod'] = () => Promise.reject('oops!') + + const asyncMethod = { + method: 'asyncMethod' as any, + args: ['foo'], + called: false, + resolve: jest.fn(), + reject: jest.fn(), + } as PreInitMethodCall + + const buffer = new PreInitMethodCallBuffer().push(asyncMethod) + flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer) + await sleep(0) + expect(asyncMethod.reject).toBeCalledWith('oops!') + }) + + test('a thrown error by a synchronous method should not terminate the queue', async () => { + // @ts-ignore + Analytics.prototype['asyncMethod'] = () => Promise.resolve(123) + + // @ts-ignore + Analytics.prototype['synchronousMethod'] = () => { + throw new Error('Ooops!') + } + + const synchronousMethod = { + method: 'synchronousMethod' as any, + args: ['foo'], + called: false, + resolve: jest.fn(), + reject: jest.fn(), + } as PreInitMethodCall + + const asyncMethod = { + method: 'asyncMethod' as any, + args: ['foo'], + called: false, + resolve: jest.fn(), + reject: jest.fn(), + } as PreInitMethodCall + + const buffer = new PreInitMethodCallBuffer().push( + synchronousMethod, + asyncMethod + ) + flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer) + await sleep(0) + expect(synchronousMethod.reject).toBeCalled() + expect(asyncMethod.resolve).toBeCalled() + }) +}) diff --git a/src/core/buffer/index.ts b/src/core/buffer/index.ts new file mode 100644 index 000000000..4213a2684 --- /dev/null +++ b/src/core/buffer/index.ts @@ -0,0 +1,281 @@ +import { Analytics } from '../../analytics' +import { Context } from '../context' +import { isThenable } from '../../lib/is-thenable' + +/** + * The names of any Analytics instance methods that can be called pre-initialization. + * These methods should exist statically on AnalyticsBrowser. + */ +export type PreInitMethodName = + | 'trackSubmit' + | 'trackClick' + | 'trackLink' + | 'trackForm' + | 'pageview' + | 'identify' + | 'reset' + | 'group' + | 'track' + | 'ready' + | 'alias' + | 'debug' + | 'page' + | 'once' + | 'off' + | 'on' + | 'addSourceMiddleware' + | 'addIntegrationMiddleware' + | 'setAnonymousId' + | 'addDestinationMiddleware' + +// Union of all analytics methods that _do not_ return a Promise +type SyncPreInitMethodName = { + [MethodName in PreInitMethodName]: ReturnType< + Analytics[MethodName] + > extends Promise + ? never + : MethodName +}[PreInitMethodName] + +const flushSyncAnalyticsCalls = ( + name: SyncPreInitMethodName, + analytics: Analytics, + buffer: PreInitMethodCallBuffer +): void => { + buffer.getCalls(name).forEach((c) => { + // While the underlying methods are synchronous, the callAnalyticsMethod returns a promise, + // which normalizes success and error states between async and non-async methods, with no perf penalty. + callAnalyticsMethod(analytics, c).catch(console.error) + }) +} + +export const flushAddSourceMiddleware = async ( + analytics: Analytics, + buffer: PreInitMethodCallBuffer +) => { + for (const c of buffer.getCalls('addSourceMiddleware')) { + await callAnalyticsMethod(analytics, c).catch(console.error) + } +} + +export const flushOn = flushSyncAnalyticsCalls.bind(this, 'on') + +export const flushSetAnonymousID = flushSyncAnalyticsCalls.bind( + this, + 'setAnonymousId' +) + +export const flushAnalyticsCallsInNewTask = ( + analytics: Analytics, + buffer: PreInitMethodCallBuffer +): void => { + buffer.toArray().forEach((m) => { + setTimeout(() => { + callAnalyticsMethod(analytics, m).catch(console.error) + }, 0) + }) +} + +/** + * Represents a buffered method call that occurred before initialization. + */ +export interface PreInitMethodCall< + MethodName extends PreInitMethodName = PreInitMethodName +> { + method: MethodName + args: PreInitMethodParams + called: boolean + resolve: (v: ReturnType) => void + reject: (reason: any) => void +} + +export type PreInitMethodParams = + Parameters + +/** + * Infer return type; if return type is promise, unwrap it. + */ +type ReturnTypeUnwrap = Fn extends (...args: any[]) => infer ReturnT + ? ReturnT extends PromiseLike + ? Unwrapped + : ReturnT + : never + +type MethodCallMap = Partial> + +/** + * Represents any and all the buffered method calls that occurred before initialization. + */ +export class PreInitMethodCallBuffer { + private _value = {} as MethodCallMap + + public toArray(): PreInitMethodCall[] { + return Object.values(this._value).reduce((acc, v) => { + return acc.concat(...v) + }, [] as PreInitMethodCall[]) + } + + public getCalls( + methodName: T + ): PreInitMethodCall[] { + return (this._value[methodName] ?? []) as PreInitMethodCall[] + } + + push(...calls: PreInitMethodCall[]): PreInitMethodCallBuffer { + calls.forEach((call) => { + if (this._value[call.method]) { + this._value[call.method]!.push(call) + } else { + this._value[call.method] = [call] + } + }) + return this + } + + clear(): PreInitMethodCallBuffer { + this._value = {} as MethodCallMap + return this + } +} + +/** + * Call method and mark as "called" + * This function should never throw an error + */ +export async function callAnalyticsMethod( + analytics: Analytics, + call: PreInitMethodCall +): Promise { + try { + if (call.called) { + return undefined + } + call.called = true + + const result: ReturnType = ( + analytics[call.method] as Function + )(...call.args) + + if (isThenable(result)) { + // do not defer for non-async methods + await result + } + + call.resolve(result) + } catch (err) { + call.reject(err) + } +} + +type AnalyticsLoader = ( + preInitBuffer: PreInitMethodCallBuffer +) => Promise<[Analytics, Context]> + +export class AnalyticsBuffered implements PromiseLike<[Analytics, Context]> { + instance?: Analytics + ctx?: Context + private preInitBuffer = new PreInitMethodCallBuffer() + private promise: Promise<[Analytics, Context]> + constructor(loader: AnalyticsLoader) { + this.promise = loader(this.preInitBuffer) + this.promise + .then(([ajs, ctx]) => { + this.instance = ajs + this.ctx = ctx + }) + .catch(() => { + // intentionally do nothing... + // this result of this promise will be caught by the 'catch' block on this class. + }) + } + + then( + ...args: [ + onfulfilled: + | ((instance: [Analytics, Context]) => T1 | PromiseLike) + | null + | undefined, + onrejected?: (reason: unknown) => T2 | PromiseLike + ] + ) { + return this.promise.then(...args) + } + + catch( + ...args: [ + onrejected?: + | ((reason: any) => TResult | PromiseLike) + | undefined + | null + ] + ) { + return this.promise.catch(...args) + } + + finally(...args: [onfinally?: (() => void) | undefined | null]) { + return this.promise.finally(...args) + } + + trackSubmit = this._createMethod('trackSubmit') + trackClick = this._createMethod('trackClick') + trackLink = this._createMethod('trackLink') + pageView = this._createMethod('pageview') + identify = this._createMethod('identify') + reset = this._createMethod('reset') + group = this._createMethod('group') + track = this._createMethod('track') + ready = this._createMethod('ready') + alias = this._createMethod('alias') + debug = this._createChainableMethod('debug') + page = this._createMethod('page') + once = this._createChainableMethod('once') + off = this._createChainableMethod('off') + on = this._createChainableMethod('on') + addSourceMiddleware = this._createMethod('addSourceMiddleware') + addIntegrationMiddleware = this._createMethod('addIntegrationMiddleware') + setAnonymousId = this._createMethod('setAnonymousId') + addDestinationMiddleware = this._createMethod('addDestinationMiddleware') + + private _createMethod(methodName: T) { + return ( + ...args: Parameters + ): Promise> => { + if (this.instance) { + return (this.instance[methodName] as Function)(...args) + } + + return new Promise((resolve, reject) => { + this.preInitBuffer.push({ + method: methodName, + args, + resolve: resolve, + reject: reject, + called: false, + } as PreInitMethodCall) + }) + } + } + + /** + * These are for methods that where determining when the method gets "flushed" is not important. + * These methods will resolve when analytics is fully initialized, and return type (other than Analytics)will not be available. + */ + private _createChainableMethod(methodName: T) { + return (...args: Parameters): AnalyticsBuffered => { + if (this.instance) { + const method = this.instance[methodName] as (...args: any[]) => void + void method(...args) + } else { + this.preInitBuffer.push({ + method: methodName, + args, + resolve: () => {}, + reject: console.error, + called: false, + } as PreInitMethodCall) + } + + return this + } + } +} diff --git a/src/core/buffer/snippet.ts b/src/core/buffer/snippet.ts new file mode 100644 index 000000000..4d09195fb --- /dev/null +++ b/src/core/buffer/snippet.ts @@ -0,0 +1,39 @@ +import type { + PreInitMethodCall, + PreInitMethodName, + PreInitMethodParams, +} from '.' + +const normalizeSnippetBuffer = (buffer: SnippetBuffer): PreInitMethodCall[] => { + return buffer.map( + ([methodName, ...args]) => + ({ + method: methodName, + resolve: () => {}, + reject: console.error, + args, + called: false, + } as PreInitMethodCall) + ) +} + +type SnippetWindowBufferedMethodCall< + MethodName extends PreInitMethodName = PreInitMethodName +> = [MethodName, ...PreInitMethodParams] + +/** + * A list of the method calls before initialization for snippet users + * For example, [["track", "foo", {bar: 123}], ["page"], ["on", "ready", function(){..}] + */ +type SnippetBuffer = SnippetWindowBufferedMethodCall[] + +/** + * Fetch the buffered method calls from the window object and normalize them. + */ +export const getSnippetWindowBuffer = (): PreInitMethodCall[] => { + const wa = window.analytics + const buffered = + // @ts-expect-error + (wa && wa[0] ? [...wa] : []) as SnippetBuffer + return normalizeSnippetBuffer(buffered) +} diff --git a/src/lib/__tests__/is-thenable.test.ts b/src/lib/__tests__/is-thenable.test.ts new file mode 100644 index 000000000..8c61439bd --- /dev/null +++ b/src/lib/__tests__/is-thenable.test.ts @@ -0,0 +1,39 @@ +import { isThenable } from '../is-thenable' + +describe('isThenable', () => { + test('es6 promises', () => { + const p = Promise.resolve(1) + expect(isThenable(p)).toBeTruthy() + }) + + test('on the prototype', () => { + class Foo { + then() { + return '123' + } + } + const p = new Foo() + expect(isThenable(p)).toBeTruthy() + }) + + test('on the pojo', () => { + const p = { + then: () => { + return '123' + }, + } + expect(isThenable(p)).toBeTruthy() + }) + + test('unhappy path', () => { + expect(isThenable(null)).toBeFalsy() + expect(isThenable(undefined)).toBeFalsy() + expect(isThenable({})).toBeFalsy() + expect( + isThenable({ + then: true, + }) + ).toBeFalsy() + expect(isThenable(new (class Foo {})())).toBeFalsy() + }) +}) diff --git a/src/lib/is-thenable.ts b/src/lib/is-thenable.ts new file mode 100644 index 000000000..fc85d3291 --- /dev/null +++ b/src/lib/is-thenable.ts @@ -0,0 +1,9 @@ +/** + * Check if thenable + * (instanceof Promise doesn't respect realms) + */ +export const isThenable = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + 'then' in value && + typeof (value as any).then === 'function' diff --git a/src/test-helpers/factories.ts b/src/test-helpers/factories.ts new file mode 100644 index 000000000..3e9784d37 --- /dev/null +++ b/src/test-helpers/factories.ts @@ -0,0 +1,8 @@ +export const createSuccess = (body: any) => { + return Promise.resolve({ + json: () => Promise.resolve(body), + ok: true, + status: 200, + statusText: 'OK', + }) as Promise +} diff --git a/src/test-helpers/sleep.ts b/src/test-helpers/sleep.ts new file mode 100644 index 000000000..a674633fb --- /dev/null +++ b/src/test-helpers/sleep.ts @@ -0,0 +1,4 @@ +export const sleep = (time: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, time) + }) diff --git a/src/test-helpers/type-assertions.ts b/src/test-helpers/type-assertions.ts new file mode 100644 index 000000000..8557ee58c --- /dev/null +++ b/src/test-helpers/type-assertions.ts @@ -0,0 +1,15 @@ +type IsAny = unknown extends T ? (T extends {} ? T : never) : never +type NotAny = T extends IsAny ? never : T +type NotUnknown = unknown extends T ? never : T + +type NotTopType = NotAny & NotUnknown + +// this is not meant to be run, just for type tests +export function assertNotAny(val: NotTopType) { + console.log(val) +} + +// this is not meant to be run, just for type tests +export function assertIs(val: T) { + console.log(val) +}