diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..a44b6a0 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,52 @@ +name: Release Candidate + +on: + pull_request: + types: [closed] + branches: + - develop + +jobs: + release_candidate: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Generate RC version + id: rc_version + run: | + # Get the latest tag + latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + + # Extract version number and increment patch + version=$(echo $latest_tag | sed 's/^v//' | awk -F. '{$NF = $NF + 1;} 1' | sed 's/ /./g') + + # Generate RC version + rc_version="rc-v${version}-${{ github.run_number }}" + + echo "RC_VERSION=$rc_version" >> $GITHUB_OUTPUT + +# - name: Create Release Candidate +# uses: rymndhng/release-on-push-action@master +# with: +# bump_version_scheme: none +# tag_prefix: "" +# tag_name: ${{ steps.rc_version.outputs.RC_VERSION }} +# release_name: "Release Candidate ${{ steps.rc_version.outputs.RC_VERSION }}" +# release_body: | +# This is a release candidate created from the develop branch. +# RC Version: ${{ steps.rc_version.outputs.RC_VERSION }} +# +# Changes in this release candidate: +# ${{ github.event.pull_request.title }} +# +# For full changes, please see the pull request: ${{ github.event.pull_request.html_url }} + + outputs: + rc_version: ${{ steps.rc_version.outputs.RC_VERSION }} diff --git a/packages/javascript-sdk/src/utils/helpers.ts b/packages/javascript-sdk/src/utils/helpers.ts index 036c108..b0d162e 100644 --- a/packages/javascript-sdk/src/utils/helpers.ts +++ b/packages/javascript-sdk/src/utils/helpers.ts @@ -41,11 +41,14 @@ export function getUtmParams(): Record { export function parseQueryString(queryString: string): Record { const params: Record = {}; - const queries = queryString.substring(1).split('&'); + // Remove the leading '?' if present + const queries = queryString.replace(/^\?/, '').split('&'); for (let i = 0; i < queries.length; i++) { const pair = queries[i].split('='); - params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ''); + if (pair[0] !== '') { + params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ''); + } } return params; @@ -56,7 +59,7 @@ export function isString(value: any): boolean { } export function isObject(value: any): boolean { - return value && typeof value === 'object' && value.constructor === Object; + return value !== null && typeof value === 'object' && value.constructor === Object; } diff --git a/packages/javascript-sdk/test/unit/core/server-side-client.test.ts b/packages/javascript-sdk/test/unit/core/server-side-client.test.ts deleted file mode 100644 index ea10147..0000000 --- a/packages/javascript-sdk/test/unit/core/server-side-client.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { UsermavenClient } from '../../../src/core/client'; -import { Config } from '../../../src/core/config'; -import * as commonUtils from '../../../src/utils/common'; -import * as helpers from '../../../src/utils/helpers'; - -describe('UsermavenClient (Server-side)', () => { - let client: UsermavenClient; - let trackSpy: ReturnType; - let trackInternalSpy: ReturnType; - const mockConfig: Config = { - key: 'test-api-key', - trackingHost: 'https://test.usermaven.com', - }; - - beforeEach(() => { - vi.spyOn(commonUtils, 'isWindowAvailable').mockReturnValue(false); - vi.spyOn(helpers, 'generateId').mockReturnValue('mocked-id'); - - // Create spies for both track and trackInternal methods - trackSpy = vi.fn(); - trackInternalSpy = vi.fn(); - - // Create the client instance - client = new UsermavenClient(mockConfig); - - // Replace both track and trackInternal methods with our spies - client.track = trackSpy; - (client as any).trackInternal = trackInternalSpy; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should initialize without browser-specific features', () => { - expect(client['autoCapture']).toBeUndefined(); - expect(client['formTracking']).toBeUndefined(); - expect(client['pageviewTracking']).toBeUndefined(); - expect(client['rageClick']).toBeUndefined(); - expect(client['cookieManager']).toBeUndefined(); - }); - - it('should use HttpsTransport on server-side', () => { - expect(client['transport'].constructor.name).toBe('HttpsTransport'); - }); - - it('should track events on server-side', () => { - const eventName = 'server_event'; - const eventPayload = { key: 'value' }; - client.track(eventName, eventPayload); - expect(trackSpy).toHaveBeenCalledWith(eventName, eventPayload); - }); - - - it('should identify users on server-side', async () => { - const userData = { id: 'server_user_123', email: 'server@example.com' }; - await client.id(userData); - expect(trackSpy).toHaveBeenCalledWith('user_identify', expect.objectContaining({ - ...userData, - anonymous_id: 'mocked-id' - })); - }); - - it('should handle group method on server-side', async () => { - const companyProps = { id: 'server_company_123', name: 'Server Company', created_at: '2023-01-01' }; - await client.group(companyProps); - expect(trackSpy).toHaveBeenCalledWith('group', expect.objectContaining(companyProps)); - }); - - it('should not throw error for pageview method on server-side', () => { - expect(() => client.pageview()).not.toThrow(); - expect(trackSpy).not.toHaveBeenCalled(); - }); - - it('should reset client state on server-side', async () => { - const persistenceSpy = vi.spyOn(client['persistence'], 'clear'); - await client.reset(); - expect(persistenceSpy).toHaveBeenCalled(); - }); - - it('should generate a new anonymous ID on server-side', () => { - expect(client['anonymousId']).toBe('mocked-id'); - expect(helpers.generateId).toHaveBeenCalled(); - }); - - it('should handle complex nested object payload', () => { - const complexPayload = { - user: { - id: 1, - name: 'John Doe', - custom: { - theme: 'dark', - notifications: true - } - } - }; - expect(() => client.track('complex_event', complexPayload)).not.toThrow(); - expect(trackSpy).toHaveBeenCalledWith('complex_event', complexPayload); - }); - - it('should throw an error for invalid company properties', () => { - const invalidProps = { id: 'company123' }; - expect(() => client.group(invalidProps as any)).rejects.toThrow('Company properties must include id, name, and created_at'); - }); -}); diff --git a/packages/javascript-sdk/test/unit/utils/helpers.test.ts b/packages/javascript-sdk/test/unit/utils/helpers.test.ts new file mode 100644 index 0000000..743327f --- /dev/null +++ b/packages/javascript-sdk/test/unit/utils/helpers.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + generateId, + isValidEmail, + debounce, + getUtmParams, + parseQueryString, + isString, + isObject, + parseLogLevel +} from '../../../src/utils/helpers'; +import { LogLevel } from '../../../src/utils/logger'; +import * as commonUtils from '../../../src/utils/common'; + +// Mock dependencies +vi.mock('../../../src/utils/common', () => ({ + generateRandom: vi.fn() +})); + +describe('Helper Functions', () => { + describe('generateId', () => { + it('should call generateRandom with 10', () => { + generateId(); + expect(commonUtils.generateRandom).toHaveBeenCalledWith(10); + }); + }); + + describe('isValidEmail', () => { + it('should return true for valid emails', () => { + expect(isValidEmail('test@example.com')).toBe(true); + expect(isValidEmail('test.name+tag@example.co.uk')).toBe(true); + }); + + it('should return false for invalid emails', () => { + expect(isValidEmail('notanemail')).toBe(false); + expect(isValidEmail('missing@tld')).toBe(false); + expect(isValidEmail('@missingusername.com')).toBe(false); + }); + }); + + describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should debounce function calls', () => { + const func = vi.fn(); + const debouncedFunc = debounce(func, 1000); + + debouncedFunc(); + debouncedFunc(); + debouncedFunc(); + + expect(func).not.toHaveBeenCalled(); + + vi.runAllTimers(); + + expect(func).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUtmParams', () => { + const originalWindow = global.window; + + beforeEach(() => { + // @ts-ignore + delete global.window; + // @ts-ignore + global.window = { location: { search: '' } }; + }); + + afterEach(() => { + global.window = originalWindow; + }); + + it('should extract UTM params from URL', () => { + global.window.location.search = '?utm_source=test&utm_medium=email&utm_campaign=summer'; + expect(getUtmParams()).toEqual({ + source: 'test', + medium: 'email', + campaign: 'summer' + }); + }); + + it('should return an empty object if no UTM params', () => { + global.window.location.search = '?param1=value1¶m2=value2'; + expect(getUtmParams()).toEqual({}); + }); + }); + + describe('parseQueryString', () => { + it('should parse query string correctly', () => { + const queryString = '?param1=value1¶m2=value2¶m3=value%20with%20spaces'; + expect(parseQueryString(queryString)).toEqual({ + param1: 'value1', + param2: 'value2', + param3: 'value with spaces' + }); + }); + + it('should handle empty query string', () => { + expect(parseQueryString('')).toEqual({}); + }); + }); + + describe('isString', () => { + it('should return true for strings', () => { + expect(isString('test')).toBe(true); + expect(isString(new String('test'))).toBe(true); + }); + + it('should return false for non-strings', () => { + expect(isString(123)).toBe(false); + expect(isString({})).toBe(false); + expect(isString(null)).toBe(false); + expect(isString(undefined)).toBe(false); + }); + }); + + describe('isObject', () => { + it('should return true for plain objects', () => { + expect(isObject({})).toBe(true); + expect(isObject({ key: 'value' })).toBe(true); + }); + + it('should return false for non-objects and non-plain objects', () => { + expect(isObject(null)).toBe(false); + expect(isObject([])).toBe(false); + expect(isObject('string')).toBe(false); + expect(isObject(123)).toBe(false); + expect(isObject(new Date())).toBe(false); + }); + }); + + describe('parseLogLevel', () => { + it('should parse valid log levels', () => { + expect(parseLogLevel('ERROR')).toBe(LogLevel.ERROR); + expect(parseLogLevel('WARN')).toBe(LogLevel.WARN); + expect(parseLogLevel('INFO')).toBe(LogLevel.INFO); + expect(parseLogLevel('DEBUG')).toBe(LogLevel.DEBUG); + }); + + it('should be case-insensitive', () => { + expect(parseLogLevel('error')).toBe(LogLevel.ERROR); + expect(parseLogLevel('WaRn')).toBe(LogLevel.WARN); + }); + + it('should return ERROR for null input', () => { + expect(parseLogLevel(null)).toBe(LogLevel.ERROR); + }); + + it('should return ERROR for invalid input', () => { + expect(parseLogLevel('INVALID')).toBe(LogLevel.ERROR); + expect(parseLogLevel('123')).toBe(LogLevel.ERROR); + }); + }); +}); diff --git a/packages/javascript-sdk/test/unit/utils/queue.test.ts b/packages/javascript-sdk/test/unit/utils/queue.test.ts new file mode 100644 index 0000000..5c057b6 --- /dev/null +++ b/packages/javascript-sdk/test/unit/utils/queue.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RetryQueue } from '../../../src/utils/queue'; +import { Transport } from '../../../src/core/types'; +import { LocalStoragePersistence } from '../../../src/persistence/local-storage'; +import * as commonUtils from '../../../src/utils/common'; + +// Mock dependencies +vi.mock('../../../src/core/types'); +vi.mock('../../../src/utils/logger', () => ({ + getLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }), +})); +vi.mock('../../../src/persistence/local-storage'); +vi.mock('../../../src/utils/common'); + +describe('RetryQueue', () => { + let retryQueue: RetryQueue; + let mockTransport: jest.Mocked; + let mockPersistence: jest.Mocked; + + beforeEach(() => { + vi.useFakeTimers(); + mockTransport = { send: vi.fn().mockResolvedValue(undefined) } as any; + mockPersistence = { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + clear: vi.fn(), + } as any; + vi.mocked(LocalStoragePersistence).mockImplementation(() => mockPersistence); + vi.mocked(commonUtils.isWindowAvailable).mockReturnValue(true); + + retryQueue = new RetryQueue(mockTransport); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('should add items to the queue', () => { + const payload = { data: 'test' }; + retryQueue.add(payload); + expect(mockPersistence.set).toHaveBeenCalledWith('queue', expect.any(String)); + }); + + it('should process batch when online', async () => { + const payload1 = { data: 'test1' }; + const payload2 = { data: 'test2' }; + retryQueue.add(payload1); + retryQueue.add(payload2); + + await vi.runOnlyPendingTimersAsync(); + await vi.runOnlyPendingTimersAsync(); // Run twice to ensure the batch is processed + + expect(mockTransport.send).toHaveBeenCalledWith([payload1, payload2]); + }); + + it('should retry failed batches', async () => { + const payload = { data: 'test' }; + retryQueue.add(payload); + + mockTransport.send.mockRejectedValueOnce(new Error('Network error')); + mockTransport.send.mockResolvedValueOnce(undefined); + + await vi.runOnlyPendingTimersAsync(); + await vi.advanceTimersByTimeAsync(retryQueue['retryInterval']); + await vi.runOnlyPendingTimersAsync(); + + expect(mockTransport.send).toHaveBeenCalledTimes(2); + }); + + it('should discard items after max retries', async () => { + const payload = { data: 'test' }; + retryQueue.add(payload); + + mockTransport.send.mockRejectedValue(new Error('Network error')); + + for (let i = 0; i <= retryQueue['maxRetries']; i++) { + await vi.runOnlyPendingTimersAsync(); + await vi.advanceTimersByTimeAsync(retryQueue['retryInterval']); + } + + expect(mockTransport.send).toHaveBeenCalledTimes(retryQueue['maxRetries'] + 1); // Initial + maxRetries + }); + + it('should load queue from storage on initialization', () => { + const storedQueue = JSON.stringify([{ payload: { data: 'stored' }, retries: 0, timestamp: Date.now() }]); + mockPersistence.get.mockReturnValueOnce(storedQueue); + + new RetryQueue(mockTransport); + + expect(mockPersistence.get).toHaveBeenCalledWith('queue'); + }); + + it('should handle offline/online transitions', async () => { + const payload = { data: 'test' }; + retryQueue.add(payload); + + // Simulate going offline + window.dispatchEvent(new Event('offline')); + await vi.runOnlyPendingTimersAsync(); + expect(mockTransport.send).not.toHaveBeenCalled(); + + // Simulate going back online + window.dispatchEvent(new Event('online')); + await vi.runOnlyPendingTimersAsync(); + + expect(mockTransport.send).toHaveBeenCalledWith([payload]); + }); +});